Kirill Konshin

React, JSX, ES Module Imports And Dynamic Imports In Browser Without Using Webpack

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:
  • 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
  • src/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>
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.

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);
  }
})();
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:
  1. We have created the export map, which associates package id with global var name
  2. We have created a
    script
    tag in
    head
    with contents of UMD-packaged script
  3. 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>
Now we're able to import React and React DOM.

UMD support: 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"
  }
}
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
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.

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'
            })
          })
        )
    );
  }

});
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>
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:
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

Tags

Topics of interest