React, JSX, ES module import (including dynamic) in a browser without Webpack

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:









Let's go through all the points in turn.







Project structure



First of all, we will determine the structure of the project:









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:







  1. We created an export map that associates a package name with a global variable
  2. Create a script tag in head



    with the contents of the script wrapped in UMD
  3. 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








All Articles