Pull to refresh

React, JSX, импорт ES модулей (в том числе динамический) в браузере без Webpack

Reading time 9 min
Views 15K
Original author: Kirill Konshin

Эта статья — попытка свести воедино имеющиеся на текущий момент средства и выяснить, возможно ли создавать production ready приложения на React без предварительной компиляции сборщиками типа Webpack, или по крайней мере свести такую компиляцию к минимуму.


Все описанное носит очень экспериментальный характер и я осознанно местами срезаю углы. Я ни в коем случае не рекомендую делать что-то такое на реальном продакшене.


Возможность использовать ECMAScript modules (<script type="module"/> с импортами вида import Foo from './foo'; и import('./Foo')) прямо в браузере давно не новость, это хорошо поддерживаемы функционал: https://caniuse.com/#feat=es6-module.


Но в реальности мы импортируем не только свои модули, но и библиотеки. Есть отличная статья на эту тему: https://salomvary.com/es6-modules-in-browsers.html. И другая не менее хорошая статья достойная упоминания https://github.com/stken2050/esm-bundlerless.


Среди прочих важных вещей из этих статей, эти пункты наиболее важны для создания React приложения:


  • Поддержка package specifier imports (или import maps): когда мы пишем import React from 'react' на самом деле мы должны импортировать что-то подобное https://cdn.com/react/react.production.js
  • Поддержка UMD: React до сих пор распространяется как UMD и на данный момент авторы все еще не пришли к согласию как распространять библиотеку в виде модуля
  • JSX
  • Импорт CSS

Давайте пройдем по всем пунктам по очереди.


Структура проекта


Первым делом определим структуру проекта:


  • node_modules очевидно это место куда будут поставлены зависимости
  • src директория с index*.html и сервисными скриптами
    • app непосредственно код приложения на React

Поддержка package specifier imports


Чтобы использовать React посредством import React from 'react'; мы должны сказать браузеру где искать настоящий исходник, т.к. react это не реальный файл, а указатель на библиотеку. Для этого есть заглушка https://github.com/guybedford/es-module-shims.


Давайте установим заглушку и React:


$ npm i es-module-shims react react-dom --save

Запустить приложение будем из файла public/index-dev.html:


<!DOCTYPE html>
<html>
<body>

  <div id="root"></div>

  <script defer src="../node_modules/es-module-shims/dist/es-module-shims.js"></script>

  <script type="importmap-shim">
    {
      "imports": {
        "react": "../node_modules/react/umd/react.development.js",
        "react-dom": "../node_modules/react-dom/umd/react-dom.development.js"
      }
    }
  </script>

  <script type="module-shim">
    import './app/index.jsx';
  </script>

</body>
</html>

Где src/app/index.jsx выглядит примерно так:


import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';

(async () => {
  const {Button} = await import('./Button.jsx');
  const root = document.getElementById('root');
  ReactDOM.render((
    <div>
      <Button>Direct</Button>
    </div>
  ), root);
})();

А src/app/Button.jsx так:


import React from 'react';
export const Button = ({children}) => <button>{children}</button>;

Будет ли это работать? Конечно же нет. Даже не смотря на то, что все успешно импортируется откуда надо.


Переходим к следующей проблеме.


Поддержка UMD


Динамический способ


Исходя из того, что React распространяется как UMD, он не может быть импортирован напрямую, даже через заглушку (если тикет закрыли как починенный — шаг можно пропустить). Нам нужно каким-то образом пропатчить исходник чтобы он стал совместим.


Приведенные выше статьи натолкнули меня на идею использовать для этого Service Workers, который может перехватывать и изменять сетевые запросы и ответы. Создадим главную точку входа src/index.js, где мы будем настраивать SW и приложение App и будем использовать ее вместо прямого вызова приложения (src/app/index.jsx):


(async () => {

  try {
    const registration = await navigator.serviceWorker.register('sw.js');
    await navigator.serviceWorker.ready;

    const launch = async () => import("./app/index.jsx");

    // это запустит SW сразу если он уже был установлен
    // https://developers.google.com/web/fundamentals/primers/service-workers/lifecycle#clientsclaim
    if (navigator.serviceWorker.controller) {
      await launch();
    } else {
      navigator.serviceWorker.addEventListener('controllerchange', launch);
    }

  } catch (error) {
    console.error('Service worker registration failed', error);
  }
})();

Создадим Service Worker (src/sw.js):


//это необходимо для немедленного перехвата запросов сразу после установки
//@see https://developers.google.com/web/fundamentals/primers/service-workers/lifecycle#clientsclaim
self.addEventListener('activate', event => event.waitUntil(clients.claim()));

const globalMap = {
    'react': 'React',
    'react-dom': 'ReactDOM'
};

const getGlobalByUrl = (url) => Object.keys(globalMap).reduce((res, key) => {
    if (res) return res;
    if (matchUrl(url, key)) return globalMap[key];
    return res;
}, null);

const matchUrl = (url, key) => url.includes(`/${key}/`);

self.addEventListener('fetch', (event) => {

  const {request: {url}} = event;

  console.log('Req', url);

  const fileName = url.split('/').pop();
  const ext = fileName.includes('.') ? url.split('.').pop() : '';

  if (!ext && !url.endsWith('/')) {
    url = url + '.jsx';
  }

  if (globalMap && Object.keys(globalMap).some(key => matchUrl(url, key))) {
    event.respondWith(
      fetch(url)
        .then(response => response.text())
        .then(body => new Response(`
          const head = document.getElementsByTagName('head')[0];
          const script = document.createElement('script');
          script.setAttribute('type', 'text/javascript');
          script.appendChild(document.createTextNode(
            ${JSON.stringify(body)}
          ));
          head.appendChild(script);
          export default window.${getGlobalByUrl(url)};
        `, {
          headers: new Headers({
            'Content-Type': 'application/javascript'
          })
        })
      )
    )
  } else if (url.endsWith('.js')) { // rewrite for import('./Panel') with no extension
    event.respondWith(
      fetch(url)
        .then(response => response.text())
        .then(body => new Response(
          body,
          {
            headers: new Headers({
              'Content-Type': 'application/javascript'
            })
        })
      )
    )
  }

});

Подытожим что было сделано:


  1. Мы создали карту экспортов, которая связывает название пакета с глобальной переменной
  2. Создали тег script в head с содержимым скрипта обернутого в UMD
  3. Экспортировали глобальную переменную как экспорт по-умолчанию

Для демо такой брутальный патчинг будет достаточным, но это может сработать не со всеми UMD обертками. Что то более надежное может быть использовано взамен.


Теперь поменяем src/index-dev.html чтобы использовать конфигурационный скрипт:


<!DOCTYPE html>
<html>
<body>

  <div id="root"></div>

  <script defer src="../node_modules/es-module-shims/dist/es-module-shims.js"></script>

  <script type="importmap-shim">... все то же самое</script>

  <!-- заменяем app/index.jsx на index.js -->
  <script type="module-shim" src="index.js"></script>

</body>
</html>

Теперь мы можем импортировать React и React DOM.


Статический путь


Следует заметить, что есть и другой путь. Есть неофициальная ES сборка React:


npm install esm-react --save

Карта импортов будет выглядеть так:


{
  "imports": {
    "react": "../node_modules/esm-react/src/react.js",
    "react-dom": "../node_modules/esm-react/src/react-dom.js"
  }
}

Но к сожалению проект весьма сильно отстает, последняя версия 16.8.3 в то время как React уже 16.10.2.


JSX


Есть два способа скомпилировать JSX. Мы можем либо предварительно собрать традиционным Бабелем из консоли, либо это можно сделать в рантайме браузера. Для продакшена само собой пред-компиляция предпочтительнее, но в режиме разработки можно и в рантайме. Раз у нас уже есть Service Worker, им и воспользуемся.


Установим особый пакет с Babel:


$ npm install @babel/standalone --save-dev

Теперь добавим следующее в Service Worker (src/sw.js):


# src/sw.js
// в самый верх файла
importScripts('../node_modules/@babel/standalone/babel.js');

// активация как и прежде

self.addEventListener('fetch', (event) => {

  // все то же самое что раньше

  } else if (url.endsWith('.jsx')) {
    event.respondWith(
      fetch(url)
        .then(response => response.text())
        .then(body => new Response(
          //TODO Кеш
          Babel.transform(body, {
            presets: [
              'react',
            ],
            plugins: [
              'syntax-dynamic-import'
            ],
              sourceMaps: true
            }).code,
            { 
              headers: new Headers({
                'Content-Type': 'application/javascript'
              })
            })
        )
    )
  }

});

Здесь мы использовали тот же подход с перехватом сетевых запросов и их переписыванием, мы использовали Babel для трансформации оригинального исходного кода. Обратите внимание, что плагин для динамических импортов называется syntax-dynamic-import, не так как обычно @babel/plugin-syntax-dynamic-import потому что это Standalone версия.


CSS


В упомянутой статье автор использовал текстовую трансформацию, мы зайдем немного дальше и внедрим CSS на страницу. Для этого снова будем использовать Service Worker (src/sw.js):


// все как и прежде

self.addEventListener('fetch', (event) => {

  // все что было до этого + Бабель

  } else if (url.endsWith('.css')) {
    event.respondWith(
      fetch(url)
        .then(response => response.text())
        .then(body => new Response(
          `
            const head = document.getElementsByTagName('head')[0];
            const style = document.createElement('style');
            style.setAttribute('type', 'text/css');
            style.appendChild(document.createTextNode(
              ${JSON.stringify(body)}
            ));
            head.appendChild(style);
            export default null;
          `,
          {
            headers: new Headers({
              'Content-Type': 'application/javascript'
            })
          })
        )
    );
  }

});

Вуаля! Если сейчас открыть src/index-dev.html в браузере мы увидим кнопки. Убедитесь что нужный Service Worker установился и ни с чем не конфликтует. Если вы не уверены, то на всякий случай можно открыть Dev Tools, зайти в Application, там в Service Workers, и нажать Unregister у всех зарегистрированных worker'ов, а затем перезагрузить страницу.


Продакшен


Код выше работает как и положено в режиме разработки, но само собой мы не хотим заставлять пользователей сайта компилировать код у себя в браузерах, это совершенно непрактично. Давайте сделаем некий минималистичный продакшен режим.


Создадим отдельную точку входа src/index.html:


<!DOCTYPE html>
<html>
<body>

<div id="root"></div>

<script type="module" src="index.js"></script>

</body>
</html>

Как можно увидеть, заглушек тут нет, мы будем использовать другой метод для переписывания имен пакетов. Поскольку для компиляции JSX нам снова нужен Бабель, мы воспользуемся им для переписки путей вместо importMap.json для заглушки. Установим нужные пакеты:


$ npm install @babel/cli @babel/core @babel/preset-react @babel/plugin-syntax-dynamic-import babel-plugin-module-resolver --save-dev

Добавим секцию со скриптами в package.json:


{
  "scripts": {
    "start": "npm run build -- --watch",
    "build": "babel src/app --out-dir build/app --source-maps --copy-files"
  }
}

Добавим файл .babelrc.js:


module.exports = {
  presets: [
    '@babel/preset-react'
  ],
  plugins: [
    '@babel/plugin-syntax-dynamic-import',
    [
      'babel-plugin-module-resolver',
      {
        alias: {
          'react': './node_modules/react/umd/react.development.js',
          'react-dom': './node_modules/react-dom/umd/react-dom.development.js'
        },
        // подменяем путь чтоб оставаться внутри директории build
        resolvePath: (sourcePath, currentFile, opts) => resolvePath(sourcePath, currentFile, opts).replace('../../', '../')
      }
    ]
  ]
}

Следует иметь в виду, что этот файл будет использоваться только для продакшена, в режиме разработки мы настраиваем Babel в Service Worker.


Добавим боевой режим в Service Worker:


// src/index.js
if ('serviceWorker' in navigator) {
    (async () => {

        try {

            // добавим это
            const production = !window.location.toString().includes('index-dev.html');

            const config = {
                globalMap: {
                    'react': 'React',
                    'react-dom': 'ReactDOM'
                },
                production
            };

            const registration = await navigator.serviceWorker.register('sw.js?' + JSON.stringify(config));

            await navigator.serviceWorker.ready;

            const launch = async () => {
                if (production) {
                    await import("./app/index.js");
                } else {
                    await import("./app/index.jsx");
                }
            };

            // https://developers.google.com/web/fundamentals/primers/service-workers/lifecycle#clientsclaim
            if (navigator.serviceWorker.controller) {
                await launch();
            } else {

navigator.serviceWorker.addEventListener('controllerchange', launch);
            }

        } catch (error) {
            console.error('Service worker registration failed', error);
        }

    })();
} else {
    alert('Service Worker is not supported');
}

Добавим условия в src/sw.js:


// src/sw.js
const {globalMap, production} = JSON.parse((decodeURIComponent(self.location.search) || '?{}').substr(1));

if (!production) importScripts('../node_modules/@babel/standalone/babel.js');

Заменим


// src/sw.js
   if (!ext && !url.endsWith('/')) {
     url = url + '.jsx' with
   }

На


// src/sw.js
   if (!ext && !url.endsWith('/')) {
     url = url + '.' + (production ? 'js' : 'jsx');
   }

Создадим небольшой консольный скрипт build.sh (люди с Windows могут по образу и подобию создать такой же для Винды) который соберет все что нужно в директорию build:


# очистка
rm -rf build

# создаем директории
mkdir -p build/scripts
mkdir -p build/node_modules

# копируем зависимости
cp -r ./node_modules/react       ./build/node_modules/react
cp -r ./node_modules/react-dom   ./build/node_modules/react-dom

# копируем файлы, которые не попадают под сборку
cp ./src/*.js        ./build
cp ./src/index.html  ./build/index.html

# собираем
npm run build

Мы идем таким путем чтобы директория node_modules не распухала на продакшене от зависимостей нужных только на фазе сборки и в режиме разработки.


Финальный репозиторий: http://github.com/kirill-konshin/pure-react-with-dynamic-imports


Если теперь открыть build/index.html то мы увидим такой же вывод как и в src/index-dev.html но в этот раз браузер ничего собирать не будет, он будет использовать предварительно собранные Бабелем файлы.


Как нетрудно увидеть, в решении присутствует дублирование: importMap.json, alias секция файла .babelrc.js и список файлов для копирования в build.sh. Для демо сгодится, но вообще это следует как-то автоматизировать.


Сборка выложена по адресу: https://kirill-konshin.github.io/pure-react-with-dynamic-imports/index.html


Заключение


В целом получился вполне жизнеспособный продукт, хотя и очень сырой.


HTTP2 по-идее должно позаботиться о куче мелких файлов пересылаемых по сети.


Репозиторий, где можно посмотреть код

Tags:
Hubs:
+11
Comments 2
Comments Comments 2

Articles