📜 ⬆️ ⬇️

Native EcmaScript modules: new features and differences from webpack

image


In the previous article Native ECMAScript modules - the first review I told the history of JavaScript modules and the current state of affairs of the implementation of native EcmaScript modules.


Two implementations are now available, which we will try to compare with the bundler modules.


Main thoughts:


  1. execute a script or load an external file and execute as a module using <script type = "module">;
  2. .js extension cannot be omitted in the import directive (the full path must be specified);
  3. module scopes should not be global and this should not refer to anything;
  4. native modules in strict mode by default (you no longer need to use the use strict directive);
  5. modules by default work as deferred scripts (the same as for <script type="text/javascript" defer /> ).

In the article we will learn about the differences of bundles, ways of interacting with modules, learn how to rewrite webpack modules into native ES and other tips and tricks.


Module path


We already know that we need to write the .js extension when we use the import "FILE.js" directive import "FILE.js" .


But there are other rules that apply to the import directive.


Let's analyze the error that will appear if you try to load a nonexistent script :


 import 'non-existing.js'; 

image


Cool, but what about the gaps?


As in classic scripts, any number of spaces at the beginning or at the end of the path are removed in <script src> and import ( demo ):


 <!--WORKS--> <script type="module" async src=" ./entry.js "></script> // WORKS import utils from " https://blog.hospodarets.com/demos/native-javascript-modules/js/utils.js "; 

You can find more examples by reading part of the HTML specification resolve a module specifier . Here are examples of valid specifiers from there:



Total about the module path:



Once we are talking about absolute URLs, let's check how we can use them.


Absolute URLs and CORS (Cross-Origin Resource Sharing)


Another difference from bundles is the ability to download files from other domains (for example, loading modules from a CDN).
Let's create a demo where we load the main-bundled.js module, which in turn imports and uses blog.hospodarets.com/…/utils.js from another domain.


 <!-- https://plnkr.co/….html --> <script type="module" async src="./main-bundled.js"></script> // https://plnkr.co/….main-bundled.js // DOES allow CORS (Cross Origin Resource Sharing) import utils from "https://blog.hospodarets.com/demos/native-javascript-modules/js/utils.js"; utils.alert(` JavaScript modules work in this browser: https://blog.whatwg.org/js-modules `); // https://blog.hospodarets.com/.../utils.js export default { alert: (msg) => { alert(msg); } }; 

The demo will work in the same way as if you downloaded scripts from your domain. It is good that there is support for absolute URLs and it works just like classic scripts that can be downloaded from any source.


Of course, such requests follow the CORS rules. For example, in the previous example we loaded the script from https://blog.hospodarets.com/demos/native-javascript-modules/js/utils.js , which allowed us to make CORS requests. This can be easily determined by looking at the response headers:


image


We can see the access-control-allow-origin: * header.


This Access-Control-Allow-Origin header: | * defines the URI that can access the resource. The special symbol * allows any request to access the resource, so our demo works.

But let's change the main-bundled.js, we will load utils.js from another place ( demo )


 // https://plnkr.co/….main-bundled.js // DOESN'T allow CORS (Cross Origin Resource Sharing) import utils from "https://hospodarets.com/developments/demos/native-javascript-modules/js/utils.js"; utils.alert(` JavaScript modules work in this browser: https://blog.whatwg.org/js-modules `); 

And the demo stops working. Despite this, you can open
hospodarets.com/…/native-javascript-modules/js/utils.js
in your browser and make sure its content matches the
blog.hospodarets.com/…/utils.js .


The difference is that the second utils.js does not provide access to the resource at the header level access-control-allow-origin :


image


which is interpreted by the browser as a rejection of any other source ( https://plnkr.co in our case) to access the resource, so the demo stops working with the following error:


image


There are some other limitations that apply to native modules, classic scripts and resources. For example, you will not be able to import the HTTP module into your HTTPS site ( Mixed Content , demo )


 // https://plnkr.co/….main-bundled.js // HTTP insecure import under the app served via HTTPS import utils from "http://blog.hospodarets.com/demos/native-javascript-modules/js/utils 

image


Total:



Script Attributes


As in classic scripts, there are many attributes that can be used in script type = ”module” .



Total:



How to determine if the script is loading or cannot be executed due to an error


As soon as I started using ES modules, the main question I had was how to determine if the script was loaded or an error occurred?


According to the specification, if any of the descendants did not load, the loading of the script stops with an error and the script is not executed. I prepared a demo where I intentionally missed the .js extension for the imported file, which is required (you may notice an error in the devtools console).


image


We already know that native modules behave like deferred default scripts. On the other hand, they can stop execution if, for example, a script graph cannot be executed / loaded.


For these two cases, we must somehow detect the fact that the script did not load or an error occurred.


Let's try using the classic way to connect scripts by changing a bit of code. Create a method that will take parameters and execute a script with them:



The method returns a Promise, which allows you to determine whether the script was loaded or there was an error while loading:


 // utils.js function insertJs({src, isModule, async, defer}) { const script = document.createElement('script'); if(isModule){ script.type = 'module'; } else{ script.type = 'application/javascript'; } if(async){ script.setAttribute('async', ''); } if(defer){ script.setAttribute('defer', ''); } document.head.appendChild(script); return new Promise((success, error) => { script.onload = success; script.onerror = error; script.src = src;// start loading the script }); } export {insertJs};   : import {insertJs} from './utils.js' // The inserted node will be: // <script type="module" src="js/module-to-be-inserted.js"></script> const src = './module-to-be-inserted.js'; insertJs({ src, isModule: true, async: true }) .then( () => { alert(`Script "${src}" is successfully executed`); }, (err) => { alert(`An error occured during the script "${src}" loading: ${err}`); } ); // module-to-be-inserted.js alert('I\'m executed'); 

But the demo where the script is successfully executed. In this example, the script will run and our success callback will be executed. Now we will make the module have an error ( demo ):


 // module-to-be-inserted.js import 'non-existing.js'; alert('I\'m executed'); 

In this case, we have an error that can be seen in the console:


image


Therefore, our reject callback is executed. You will also see an error message if you try to use import \ export in other modules ( demo ):


image


Now we have the ability to connect scripts and be sure that the scripts can / cannot load.


Total:



Features of native modules


Native modules - singleton


According to the specification, no matter how many times you will import the same module. All modules are singleton. Example:


 if(window.counter){ window.counter++; }else{ window.counter = 1; } alert(`increment.js- window.counter: ${window.counter}`); const counter = window.counter; export {counter}; 

You can import this module as many times as you want. It will be executed only once, window.counter and the exported counter will be 1 ( demo )


Imports “float”


Like functions in javascript, imports “hoisted”. This behavior is important to know. You can apply the same rules to the writing of imports as to the declaration of variables - always write them at the beginning of the file. This is why the following code works :


 alert(`main-bundled.js- counter: ${counter}`); import {counter} from './increment.js'; 

The order of execution of the code below ( demo ):



 import './module1.js'; alert('code1'); import module2 from './module2.js'; alert('code2'); import module3 from './module3.js'; 

Imports and exports cannot be nested in blocks


Due to the fact that the structure of ES modules is static, they cannot be imported / exported inside conditional blocks. It is widely used to optimize code loading. You also cannot wrap them in a try {} catch () {} block or something like that.


Here is a demo :


 if(Math.random()>0.5){ import './module1.js'; // SyntaxError: Unexpected keyword 'import' } const import2 = (import './main2.js'); // SyntaxError try{ import './module3.js'; // SyntaxError: Unexpected keyword 'import' }catch(err){ console.error(err); } const moduleNumber = 4; import module4 from `module${moduleNumber}`; // SyntaxError: Unexpected token 

Total:



How to determine if modules are supported


Browsers started adding ES modules, and we need a way to find that the browser supports them. First thoughts on how to define module support:


 const modulesSupported = typeof exports !== undefined; const modulesSupported2 = typeof import !== undefined; 

This option does not work, since import / export is intended for use only for functional modules. These examples are executed with the “Syntax errors” error. Even worse, import / export should not be loaded as a classic script. Therefore, we need another way.


Determining support for ES modules in browsers


We have the ability to determine the loading of regular scripts by listening to onload/onerror . We also know that if the type attribute is not supported, it will simply be ignored by the browser. This means we can connect the script type="module" and know that if it is loaded, the browser supports the system of modules.


It is unlikely that you would like to create a separate script in the project for such a check. For this, we have the Blob () API to create an empty script and provide the correct MIME type for it. In order to get the URL representation of the script, which we can assign to the src attribute, you need to use the URL.createObjectURL () method


Another problem is that the browser simply ignores the type="module" scripts if the browser does not support them in the browser, without any onload / onerror triggering event. Let's just give up our Promise after the timeout.


And finally, after our successful Promise, we have to tidy up a bit: remove the script from the DOM and remove unnecessary URL objects from the memory.


And now we will combine all this in the example :


 function checkJsModulesSupport() { // create an empty ES module const scriptAsBlob = new Blob([''], { type: 'application/javascript' }); const srcObjectURL = URL.createObjectURL(scriptAsBlob); // insert the ES module and listen events on it const script = document.createElement('script'); script.type = 'module'; document.head.appendChild(script); // return the loading script Promise return new Promise((resolve, reject) => { // HELPERS let isFulfilled = false; function triggerResolve() { if (isFulfilled) return; isFulfilled = true; resolve(); onFulfill(); } function triggerReject() { if (isFulfilled) return; isFulfilled = true; reject(); onFulfill(); } function onFulfill() { // cleaning URL.revokeObjectURL(srcObjectURL); script.parentNode.removeChild(script) } // EVENTS script.onload = triggerResolve; script.onerror = triggerReject; setTimeout(triggerReject, 100); // reject on timeout // start loading the script script.src = srcObjectURL; }); }; checkJsModulesSupport().then( () => { console.log('ES modules ARE supported'); }, () => { console.log('ES modules are NOT supported'); } ); 

How to determine that the script was executed as a native module


Cool, now we can understand whether the browser supports native modules. But what if we want to know in which mode the loaded script runs?


In the document object there is a property document.currentScript that contains a link to the current script. Therefore, you can check the type attribute:


 const isModuleScript = document.currentScript.type === 'module'; 

but currentScript is not supported in modules ( demo ).


We can clarify whether the script is a module by checking the context reference (in other words, a link). If this refers to a global object, it will be clear that the script is not a native module.


 const isNotModuleScript = this !== undefined; 

But we must bear in mind that this method can give false data, for example, bound .


Switch from Webpack to native ES modules


It's time to rewrite some Webpack modules to native ones, compare the syntax and make sure that everything still works. Let's take a simple example that uses the popular lodash library.


So, we use aliases and Webpack features to simplify the import syntax. For example, we will do:


 import _ from 'lodash'; 

Webpack will look in our node_modules folder, find lodash will automatically import the index.js file. Which, in turn, requires downloading lodash.js , where all the code of the library. In addition, you can import specific functions as follows:


 import map from 'lodash/map'; 

Webpack will find node_modules/lodash/map.js and import the file. Convenient and fast, agree? Let's try the following example:


 // main-bundled.js import _ from 'lodash'; console.log('lodash version:', _.VERSION); // eg 4.17.4 import map from 'lodash/map'; console.log( _.map([ { 'user': 'barney' }, { 'user': 'fred' } ], 'user') ); // ['barney', 'fred'] 

First of all, lodash just doesn't work with ES modules. If you look at the source code, you will see that the commonjs approach is used:


 // lodash/map.js var arrayMap = require('./_arrayMap'); //... module.exports = map; 

After some searching, it turned out that the authors of lodash created a special project for this - lodash-es - which contains the library modules of lodash in the form of ES modules.


If we check the code, we will see that these are ES modules:


 // lodash-es/map.js import arrayMap from './_arrayMap.js'; //... export default map; 

Here is the usual structure of our application (which we will port):


image


I intentionally placed lodash-es in the dist_node_modules folder instead of node_modules . In most projects, the node_modules folder is outside of GIT-a and is not part of the distribution of the code. You can find the code on Github .


The main-bundle.js is assembled by Webpack2 into the dist/app.bundle.js , on the other hand, js/main-native.js ES module and must be loaded by the browser along with the dependencies.


We already know that we cannot help writing the file extension for native modules, so first of all we need to add them.


 // 1) main-native.js DOESN'T WORK import lodash from 'lodash-es.js'; import map from 'lodash-es/map.js'; 

Secondly, the URLs of native modules must be absolute or must begin with “/”, “./”, or “../”. Oh, this is the hardest. For our structure, we must do the following:


 // 2) main-native.js WORKS, USES RELATIVE URLS import _ from '../dist_node_modules/lodash-es/lodash.js'; import map from '../dist_node_modules/lodash-es/map.js'; 

And after a while we can start with a more complex structure of the organization of the modules. We can have many relative and very long urls, so you can easily replace all files with the following option:


 // 2) main-native.js WORKS, CAN BE REUSED/COPIED IN ANY ES MODULE IN THE PROJECT import _ from '/dist_node_modules/lodash-es/lodash.js'; import map from '/dist_node_modules/lodash-es/map.js'; 

Usually the directory root points to the location of index.html, so the tag does not affect the behavior of the imported modules.

Here is the demo and code


 console.log('----- Native JavaScript modules -----'); import _ from '/demos/native-ecmascript-modules-aliases/dist_node_modules/lodash-es/lodash.js'; console.log(`lodash version: ${_.VERSION}`); // eg 4.17.4 import map from '/demos/native-ecmascript-modules-aliases/dist_node_modules/lodash-es/map.js'; console.log( map([ {'user': 'barney'}, {'user': 'fred'} ], 'user') ); // ['barney', 'fred'] 

Demo


At the end of this chapter, I note that for importing scripts, modules and dependencies, the browser makes requests (as well as for other resources). In our case, the browser loads all the lodash dependencies, resulting in about 600 files getting into the browser:


image


As you can guess, it’s a very bad idea to upload so many files, especially if you don’t have HTTP / 2 support on the site.


Now you know that you can switch from a Webpack to native modules, and even know about the existence of lodash-es.


Total:



Using ES modules with fallback


Let's use all our knowledge to create a useful script and apply it, for example, in our lodash demo .


We will check if the browser supports ES modules (using checkJsModulesSupport ()) and, depending on this, decide what to connect to the user. If modules are supported, we will load the main-native.js file for them. Otherwise, we will connect the assembled JS file to the Webpack (using insertJS ()).


In order for the example to work for all browsers, let's provide an API with which you can set attributes for scripts that will indicate in what way we want to load them.


Something like that:


image


And here's the code that will make it all work, using the previous examples discussed earlier:


 checkJsModulesSupport().then( () => { // insert module script insertJs({ src: currentScript.getAttribute('es'), isModule: true }); // global class if (isAddGlobalClassSet) { document.documentElement.classList.add(esModulesSupportedClass); } }, () => { // insert classic script insertJs({ src: currentScript.getAttribute('js') }); // global class if (isAddGlobalClassSet) { document.documentElement.classList.add(esModulesNotSupportedClass); } } ); 

I posted this es-modules-utils script on Github .


Currently, there is a discussion about the possibility of adding native attributes of a nomodule or nosupport to the script, which will provide better compatibility for the fallback (thanks to @rauschma , who suggested this).


Conclusion


We have seen in practice the distinction between ES modules and classic scripts. We learned how to determine if the module was loaded or an error occurred. Now we know how to use ES modules with third-party libraries.


In addition, we have a useful es-modules-utils script on Github that can provide backward compatibility for browsers that do not support ES modules.


PS You can also read my article about the ability to dynamically load scripts using dynamic import () statement: Native ECMAScript modules: dynamic import () .


From translator


I work in Avia team Tutu.ru front-end developer. Native modules are developing very much and I follow this. All modern browsers already support them. At the moment, we have almost the full opportunity to use this part of the language specification right now. The future is coming :)


')

Source: https://habr.com/ru/post/329918/


All Articles