This article is an attempt to bring together the currently available tools and find out whether it is possible to create production ready applications on React without prior compilation by collectors like Webpack, or at least minimize such compilation.
Everything described is very experimental and I deliberately cut corners in places. In no case do I recommend doing something like this on real production.
The ability to use ECMAScript modules ( <script type="module"/>
with imports of the form import Foo from './foo';
and import('./Foo')
) directly in the browser is not new for a long time, itโs well supported functionality: https: //caniuse.com/#feat=es6-module .
But in reality, we import not only our modules, but also libraries. There is an excellent article on this subject: https://salomvary.com/es6-modules-in-browsers.html . And another equally good article worth mentioning is https://github.com/stken2050/esm-bundlerless .
Among the other important things from these articles, these points are most important for creating a React application:
- Support for package specifier imports (or import maps): when we write
import React from 'react'
we actually have to import something like thishttps://cdn.com/react/react.production.js
- Support for UMD: React is still distributed as UMD and at the moment, the authors still have not agreed on how to distribute the library as a module
- Jsx
- CSS Import
Let's go through all the points in turn.
Project structure
First of all, we will determine the structure of the project:
-
node_modules
obviously this is where the dependencies will be put -
src
directory withindex*.html
and service scripts
-
app
directly application code on React
-
Package specifier imports support
To use React through import React from 'react';
we must tell the browser where to look for the real source, because react
is not a real file, but a pointer to a library. There is a stub for this https://github.com/guybedford/es-module-shims .
Let's set up the stub and React:
$ npm i es-module-shims react react-dom --save
We will start the application from the file 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>
Where src/app/index.jsx
looks something like this:
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); })();
And src/app/Button.jsx
like this:
import React from 'react'; export const Button = ({children}) => <button>{children}</button>;
Will this work? Of course not. Even despite the fact that everything is successfully imported from where necessary.
Let's move on to the next problem.
UMD support
Dynamic way
Based on the fact that React is distributed as UMD, it cannot be imported directly, even through a stub (if the ticket was closed as repaired, you can skip the step). We need to somehow patch the source so that it becomes compatible.
The above articles prompted me to use Service Workers for this, which can intercept and modify network requests and responses. Create the main entry point src/index.js
, where we will configure SW and the App and use it instead of directly invoking the application ( 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); } })();
Create a 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' }) }) ) ) } });
To summarize what has been done:
- We created an export map that associates a package name with a global variable
- Create a script tag in
head
with the contents of the script wrapped in UMD - Exported global variable as default export
For a demo, such a brutal patch will be sufficient, but this may not work with all UMD wrappers. Something more reliable can be used in return.
Now change src/index-dev.html
to use the configuration script:
<!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>
Now we can import the React and React DOM.
Static path
It should be noted that there is another way. There is an unofficial ES build of React:
npm install esm-react --save
The import map will look like this:
{ "imports": { "react": "../node_modules/esm-react/src/react.js", "react-dom": "../node_modules/esm-react/src/react-dom.js" } }
But unfortunately the project is very far behind, the latest version is 16.8.3
while React is already 16.10.2
.
Jsx
There are two ways to compile JSX. We can either pre-assemble the traditional Babel from the console, or this can be done in the browser runtime. For production, pre-compilation itself is preferable, but in development mode it is also possible in runtime. Since we already have a Service Worker, we will use it.
Install a special package with Babel:
$ npm install @babel/standalone --save-dev
Now add the following to 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' }) }) ) ) } });
Here we used the same approach with intercepting network requests and rewriting them, we used Babel to transform the original source code. Please note that the plugin for dynamic imports is called syntax-dynamic-import
, not like the usual @babel/plugin-syntax-dynamic-import
because it is a Standalone version.
CSS
In the mentioned article, the author used text transformation, we will go a little further and embed CSS on the page. To do this, we will again use the 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' }) }) ) ); } });
Voila! If we now open src/index-dev.html
in a browser, we will see buttons. Make sure that the required Service Worker is installed and is not in conflict with anything. If you are not sure, just in case you can open Dev Tools, go to Application
, there in Service Workers
, and click Unregister
for all registered workers, and then reload the page.
Production
The code above works as it should in development mode, but by itself we do not want to force site users to compile code in their browsers, this is completely impractical. Let's make some minimalistic production mode.
Create a separate src/index.html
entry point:
<!DOCTYPE html> <html> <body> <div id="root"></div> <script type="module" src="index.js"></script> </body> </html>
As you can see, there are no stubs here, we will use another method for rewriting package names. Since we need Babel to compile JSX again, we will use it to importMap.json
paths instead of importMap.json
for the stub. Install the necessary packages:
$ npm install @babel/cli @babel/core @babel/preset-react @babel/plugin-syntax-dynamic-import babel-plugin-module-resolver --save-dev
Add a section with scripts to package.json
:
{ "scripts": { "start": "npm run build -- --watch", "build": "babel src/app --out-dir build/app --source-maps --copy-files" } }
Add the .babelrc.js
file:
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('../../', '../') } ] ] }
It should be borne in mind that this file will be used only for production, in development mode we configure Babel in Service Worker.
Add combat mode to 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'); }
Add the conditions to 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');
Replace
// src/sw.js if (!ext && !url.endsWith('/')) { url = url + '.jsx' with }
On
// src/sw.js if (!ext && !url.endsWith('/')) { url = url + '.' + (production ? 'js' : 'jsx'); }
Let's create a small console script build.sh
(people with Windows can create the same for Windows in the image and likeness) which will collect everything you need into the build
directory:
# 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
We go this way so that the node_modules
directory node_modules
not swell on production from dependencies needed only in the build phase and in development mode.
Final repository: http://github.com/kirill-konshin/pure-react-with-dynamic-imports
If we now open build/index.html
then we will see the same output as in src/index-dev.html
but this time the browser will not collect anything, it will use the files previously collected by Babel.
As you can see, there is duplication in the solution: importMap.json
, alias
section of the .babelrc.js
file and a list of files to copy to build.sh
. It will do for a demo, but in general it should be somehow automated.
The assembly is available at: https://kirill-konshin.github.io/pure-react-with-dynamic-imports/index.html
Conclusion
In general, a completely viable product was obtained, although very raw.
HTTP2 is supposed to take care of a bunch of small files sent over the network.
Repository where you can see the code