The loading implementation of the ES6 Module


May 08, 2021 14:00 ES6


Table of contents


1. Browser loading

Traditional methods

In HTML web pages, the <script> scripts with the hashtag .

  1. <!-- 页面内嵌的脚本 -->
  2. <script type="application/javascript">
  3. // module code
  4. </script>
  5. <!-- 外部脚本 -->
  6. <script type="application/javascript" src="path/to/myModule.js">
  7. </script>

In the above code, because the default language of the browser script is JavaScript, type="application/javascript" omitted.

By default, the browser loads JavaScript scripts synchronously, i.e. the rendering engine stops when it encounters a label and waits until the script is executed before continuing to render down. If it is an external script, you must also add the script download time.

If the script is large, it takes a long time to download and execute, causing the browser to clog up and the user to feel that the browser is "stuck" without any response. This is obviously a very bad experience, so the browser allows scripts to load asynchronously, and here are two asynchronous loading syntaxes.

  1. <script src="path/to/myModule.js" defer></script>
  2. <script src="path/to/myModule.js" async></script>

In the code above, <script> defer async and the script loads asynchronously. When the rendering engine encounters this line of commands, it starts downloading external scripts, but instead of waiting for it to download and execute, it executes the subsequent commands directly.

defer async is that defer does not execute until the entire page is rendered normally in memory async script execution is complete); I n a word, defer is "rendered and executed" and async is "executed after downloading". In addition, if there defer scripts, they are loaded in the order in which they appear on the page, and async scripts are not guaranteed to load in the order.

Load rules

The browser loads the ES6 module and also uses <script> but adds type="module"

  1. <script type="module" src="./foo.js"></script>

The above code inserts a module foo.js type module the browser knows that this is an ES6 module.

The browser is type="module" those with type"module" and does not clog the browser, i.e. waits until the entire page is rendered and then executes the module <script> is equivalent to opening the defer property defer

  1. <script type="module" src="./foo.js"></script>
  2. <!-- 等同于 -->
  3. <script type="module" src="./foo.js" defer></script>

If the page has <script type="module"> are executed in the order in which they appear on the page.

The async <script> label can also be async and the rendering engine interrupts rendering as soon as the load is complete. When the execution is complete, the rendering resumes.

  1. <script type="module" src="./foo.js" async></script>

Once the async is used, the module is not executed in the order in which it <script type="module"> on the page, but as long as the module is loaded.

The ES6 module also allows embedded in a Web page, and the syntax behaves exactly the same as loading an external script.

  1. <script type="module">
  2. import utils from "./utils.js";
  3. // other code
  4. </script>

jQuery, for example, supports module loading.

  1. <script type="module">
  2. import $ from "./jquery/src/jquery.js";
  3. $('#message').text('Hi from jQuery!');
  4. </script>

For external module scripts (the example above foo.js there are a few things to note.

  • The code runs in the module scope, not in the global scope. The top-level variable inside the module is not visible externally.
  • Module scripts are automatically in strict mode, with or without use strict
  • In modules, you import command to load other .js suffix is not omitted and requires an absolute URL or relative URL), or you can use the export export to output the external interface.
  • In the module, the top-level this keyword undefined of pointing to window. That is, it makes no sense to use this keyword at the top of the module.
  • If the same module is loaded more than once, it will only be executed once.

Here is an example module.

  1. import utils from 'https://example.com/js/utils.js';
  2. const x = 1;
  3. console.log(x === window.x); //false
  4. console.log(this === undefined); // true

Using the this point undefined you can detect whether the current code is in the ES6 module.

  1. const isNotModuleScript = this !== undefined;

2. Differences between the ES6 module and the CommonJS module

Before discussing node .js to load an ES6 module, it is important to understand that the ES6 module is completely different from the CommonJS module.

They have two major differences.

  • The CommonJS module outputs a copy of the value, and the ES6 module outputs a reference to the value.
  • CommonJS modules are runtime loads and ES6 modules are compile-time output interfaces.

The second difference is that CommonJS is loaded with an module.exports which is not generated until the script is run. The ES6 module is not an object, and its external interface is only a static definition that is generated during the static parsing phase of the code.

The first difference is highlighted below.

The CommonJS module outputs a copy of the value, that is, once a value is output, changes within the module do not affect the value. Take a look at the following example of lib.js file.

  1. // lib.js
  2. var counter = 3;
  3. function incCounter() {
  4. counter++;
  5. }
  6. module.exports = {
  7. counter: counter,
  8. incCounter: incCounter,
  9. };

The above code outputs the internal counter and the internal method of overwriteing the incCounter Then, load main.js main module.

  1. // main.js
  2. var mod = require('./lib');
  3. console.log(mod.counter); // 3
  4. mod.incCounter();
  5. console.log(mod.counter); // 3

As the code above .js lib.js module loads, its internal changes do not affect mod.counter output. T his mod.counter is a value of the original type and is cached. You can't get an internally changed value unless you write it as a function.

  1. // lib.js
  2. var counter = 3;
  3. function incCounter() {
  4. counter++;
  5. }
  6. module.exports = {
  7. get counter() {
  8. return counter
  9. },
  10. incCounter: incCounter,
  11. };

In the code above, the counter property is actually a value taker function. Now that the main.js can correctly read the change in the internal variable counter

  1. $ node main.js
  2. 3
  3. 4

The ES6 module operates differently from CommonJS. W hen the JS engine analyzes the script statically, a read-only import when the module loads the command import. W ait until the script is actually executed, then take the value from this read-only reference to the module that was loaded. I n other words, ES6's import bit like a "symbolic connection" of the Unix system, with the original value changing and the value loaded by the import changing. Therefore, the ES6 module is a dynamic reference and does not cache values, and variables in the module bind to the module in which it resys.

Let's take the example above.

  1. // lib.js
  2. export let counter = 3;
  3. export function incCounter() {
  4. counter++;
  5. }
  6. // main.js
  7. import { counter, incCounter } from './lib';
  8. console.log(counter); // 3
  9. incCounter();
  10. console.log(counter); // 4

The above code explains that the variable counter entered by the ES6 counter is live and fully reflects lib.js module lib.

Give another example that export in the export section.

  1. // m1.js
  2. export var foo = 'bar';
  3. setTimeout(() => foo = 'baz', 500);
  4. // m2.js
  5. import {foo} from './m1.js';
  6. console.log(foo);
  7. setTimeout(() => console.log(foo), 500);

In the code above, m1.js foo equal to bar when it was bar and after 500 milliseconds, it baz `

Let's see if the .js can read this change correctly.

  1. $ babel-node m2.js
  2. bar
  3. baz

The above code indicates that the ES6 module does not cache the results of the run, but dynamically values the loaded module, and that the variable is always bound to the module in which it resys.

Because the module variable entered by ES6 is just a "symbolic connection", the variable is read-only and re-assigning it will be misaled.

  1. // lib.js
  2. export let obj = {};
  3. // main.js
  4. import { obj } from './lib';
  5. obj.prop = 123; // OK
  6. obj = {}; // TypeError

In the code above, the main .js enter .js obj from lib, you can add properties to obj, but re-assignment will be wrong. Because the address to which the variable obj points is read-only and cannot be re-assigned, this is like a .js creates a const variable called obj.

Finally, the export outputs the same value through the interface. Different scripts load this interface, resulting in the same instance.

  1. // mod.js
  2. function C() {
  3. this.sum = 0;
  4. this.add = function () {
  5. this.sum += 1;
  6. };
  7. this.show = function () {
  8. console.log(this.sum);
  9. };
  10. }
  11. export let c = new C();

The script mod above .js output an instance of C. Different scripts load the module, resulting in the same instance.

  1. // x.js
  2. import {c} from './mod';
  3. c.add();
  4. // y.js
  5. import {c} from './mod';
  6. c.show();
  7. // main.js
  8. import './x';
  9. import './y';

Now that the main .js, the output is 1.

  1. $ babel-node main.js
  2. 1

This proves that both .js x.js and y load the same instance of C.

3. Node .js loaded

Overview

Node .js is cumbersome to handle ES6 modules because it has its own CommonJS module format, which is incompatible with the ES6 module format. T he current solution is to separate the two, with the ES6 module and CommonJS using their own loading schemes. Starting with v13.2, node .js has turned on ES6 module support by default.

Node .js requires the ES6 module to use .mjs suffix file name. T hat is, as long as the import or import used in export script file, the .mjs name must be used. Node .js the .mjs an ES6 module, enabling strict mode by default, without having to specify "use strict"

If you don't want to change the suffix name to .mjs, you can specify the type field as module in the project's package.json file.

  1. {
  2. "type": "module"
  3. }

Once set up, the JS script in the directory is interpreted as an ES6 module.

  1. # 解释成 ES6 模块
  2. $ node my-app.js

If you want to use the CommonJS module at this point, you need to change the suffix name of the CommonJS script to .cjs. If there is no type field, or if the type field is commonjs, .js script is interpreted as a CommonJS module.

Summary: .mjs files are always loaded as ES6 modules, .cjs files are always loaded as CommonJS modules, and the loading of .js files depends on the settings of the type field inside package.json.

Note that the ES6 module and the CommonJS module should not be mixed as much as possible. T he require command does not load the .mjs file, it reports an error, and only the import command can load the .mjs file. Conversely, the require command cannot be used in the .mjs file, and import must be used.

The main field

package.json file has two fields that specify the module's entry file: main and exports For simpler modules, you can specify the main loaded by the module using only the main field.

  1. // ./node_modules/es-module-package/package.json
  2. {
  3. "type": "module",
  4. "main": "./src/index.js"
  5. }

The above code specifies that the entry script for the project is ./src/index.js in the format of the ES6 module. If there is no index.js index is interpreted as a CommonJS module.

The import command can then load the module.

  1. // ./my-app.mjs
  2. import { something } from 'es-module-package';
  3. // 实际加载的是 ./node_modules/es-module-package/src/index.js

In the above code, after running the script, Node.js goes under the ./node_modules directory, looks for the es-module-package module, and then executes the portal file according to the main field of the module package.json.

At this point, if the es-module-package module is loaded with the require() command of the CommonJS module, an error is reported because the CommonJS module cannot handle the export command.

The exports field

exports takes precedence over main field. It has several uses.

(1) Subdirectt alias

package.json exports can specify an alias for a script or subdirect directory.

  1. // ./node_modules/es-module-package/package.json
  2. {
  3. "exports": {
  4. "./submodule": "./src/submodule.js"
  5. }
  6. }

The code above specifies that the src/submodule .js the alias submodule, and then the file can be loaded from the alias.

  1. import submodule from 'es-module-package/submodule';
  2. // 加载 ./node_modules/es-module-package/src/submodule.js

The following is an example of a subdirecte alias.

  1. // ./node_modules/es-module-package/package.json
  2. {
  3. "exports": {
  4. "./features/": "./src/features/"
  5. }
  6. }
  7. import feature from 'es-module-package/features/x.js';
  8. // 加载 ./node_modules/es-module-package/src/features/x.js

If you don't specify an alias, you can't load the script in the form of Modules and Script Names.

  1. // 报错
  2. import submodule from 'es-module-package/private-module.js';
  3. // 不报错
  4. import submodule from './node_modules/es-module-package/private-module.js';

(2) The alias of main

The alias for the exports field is if it is . , which represents the main entrance to the module, takes precedence over the main field and can be simply capitalization as the value of the exports field.

  1. {
  2. "exports": {
  3. ".": "./main.js"
  4. }
  5. }
  6. // 等同于
  7. {
  8. "exports": "./main.js"
  9. }

Because the exports field is known only to node .js ES6-supported nodes, it can be used to compatible with older versions .js.

  1. {
  2. "main": "./main-legacy.cjs",
  3. "exports": {
  4. ".": "./main-modern.cjs"
  5. }
  6. }

In the code above, the entry file for the older version of node.js (which does not support the ES6 module) is main-legacy.cjs, and the entry file for the new version of node.js is main-modern.cjs.

(3) Conditional loading

Use. T his alias can specify different entrances for the ES6 module and CommonJS. Currently, this feature requires the .js the --test-conditional-exports flag when the node is running.

  1. {
  2. "type": "module",
  3. "exports": {
  4. ".": {
  5. "require": "./main.cjs",
  6. "default": "./main.js"
  7. }
  8. }
  9. }

In the code above, the alias . The require condition specifies the entry file of the require() command (that is, the entrance to CommonJS), and the default condition specifies the entry of other cases (that is, the entrance to ES6).

The above writing can be simplified as follows.

  1. {
  2. "exports": {
  3. "require": "./main.cjs",
  4. "default": "./main.js"
  5. }
  6. }

Note that if there are other aliases at the same time, you cannot use short writing, otherwise or report an error.

  1. {
  2. // 报错
  3. "exports": {
  4. "./feature": "./lib/feature.js",
  5. "require": "./main.cjs",
  6. "default": "./main.js"
  7. }
  8. }

The ES6 module loads the CommonJS module

Currently, a common way for a module to support both ES6 and main CommonJS formats is that the package.json file specifies the CommonJS entry for Node.js usage, and the module field specifies the ES6 module entry for the packaging tool because node.js does not know the module field.

With the condition loading of the last section, node .js can handle both modules at the same time.

  1. // ./node_modules/pkg/package.json
  2. {
  3. "type": "module",
  4. "main": "./index.cjs",
  5. "exports": {
  6. "require": "./index.cjs",
  7. "default": "./wrapper.mjs"
  8. }
  9. }

The code above specifies the CommonJS entry file index.cjs, and the following is the code for this file.

  1. // ./node_modules/pkg/index.cjs
  2. exports.name = 'value';

The ES6 module can then load the file.

  1. // ./node_modules/pkg/wrapper.mjs
  2. import cjsModule from './index.cjs';
  3. export const name = cjsModule.name;

Note that the import command loads the CommonJS module, which can only be loaded as a whole, not just a single output item.

  1. // 正确
  2. import packageMain from 'commonjs-package';
  3. // 报错
  4. import { method } from 'commonjs-package';

Another workable way to load is to use node .js the built-in module.createRequire() method.

  1. // cjs.cjs
  2. module.exports = 'cjs';
  3. // esm.mjs
  4. import { createRequire } from 'module';
  5. const require = createRequire(import.meta.url);
  6. const cjs = require('./cjs.cjs');
  7. cjs === 'cjs'; // true

In the above code, the ES6 module can load the CommonJS module via the module.createRequire() method

The CommonJS module loads the ES6 module

CommonJS require does not load ES6 module, reports errors, and can only be import() method.

  1. (async () => {
  2. await import('./my-app.mjs');
  3. })();

The above code can be run in the CommonJS module.

Node .js built-in modules

Node .js modules can be loaded as a whole or as a specified output item.

  1. // 整体加载
  2. import EventEmitter from 'events';
  3. const e = new EventEmitter();
  4. // 加载指定的输出项
  5. import { readFile } from 'fs';
  6. readFile('./foo.txt', (err, source) => {
  7. if (err) {
  8. console.error(err);
  9. } else {
  10. console.log(source);
  11. }
  12. });

Load the path

The loading path of the ES6 module must give the full path to the script and cannot omit the script's suffix name. import field of package.json main will report an error if the script's suffix name is omitted.

  1. // ES6 模块中将报错
  2. import { something } from './index';

In order to be the import the browser's import loading rules, node .js supports the URL path for .mjs file.

  1. import './foo.mjs?query=1'; // 加载 ./foo 传入参数 ?query=1

In the above code, the script path comes with the parameter ?query=1, and Node is interpreted by URL rules. T he same script is loaded multiple times and saved in a different cache whenever the parameters are different. F or this reason, as long as the file name contains: , % , , , and other special characters, it is best to escape them.

Currently, node.js import command only supports loading local modules (file: protocol) and data: protocols, and does not support loading remote modules. In addition, script paths support only relative paths, not absolute paths (i.e., paths that start with /or /).

Finally, Node's 'import'' command is loaded asynchronously, which is the same as the browser handles.

The internal variable

The ES6 module should be generic and the same module can be used in browser and server environments without modification. To achieve this, Node states that some internal variables unique to the CommonJS module cannot be used in the ES6 module.

First, the this keyword. In the ES6 module, this the top points undefined this at the top of this CommonJS module points to the current module, which is a significant difference between the two.

Second, the following top-level variables do not exist in the ES6 module.

  • arguments
  • require
  • module
  • exports
  • __filename
  • __dirname

4. Loop load

“循环加载” means that the execution of a script depends on the b script, while the execution of the b script depends on the a script.

  1. // a.js
  2. var b = require('b');
  3. // b.js
  4. var a = require('a');

In general, "loop loading" indicates that there is strong coupling, which, if not handled well, can also lead to recursive loading, which prevents the program from executing and should therefore be avoided.

In practice, however, this is difficult to avoid, especially for large projects with complex dependencies, where a dependency b, b depend on c, and c depend on a. This means that the module loading mechanism must consider the "loop load" scenario.

For the JavaScript language, the two most common module formats, CommonJS and ES6, handle "loop loading" differently and return different results.

How the CommonJS module is loaded

Before describing how ES6 handles "loop loading," let's introduce the loading principle of the most popular CommonJS module format.

A module of CommonJS is a script file. require first time the require command loads the script, the entire script is executed and an object is generated in memory.

  1. {
  2. id: '...',
  3. exports: { ... },
  4. loaded: true,
  5. ...
  6. }

The above code is an object generated after the node internally loads the module. T he id property of the object is the module name, the exports property is the interface of the module output, and the loaded property is a Boolean value that indicates whether the module's script has been executed. There are many other attributes that are omitted here.

When you need to use this module later, you'll get a value on the exports property. E ven if the require command is executed again, the module is not executed again, but is taken into the cache. That is, no matter how many times the CommonJS module is loaded, it will only run once on the first load and then load later, returning the results of the first run unless the system cache is manually cleared.

The loop load of the CommonJS module

CommonJS module is that it is executed at load time, i.e. require code is executed in full at the time of need. Once a module is "loop loaded", only the part that has already been executed is output, and the part that has not yet been executed is not output.

Let's look at the examples in node's official documentation. Script file a.js code is as follows.

  1. exports.done = false;
  2. var b = require('./b.js');
  3. console.log('在 a.js 之中,b.done = %j', b.done);
  4. exports.done = true;
  5. console.log('a.js 执行完毕');

In the code above, .js the script outputs a done variable and then loads another script file, b.js. Note that at this point .js the code stops here, waiting for the b.js to be executed before going down.

Then look at .js b-code.

  1. exports.done = false;
  2. var a = require('./a.js');
  3. console.log('在 b.js 之中,a.done = %j', a.done);
  4. exports.done = true;
  5. console.log('b.js 执行完毕');

In the code above, .js b-line is executed to the second line, and the a.js is loaded, and then a "loop load" occurs. The system will take the value of the exports property of the object corresponding to the a.js module, but because the a.js has not been executed, only the part that has already been executed can be taken back from the exports property, not the last value.

a .js that have been executed, there is only one line.

  1. exports.done = false;

Therefore, for b.js, it enters only one variable, done, from a .js, with a value of false.

The b.js then executes further, until all execution is complete, and then the execution is returned to .js. S o, .js executes until the execution is complete. Let's write a script main .js verify the process.

  1. var a = require('./a.js');
  2. var b = require('./b.js');
  3. console.log('在 main.js 之中, a.done=%j, b.done=%j', a.done, b.done);

Perform the main .js, and the results are as follows.

  1. $ node main.js
  2. b.js 之中,a.done = false
  3. b.js 执行完毕
  4. a.js 之中,b.done = true
  5. a.js 执行完毕
  6. main.js 之中, a.done=true, b.done=true

The code above proves two things. O ne is that, in .js b-.js, the first line is not executed, only the first line is executed. Second, when the main .js executes to the second row, it does not execute b.js again, but rather outputs the result of the execution of the cached b.js, which is its fourth line.

  1. exports.done = true;

In summary, CommonJS enters a copy of the output value, not a reference.

In addition, there may be differences because when the CommonJS module encounters a loop load, it returns a value for the part that is currently executed, rather than the value after the code has all been executed. Therefore, you must be very careful when entering variables.

  1. var a = require('a'); // 安全的写法
  2. var foo = require('a').foo; // 危险的写法
  3. exports.good = function (arg) {
  4. return a.foo('good', arg); // 使用的是 a.foo 的最新值
  5. };
  6. exports.bad = function (arg) {
  7. return foo('bad', arg); // 使用的是一个部分加载时的值
  8. };

In the above code, if a loop load occurs, the value of the require ('a').foo is likely to be rewritten later, and it would be a little more safe to switch to require ('a').

Loop loading of ES6 modules

ES6 handles "loop loading" in a fundamentally different way from CommonJS. The ES6 module is a dynamic reference, and import load variables from a module import foo from 'foo' variables are not cached, but become a reference to the loaded module, requiring the developer to guarantee that the value will be available when the value is actually taken.

Take a look at the following example.

  1. // a.mjs
  2. import {bar} from './b';
  3. console.log('a.mjs');
  4. console.log(bar);
  5. export let foo = 'foo';
  6. // b.mjs
  7. import {foo} from './a';
  8. console.log('b.mjs');
  9. console.log(foo);
  10. export let bar = 'bar';

In the above code, a.mjs load b.mjs and b.mjs load a.mjs, which constitute a circular load. Execute a.mjs, as follows.

  1. $ node --experimental-modules a.mjs
  2. b.mjs
  3. ReferenceError: foo is not defined

In the above code, the execution of a.mjs will report an error later, the foo variable is not defined, why?

Let's look at how the ES6 loop load is handled. F irst, after executing a.mjs, the engine finds that it is loaded with b.mjs, so b.mjs is executed first, and then a.mjs is executed. T hen, when b.mjs is executed, it is known to have entered the foo interface from a.mjs, and instead of executing a.mjs, it is considered to already exist and continues down. It was only when the third .log console (foo) that it was discovered that the interface was simply undefined and therefore erred.

The way to solve this problem is to let b.mjs run when foo is already defined. This can be solved by writing foo as a function.

  1. // a.mjs
  2. import {bar} from './b';
  3. console.log('a.mjs');
  4. console.log(bar());
  5. function foo() { return 'foo' }
  6. export {foo};
  7. // b.mjs
  8. import {foo} from './a';
  9. console.log('b.mjs');
  10. console.log(foo());
  11. function bar() { return 'bar' }
  12. export {bar};

Then execute a.mjs to get the expected results.

  1. $ node --experimental-modules a.mjs
  2. b.mjs
  3. foo
  4. a.mjs
  5. bar

This is because the function has a lift effect, and the function foo is already defined when the import is executed, so b.mjs does not report errors when loading. This also means that if the function foo is rewritten as a function expression, an error is also reported.

  1. // a.mjs
  2. import {bar} from './b';
  3. console.log('a.mjs');
  4. console.log(bar());
  5. const foo = () => 'foo';
  6. export {foo};

The fourth line of the above code, changed to a function expression, does not have a lifting effect, the execution will report errors.

Let's look at another example given by the ES6 module loader SystemJS.

  1. // even.js
  2. import { odd } from './odd'
  3. export var counter = 0;
  4. export function even(n) {
  5. counter++;
  6. return n === 0 || odd(n - 1);
  7. }
  8. // odd.js
  9. import { even } from './even';
  10. export function odd(n) {
  11. return n !== 0 && even(n - 1);
  12. }

In the code above, the function .js inside the .js even has a parameter n, which subtracts 1 as long as it is not equal to 0, and the incoming loaded odd(). odd .js do the same.

Run this code above, and the results are as follows.

  1. $ babel-node
  2. > import * as m from './even.js';
  3. > m.even(10);
  4. true
  5. > m.counter
  6. 6
  7. > m.even(20)
  8. true
  9. > m.counter
  10. 17

In the above code, as argument n changes from 10 to 0, even() executes a total of 6 times, so the variable counter is equal to 6. On the second call to even(), argument n changes from 20 to 0, and even() executes a total of 11 times, plus the previous 6 times, so the variable counter equals 17.

If this example were to be rewritten into CommonJS, it would not be executed at all and would be reported as wrong.

  1. // even.js
  2. var odd = require('./odd');
  3. var counter = 0;
  4. exports.counter = counter;
  5. exports.even = function (n) {
  6. counter++;
  7. return n == 0 || odd(n - 1);
  8. }
  9. // odd.js
  10. var even = require('./even').even;
  11. module.exports = function (n) {
  12. return n != 0 && even(n - 1);
  13. }

In the code above, the .js load odd .js, and the odd .js to load the .js to form a "loop load". At this point, the execution engine outputs the part that even.js has already executed (no results exist), so in odd.js, the variable even is equal to undefined, and then calls even (n - 1) later to report an error.

  1. $ node
  2. > var m = require('./even');
  3. > m.even(10)
  4. TypeError: even is not a function