📦 React, JSX, ES module imports (dynamic too) in browser without Webpack

📦 React, JSX, ES module imports (dynamic too) in browser without Webpack
This article is an attempt to put together everything available at the moment and see if it is really possible to implement production ready React-based website without pre-compilation like Webpack/Parcel or at least to have as less pre-compilation as possible.
TL;DR
It is possible to serve React project with minimal compilation of JSX without using bundlers like Webpack or Parcel.
Bundlers pack code into chunks, which has drawbacks: chunks have to be small enough to deliver only what's needed and big enough to make better use of HTTP1.1 connections. Finding the right balance may be tricky and involves some automatic and manual tuning. In addition it slows down the build and dev mode.
We will use Service Workers to do all the work in runtime and some Babel for production build.
Everything described in the article is highly experimental and I am cutting corners everywhere. By no means I recommend to use any of that in production :)
Current state
Ability to use ECMAScript modules (<script type="module"/> with imports like import Foo from './foo'; and import('./Foo')) directly in browser is quite well known at the moment and has good browser support: https://caniuse.com/#feat=es6-module.
But in reality we don't just import our own modules, we import libraries. There is a great article on this topic: https://salomvary.com/es6-modules-in-browsers.html. Also there's another worth to mention project https://github.com/stken2050/esm-bundlerless.
Among other important things from the articles, these ones will be crucial in order to make React app work:
Package specifier imports support (or import maps): when we import react in reality we should import something like https://cdn.com/react/react.production.js
UMD support: React is still distributed as UMD and so far there's still an ongoing discussion how to publish it using ES modules
JSX
Import CSS
Let's solve these issues one by one.
Project structure
First things first, let's assume the project will have the following structure:
node_modules obviously a place where we will install all dependencies
src dir with index*.html and service scripts
app app source code
Package specifier imports support
In order to use React like so import React from 'react'; we need to tell the browser where to find the actual source. This is quite simple, there's a shim for that: https://github.com/guybedford/es-module-shims.
Let's install the shim and React:
$ npm i es-module-shims react react-dom --save
In order to launch the app we can do something like this in 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>
Copy
HTML
Where in src/app/index.jsx we will have:
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); })();
Copy
JavaScript
And the src/app/Button.jsx:
import React from 'react'; export const Button = ({children}) => <button>{children}</button>;
Copy
JavaScript
Does it work? Of course, no. Even though we've successfully imported everything.
Let's move on to the next challenge.
UMD support
Dynamic way
The issue now is that React is distributed as UMD, it cannot be consumed by imports, even by the shimmed ones (if the ticket is resolved, just skip this step). So we need to somehow patch the distributable to convince browser that it's a legit ES modules.
The above mentioned article led me to an idea that we can use Service Workers to intercept and pre-process network requests. Let's create the main endpoint src/index.js, which will bootstrap the SW and App and use it instead of the App directly (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); } })();
Copy
JavaScript
And then let's create the 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' }) }) ) ) } });
Copy
JavaScript
Here's what we've done here:
We have created the export map, which associates package id with global var name
We have created a script tag in head with contents of UMD-packaged script
We've exported the mapped global var as default export of module
For the sake of tech demo this method of patching should be enough, but it may break with other UMD declaration. Something more robust can be used to process sources.
Now let's adjust the src/index-dev.html to use the bootstrap entry point:
<!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>
Copy
HTML
Now we're able to import React and React DOM.
Static way
It's worth to mention, that there's also another way. We can install ESM distributable:
npm install esm-react --save
And then use following map:
{ "imports": { "react": "../node_modules/esm-react/src/react.js", "react-dom": "../node_modules/esm-react/src/react-dom.js" } }
Copy
JSON
But unfortunately this project is quite stale, latest is 16.8.3 whereas React is 16.10.2.
JSX
There are two ways to do the JSX compilation. We can either go traditional way and use Babel to pre-compile or we can use it in runtime. Of course for production it would make much more sense to pre-compile, development mode can be more brutal. Since we already use Service Worker let's enhance it.
Let's install a special Babel package that can do it:
$ npm install @babel/standalone --save-dev
Copy
Bash
Now let's add following to the 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' }) }) ) ) } });
Copy
JavaScript
Here we've used same approach to intercept the network request and respond with slightly different content, in this case we use Babel to transform the original response. Please note that plugin for dynamic import has a different name syntax-dynamic-import, not a usual @babel/plugin-syntax-dynamic-import due to Standalone usage.
CSS
In the above mentioned article author used text transformation, here we will go a bit further and inject the CSS in the page. For that we again will use the 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' }) }) ) ); } });
Copy
JavaScript
Et voila! If you now open the src/index-dev.html in the browser you'll see the buttons. Make sure the proper Service Worker is being picked up, if you're not sure, open Dev Tools, go to Application tab and Service Workers section, Unregister everything and reload the page.
More production friendly case
The above mentioned code works fine for dev mode, but realistically we don't want all of the app users to compile the code in their browsers, it's impractical. So let's push things a bit further and make the minimalistic production mode.
In order to do that we will create one more index file src/index.html with following content:
<!DOCTYPE html> <html> <body> <div id="root"></div> <script type="module" src="index.js"></script> </body> </html>
Copy
HTML
As you see, no shims here, we will use different technique to do rewrites. Since we still have to use Babel to compile JSX we can also rewrite module paths there instead of using importMap.json for the shim. Let's install everything:
$ npm install @babel/cli @babel/core @babel/preset-react \ @babel/plugin-syntax-dynamic-import babel-plugin-module-resolver --save-dev
Copy
Bash
Let's add the scripts section to package.json:
{ "scripts": { "start": "npm run build -- --watch", "build": "babel src/app --out-dir build/app --source-maps --copy-files" } }
Copy
JSON
Next let's add .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('../../', '../') } ] ] }
Copy
JavaScript
Keep in mind that this file will be used only for production, for dev we configure Babel in the Service Worker.
Also let's add production mode support to 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'); }
Copy
JavaScript
And use the condition in 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');
Copy
JavaScript
Also replace
// src/sw.js if (!ext && !url.endsWith('/')) { url = url + '.jsx' with }
Copy
JavaScript
with
// src/sw.js if (!ext && !url.endsWith('/')) { url = url + '.' + (production ? 'js' : 'jsx'); }
Copy
JavaScript
Now let's add a build script build.sh which will copy everything needed to build dir:
# 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
Copy
Bash
We do this to make node_modules leaner by skipping build dependencies.
Now if you open build/index.html you will see the same output as for src/index-dev.html but this time browser won't build Babel, it will use pre-built files.
As you see, the solution now has duplicates, like importMap.json, the alias section in .babelrc.js and list of files to be copied in build.sh. For demo purposes it's fine but for real usage it would be better to automate this.
Conclusion
HTTP2 should take care of those small source files sent over the network, hopefully.
Here's the repo where I have put everything together: https://github.com/kirill-konshin/pure-react-with-dynamic-imports