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 :)
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:
Let's solve these issues one by one.
First things first, let's assume the project will have the following structure:
node_modules
obviously a place where we will install all dependenciessrc
dir with index*.html
and service scriptssrc/app
app source codeIn 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>
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);
})();
And the
src/app/Button.jsx
:import React from 'react';
export const Button = ({children}) => <button>{children}</button>;
Does it work? Of course, no. Even though we've successfully imported everything.
Let's move on to the next challenge.
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);
}
})();
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'
})
})
)
)
}
});
Here's what we've done here:
script
tag in head
with contents of UMD-packaged scriptFor 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>
Now we're able to import React and React DOM.
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"
}
}
But unfortunately this project is quite stale, latest is
16.8.3
whereas React is 16.10.2
.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
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'
})
})
)
)
}
});
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.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'
})
})
)
);
}
});
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.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>
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
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"
}
}
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('../../', '../')
}
]
]
}
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');
}
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');
Also replace
// src/sw.js
if (!ext && !url.endsWith('/')) {
url = url + '.jsx' with
}
with
// src/sw.js
if (!ext && !url.endsWith('/')) {
url = url + '.' + (production ? 'js' : 'jsx');
}
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
We do this to make
node_modules
leaner by skipping build dependencies.Here's the final state:
https://github.com/kirill-konshin/pure-react-with-dynamic-imports
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.Here's the published build: https://kirill-konshin.github.io/pure-react-with-dynamic-imports/index.html
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