Эта статья представляет собой попытку собрать воедино все, что доступно на данный момент, и посмотреть, действительно ли возможно реализовать готовый к производству веб-сайт на основе 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' }) }) ) ) }
});
Вот что мы здесь сделали:
- Мы создали карту экспорта, которая связывает идентификатор пакета с глобальным именем переменной.
- Мы создали тег
script
вhead
с содержимым сценария, упакованного в UMD. - Мы экспортировали отображаемую глобальную переменную как экспорт модуля по умолчанию.
Для технической демонстрации этого метода исправления должно быть достаточно, но он может не работать с другим объявлением 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