Эта статья представляет собой попытку собрать воедино все, что доступно на данный момент, и посмотреть, действительно ли возможно реализовать готовый к производству веб-сайт на основе React без предварительной компиляции, такой как Webpack / Parcel, или, по крайней мере, иметь как можно меньше предварительной компиляции.

TL;DR

Можно обслуживать проект React с минимальной компиляцией JSX без использования таких сборщиков, как Webpack или Parcel.

Компоновщики упаковывают код в блоки, что имеет недостатки: блоки должны быть достаточно маленькими, чтобы доставлять только то, что необходимо, и достаточно большими, чтобы лучше использовать соединения HTTP1.1. Поиск правильного баланса может быть сложной задачей и требует некоторой автоматической и ручной настройки. Вдобавок это замедляет режим сборки и разработки.

Мы будем использовать Service Workers для выполнения всей работы во время выполнения и немного Babel для производственной сборки.

Все, что описано в статье, носит экспериментальный характер, и я везде стараюсь «срезать углы». Ни в коем случае не рекомендую использовать что-либо из этого в продакшене :)

Текущее состояние

Возможность использования модулей ECMAScript (<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 работало:

  • Поддержка импорта спецификатора пакета (или импорта карт): когда мы импортируем реакцию, на самом деле мы должны импортировать что-то вроде https://cdn.com/react/react.production.js
  • Поддержка UMD: React по-прежнему распространяется как UMD, и до сих пор продолжается обсуждение того, как опубликовать его с помощью модулей ES.
  • JSX
  • Импортировать CSS

Давайте решать эти вопросы по очереди.

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

Перво-наперво, предположим, что проект будет иметь следующую структуру:

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

Поддержка импорта спецификатора пакета

Чтобы использовать React таким образом import React from '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, он не может быть использован импортом, даже при помощи шиммирования (если тикет разрешен, просто пропустите этот шаг). Поэтому нам нужно как-то пропатчить распространяемый, чтобы убедить браузер в том, что это законные модули ES.

Вышеупомянутая статья навела меня на мысль, что мы можем использовать Service Workers для перехвата и предварительной обработки сетевых запросов. Давайте создадим главную конечную точку src/index.js, которая загрузит ПО и приложение и будет использовать их вместо приложения напрямую (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");
    // this launches the React app if the SW has been installed before or immediately after registration
    // 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):

//this is needed to activate the worker immediately without reload
//@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">... same as before</script>
  <!-- change the file from app/index.jsx to index.js -->
  <script type="module-shim" src="index.js"></script>
</body>
</html>

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

Статический способ

Стоит отметить, что есть и другой способ. Мы можем установить распространяемый ESM:

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. Мы можем либо пойти традиционным путем и использовать Babel для предварительной компиляции, либо мы можем использовать его во время выполнения. Конечно, для производства было бы гораздо разумнее предварительно скомпилировать, режим разработки может быть более жестким. Поскольку мы уже используем Service Worker, давайте улучшим его.

Давайте установим специальный пакет Babel, который может это сделать:

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

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

# src/sw.js
// at the very top of the file
importScripts('../node_modules/@babel/standalone/babel.js');
// activation stuff as before
self.addEventListener('fetch', (event) => {
  // whatever we had before
  } else if (url.endsWith('.jsx')) {
    event.respondWith(
      fetch(url)
        .then(response => response.text())
        .then(body => new Response(
          //TODO Cache
          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 из-за использования автономного режима.

CSS

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

// same as before
self.addEventListener('fetch', (event) => {
  // whatever we had before + Babel stuff
  } else if (url.endsWith('.css')) {
    event.respondWith(
      fetch(url)
        .then(response => response.text())
        .then(body => new Response(
          //TODO We don't track instances
          //so 2x import will result in 2x <style> tags
          `
            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. Если вы не уверены, откройте Инструменты разработчика, перейдите на вкладку Application и раздел Service Workers, Unregister все и перезагрузите страницу.

Более удобный для производства корпус

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

Для этого мы создадим еще один индексный файл src/index.html следующего содержания:

<!DOCTYPE html>
<html>
<body>
<div id="root"></div>
<script type="module" src="index.js"></script>
</body>
</html>

Как видите, никаких прокладок здесь нет, мы будем использовать другую технику для перезаписи. Поскольку нам все еще нужно использовать Babel для компиляции 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'
        },
        // we replace as follows to make sure we stay in build dir
        resolvePath: (sourcePath, currentFile, opts) => resolvePath(sourcePath, currentFile, opts).replace('../../', '../')
      }
    ]
  ]
}

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

Также давайте добавим поддержку рабочего режима в Service Worker:

// src/index.js
if ('serviceWorker' in navigator) {
    (async () => {
        try {
            // adding this
            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");
                }
            };
            // this launches the React app if the SW has been installed before or immediately after registration
            // 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, который скопирует все необходимое в build каталог:

# cleanup
rm -rf build
# create directories
mkdir -p build/scripts
mkdir -p build/node_modules
# copy used node modules
cp -r ./node_modules/react       ./build/node_modules/react
cp -r ./node_modules/react-dom   ./build/node_modules/react-dom
# copy files that are not built
cp ./src/*.js        ./build
cp ./src/index.html  ./build/index.html
# build
npm run build

Мы делаем это, чтобы сделать node_modules компактнее, пропуская зависимости сборки.

Вот и финальное состояние: kirill-konshin / pure-react-with-dynamic-import.

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

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

Вот опубликованная сборка: https://kirill-konshin.github.io/pure-react-with-dynamic-imports/index.html

Вывод

Надеюсь, HTTP2 позаботится об этих небольших исходных файлах, пересылаемых по сети.

Вот репо, где я все собрал: https://github.com/kirill-konshin/pure-react-with-dynamic-imports