Module loading implementation

The last chapter introduced the module syntax. This chapter introduces how to load ES6 modules in the browser and Node.js, as well as some problems often encountered in actual development (such as loop loading).

Browser loading

Traditional method

In HTML pages, the browser loads JavaScript scripts through the <script> tag.

<!-- Script embedded in the page-->
<script type="application/javascript">
  // module code
</script>

<!-- External script-->
<script type="application/javascript" src="path/to/myModule.js"></script>

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

By default, the browser loads JavaScript scripts synchronously, that is, the rendering engine will stop when it encounters the <script> tag, and then continue to render downwards after the script is executed. If it is an external script, the time when the script was downloaded must also be added.

If the script is large in size, it will take a long time to download and execute, which will cause the browser to block, and the user will feel that the browser is "stuck" without any response. This is obviously a very bad experience, so browsers allow scripts to be loaded asynchronously. The following are two syntaxes for asynchronous loading.

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

In the above code, if the <script> tag opens the defer or async attribute, the script will be loaded asynchronously. When the rendering engine encounters this line of command, it will start downloading the external script, but it will not wait for it to download and execute, but will directly execute the following commands.

The difference between defer and async is: defer will not execute until the entire page is rendered normally in memory (the DOM structure is completely generated, and other scripts are executed); once async is downloaded, the rendering engine The rendering will be interrupted, and the rendering will continue after executing this script. In a word, defer means "execute after rendering", and async means "execute after downloading". In addition, if there are multiple defer scripts, they will be loaded in the order in which they appear on the page, and multiple async scripts cannot guarantee the loading order.

Loading rules

The browser loads ES6 modules and also uses the <script> tag, but the type="module" attribute should be added.

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

The above code inserts a module foo.js into the web page. Since the type attribute is set to module, the browser knows that this is an ES6 module.

The browser loads the <script> with type="module" asynchronously, which will not block the browser, that is, wait until the entire page is rendered before executing the module script, which is equivalent to opening the <script > The deferattribute of thetag.

<script type="module" src="./foo.js"></script>
<!-- Same as -->
<script type="module" src="./foo.js" defer></script>

If the webpage has multiple <script type="module">, they will be executed in the order in which they appear on the page.

The async attribute of the <script> tag can also be turned on. At this time, as long as the loading is completed, the rendering engine will interrupt the rendering and execute it immediately. After the execution is complete, the rendering is resumed.

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

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

ES6 modules are also allowed to be embedded in web pages, and their grammatical behavior is exactly the same as loading external scripts.

<script type="module">
  import utils from "./utils.js";

  // other code
</script>

For example, jQuery supports module loading.

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

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

-The code runs in the module scope, not in the global scope. The top-level variables inside the module are not visible outside. -Module scripts automatically adopt strict mode, regardless of whether use strict is declared or not. -Among the modules, you can use the import command to load other modules (the .js suffix cannot be omitted, you need to provide an absolute URL or a relative URL), or you can use the export command to export external interfaces. -In the module, the top-level this keyword returns undefined instead of pointing to window. In other words, it is meaningless to use the this keyword at the top level of the module. -If the same module is loaded multiple times, it will be executed only once.

Below is an example module.

import utils from "https://example.com/js/utils.js";

const x = 1;

console.log(x === window.x); //false
console.log(this === undefined); // true

Using the top-level this equals undefined syntax point, you can detect whether the current code is in the ES6 module.

const isNotModuleScript = this !== undefined;

Differences between ES6 modules and CommonJS modules

Before discussing Node.js loading ES6 modules, you must understand that ES6 modules are completely different from CommonJS modules.

There are three major differences.

-The output of the CommonJS module is a copy of the value, and the output of the ES6 module is the reference of the value. -The CommonJS module is loaded at runtime, and the ES6 module is the output interface at compile time. -The require() of the CommonJS module is to load the module synchronously, and the import command of the ES6 module is to be loaded asynchronously, with an independent module dependency analysis phase.

The second difference is because CommonJS loads an object (the module.exports property), which will only be generated after the script is run. The ES6 module is not an object, and its external interface is just a static definition, which will be generated during the static code analysis phase.

The following focuses on explaining the first difference.

The output of the CommonJS module is a copy of the value, that is, once a value is output, changes within the module will not affect the value. Please see the example of the module file lib.js below.

// lib.js
var counter = 3;
function incCounter() {
  counter++;
}
module.exports = {
  counter: counter,
  incCounter: incCounter,
};

The above code outputs the internal variable counter and the internal method incCounter that rewrites this variable. Then, load this module in main.js.

// main.js
var mod = require("./lib");

console.log(mod.counter); // 3
mod.incCounter();
console.log(mod.counter); // 3

The above code shows that after the lib.js module is loaded, its internal changes will not affect the output mod.counter. This is because mod.counter is a primitive value and will be cached. Unless it is written as a function, the internally changed value can be obtained.

// lib.js
var counter = 3;
function incCounter() {
  counter++;
}
module.exports = {
  get counter() {
    return counter;
  },
  incCounter: incCounter,
};

In the above code, the output counter attribute is actually a value taker function. Now execute main.js again, and the changes of the internal variable counter can be read correctly.

$ node main.js
3
4

The operating mechanism of ES6 modules is different from CommonJS. When the JS engine analyzes the script statically, it will generate a read-only reference when it encounters the module loading command import. When the script is actually executed, the read-only reference is used to get the value in the loaded module. In other words, ES6's import is a bit like the "symbolic link" of Unix systems. The original value changes, and the value loaded by import will also change. Therefore, ES6 modules are dynamically referenced, and values ​​are not cached, and the variables in the module are bound to the module in which they are located.

Let's take the example above.

// lib.js
export let counter = 3;
export function incCounter() {
  counter++;
}

// main.js
import { counter, incCounter } from "./lib";
console.log(counter); // 3
incCounter();
console.log(counter); // 4

The above code shows that the variable counter input by the ES6 module is alive and fully reflects the changes inside the module lib.js where it is located.

Give another example that appeared in the export section.

// m1.js
export var foo = "bar";
setTimeout(() => (foo = "baz"), 500);

// m2.js
import { foo } from "./m1.js";
console.log(foo);
setTimeout(() => console.log(foo), 500);

In the above code, the variable foo of m1.js is equal to bar when it is first loaded, and becomes equal to baz again after 500 milliseconds.

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

$ babel-node m2.js

bar
baz

The above code shows that the ES6 module does not cache the running result, but dynamically fetches the value of the loaded module, and the variable is always bound to the module where it is located.

Since the module variable input by ES6 is only a "symbolic connection", this variable is read-only, and an error will be reported if it is re-assigned.

// lib.js
export let obj = {};

// main.js
import { obj } from "./lib";

obj.prop = 123; // OK
obj = {}; // TypeError

In the above code, main.js enters the variable obj from lib.js. You can add attributes to obj, but you will get an error if you re-assign the value. Because the address pointed to by the variable obj is read-only and cannot be reassigned, it is like main.js creates a const variable named obj.

Finally, export outputs the same value through the interface. Different scripts load this interface and get the same instance.

// mod.js
function C() {
  this.sum = 0;
  this.add = function () {
    this.sum += 1;
  };
  this.show = function () {
    console.log(this.sum);
  };
}

export let c = new C();

The above script mod.js, the output is an instance of C. Different scripts load this module and get the same instance.

// x.js
import { c } from "./mod";
c.add();

// y.js
import { c } from "./mod";
c.show();

// main.js
import "./x";
import "./y";

Now execute main.js, the output is 1.

$ babel-node main.js
1

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

Node.js module loading method

Overview

JavaScript now has two modules. One is ES6 module, referred to as ESM; the other is CommonJS module, referred to as CJS.

The CommonJS module is dedicated to Node.js and is not compatible with ES6 modules. In the syntax above, the most obvious difference between the two is that CommonJS modules use require() and module.exports, and ES6 modules use import and export.

They use different loading schemes. Starting from Node.js v13.2, Node.js has enabled ES6 module support by default.

Node.js requires ES6 modules to use .mjs suffix file name. In other words, as long as the import or export command is used in the script file, the .mjs suffix name must be used. When Node.js encounters a .mjs file, it considers it to be an ES6 module, and strict mode is enabled by default. It is not necessary to specify "use strict" at the top of each module file.

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

{
   "type": "module"
}

Once set, the JS scripts in this directory will be interpreted as ES6 modules.

# Interpreted as ES6 module
$ node my-app.js

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

Summarized in one sentence: .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 setting of the type field in package.json .

Note, try not to mix ES6 modules and CommonJS modules. The require command cannot load the .mjs file and will report an error. Only the import command can load the .mjs file. Conversely, the require command cannot be used in the .mjs file, and the import must be used.

The main field of package.json

The package.json file has two fields to specify the entry file of the module: main and exports. For simpler modules, you can use only the main field to specify the entry file loaded by the module.

// ./node_modules/es-module-package/package.json
{
  "type": "module",
  "main": "./src/index.js"
}

The above code specifies that the entry script of the project is ./src/index.js, and its format is an ES6 module. If there is no type field, index.js will be interpreted as a CommonJS module.

Then, the import command can load this module.

// ./my-app.mjs

import { something } from "es-module-package";
// What is actually loaded is ./node_modules/es-module-package/src/index.js

In the above code, after running the script, Node.js will go to the ./node_modules directory, look for the es-module-package module, and then execute the entry according to the main field of the module package.json file.

At this time, if you use the require() command of the CommonJS module to load the es-module-package module, an error will be reported, because the CommonJS module cannot handle the export command.

The exports field of package.json

The priority of the exports field is higher than that of the main field. It has multiple uses.

(1) Subdirectory alias

The exports field of the package.json file can specify aliases for scripts or subdirectories.

// ./node_modules/es-module-package/package.json
{
  "exports": {
    "./submodule": "./src/submodule.js"
  }
}

The above code assigns the alias of src/submodule.js as submodule, and then this file can be loaded from the alias.

import submodule from "es-module-package/submodule";
// Load./node_modules/es-module-package/src/submodule.js

The following is an example of subdirectory aliases.

// ./node_modules/es-module-package/package.json
{
  "exports": {
    "./features/": "./src/features/"
  }
}

import feature from'es-module-package/features/x.js';
// Load./node_modules/es-module-package/src/features/x.js

If no alias is specified, the script cannot be loaded in the form of "module + script name".

// report an error
import submodule from "es-module-package/private-module.js";

// No error
import submodule from "./node_modules/es-module-package/private-module.js";

(2) The alias of main

If the alias of the exports field is ., it represents the main entrance of the module, which has a higher priority than the main field, and can be directly abbreviated to the value of the exports field.

{
  "exports": {
    ".": "./main.js"
  }
}

// Equivalent to
{
  "exports": "./main.js"
}

Since the exports field is only recognized by Node.js that supports ES6, it can be used to be compatible with older versions of Node.js.

{
  "main": "./main-legacy.cjs",
  "exports": {
    ".": "./main-modern.cjs"
  }
}

In the above code, the entry file of the old version of Node.js (not supporting ES6 modules) is main-legacy.cjs, and the entry file of the new version of Node.js is main-modern.cjs.

(3) Conditional loading

Using the alias ., you can specify different entries for ES6 modules and CommonJS. Currently, this function needs to enable the --experimental-conditional-exports flag when Node.js is running.

{
  "type": "module",
  "exports": {
    ".": {
      "require": "./main.cjs",
      "default": "./main.js"
    }
  }
}

In the above code, the require condition of the alias . specifies the entry file of the require() command (ie the entry of CommonJS), and the default condition specifies the entry of other cases (ie the entry of ES6).

The above writing can be abbreviated as follows.

{
  "exports": {
    "require": "./main.cjs",
    "default": "./main.js"
  }
}

Note that if there are other aliases at the same time, you cannot use abbreviations, otherwise an error may be reported.

{
  // report an error
  "exports": {
    "./feature": "./lib/feature.js",
    "require": "./main.cjs",
    "default": "./main.js"
  }
}

CommonJS module loads ES6 modules

The require() command of CommonJS cannot load ES6 modules and will report an error. You can only use the import() method to load.

(async () => {
  await import("./my-app.mjs");
})();

The above code can be run in CommonJS module.

One of the reasons that require() does not support ES6 modules is that it is loaded synchronously, and the top-level await command can be used inside ES6 modules, which prevents them from being loaded synchronously.

ES6 modules load CommonJS modules

The import command of ES6 modules can load CommonJS modules, but they can only be loaded as a whole, not just a single output item.

// correct
import packageMain from "commonjs-package";

// report an error
import { method } from "commonjs-package";

This is because ES6 modules need to support static code analysis, and the output interface of CommonJS modules is module.exports, which is an object that cannot be statically analyzed, so it can only be loaded as a whole.

Load a single output item, which can be written as follows.

import packageMain from "commonjs-package";
const { method } = packageMain;

Another alternative loading method is to use Node.js's built-in module.createRequire() method.

// cjs.cjs
module.exports = "cjs";

// esm.mjs
import { createRequire } from "module";

const require = createRequire(import.meta.url);

const cjs = require("./cjs.cjs");
cjs === "cjs"; // true

In the above code, the ES6 module can load the CommonJS module through the module.createRequire() method. However, this way of writing is equivalent to mixing ES6 and CommonJS, so it is not recommended.

Support two formats of modules at the same time

It is very easy for a module to support both CommonJS and ES6 formats at the same time.

If the original module is in ES6 format, you need to give an overall output interface, such as export default obj, so that CommonJS can be loaded with import().

If the original module is in CommonJS format, a wrapper layer can be added.

import cjsModule from "../index.js";
export const foo = cjsModule.foo;

The above code first enters the CommonJS module as a whole, and then outputs the named interface as needed.

You can change the suffix of this file to .mjs, or put it in a subdirectory, and put a separate package.json file in this subdirectory, specifying { type: "module"} .

Another way is to specify the respective loading entry of the two format modules in the exports field of the package.json file.

"exports": {
  "require": "./index.js",
  "import": "./esm/wrapper.js"
}

The above code specifies require() and import, and loading the module will automatically switch to a different entry file.

Node.js built-in modules

The built-in modules of Node.js can be loaded as a whole, or specified output items can be loaded.

// Overall loading
import EventEmitter from "events";
const e = new EventEmitter();

// Load the specified output item
import { readFile } from "fs";
readFile("./foo.txt", (err, source) => {
  if (err) {
    console.error(err);
  } else {
    console.log(source);
  }
});

Load path

The loading path of the ES6 module must give the full path of the script, and the suffix of the script cannot be omitted. If you omit the suffix of the script in the main field of the import command and the package.json file, an error will be reported.

// An error will be reported in the ES6 module
import { something } from "./index";

In order to be the same as the browser's import loading rules, Node.js .mjs files support URL paths.

import "./foo.mjs?query=1"; // Load. /foo pass in parameters? query=1

In the above code, the script path has a parameter ?query=1, and Node will interpret it according to the URL rules. As long as the parameters of the same script are different, it will be loaded multiple times and saved in different caches. For this reason, as long as the file name contains special characters such as :, %, #, ?, it is best to escape these characters.

Currently, the import command of Node.js only supports loading local modules (file: protocol) and data: protocol, and does not support loading remote modules. In addition, script paths only support relative paths, not absolute paths (that is, paths beginning with / or //).

Internal variables

ES6 modules should be universal, and the same module can be used in browser and server environments without modification. In order to achieve this goal, Node.js stipulates that some internal variables unique to CommonJS modules cannot be used in ES6 modules.

First, there is the this keyword. Among ES6 modules, the top-level this points to undefined; the top-level this of CommonJS modules points to the current module. This is a major difference between the two.

Secondly, the following top-level variables do not exist in ES6 modules.

-arguments -require -module -exports -__filename -__dirname

Cycle loading

"Circular dependency" means that the execution of the a script depends on the b script, and the execution of the b script depends on the a script.

// a.js
var b = require("b");

// b.js
var a = require("a");

Generally, "cyclic loading" means that there is strong coupling. If it is not handled well, it may also lead to recursive loading, making the program impossible to execute, so it should be avoided.

But in fact, this is difficult to avoid, especially for large projects with complex dependencies. It is easy for a to depend on b, b to depend on c, and c to depend on a. . This means that the module loading mechanism must consider the "cyclic loading" situation.

For the JavaScript language, the two most common module formats, CommonJS and ES6, have different methods of handling "cyclic loading" and return different results.

CommonJS module loading principle

Before introducing how ES6 handles "cyclic loading", first introduce the loading principle of the most popular CommonJS module format.

A module of CommonJS is a script file. The require command loads the script for the first time, it executes the entire script, and then generates an object in memory.

{
  id:'...',
  exports: {... },
  loaded: true,
  ...
}

The above code is an object generated after loading the module inside Node. The id attribute of the object is the name of the module, the exports attribute is the various interfaces output by the module, and the loaded attribute is a boolean value indicating whether the script of the module has been executed. There are many other attributes, which are omitted here.

When you need to use this module in the future, you will get the value on the exports attribute. Even if the require command is executed again, the module will not be executed again, but the value will be retrieved in the cache. In other words, no matter how many times the CommonJS module is loaded, it will only run once when it is loaded for the first time, and when it is loaded later, the result of the first run will be returned unless the system cache is manually cleared.

Cyclic loading of CommonJS modules

The important feature of the CommonJS module is execution when it is loaded, that is, the script code will all be executed when it is require. Once a module is "cyclically loaded", only the part that has been executed will be output, and the part that has not been executed will not be output.

Let's take a look at the example in Node Official Document. The code of the script file a.js is as follows.

exports.done = false;
var b = require("./b.js");
console.log("In a.js, b.done = %j", b.done);
exports.done = true;
console.log("a.js is executed");

In the above code, the a.js script outputs a done variable first, and then loads another script file b.js. Note that the a.js code stops here at this time, wait for the completion of the execution of b.js, and then execute again.

Look at the code of b.js again.

exports.done = false;
var a = require("./a.js");
console.log("In b.js, a.done = %j", a.done);
exports.done = true;
console.log("b.js execution completed");

In the above code, when b.js is executed to the second line, a.js will be loaded. At this time, "cyclic loading" occurs. The system will go to the exports property of the corresponding object of the a.js module to get the value, but because a.js has not been executed yet, only the executed part can be retrieved from the exports property, not the final value .

The executed part of a.js has only one line.

exports.done = false;

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

Then, b.js continues to execute until all executions are completed, and then return the execution right to a.js. So, a.js continues to execute until the execution is complete. We write a script main.js to verify this process.

var a = require("./a.js");
var b = require("./b.js");
console.log("In main.js, a.done=%j, b.done=%j", a.done, b.done);

Execute main.js and the results are as follows.

$ node main.js

In b.js, a.done = false
b.js is executed
In a.js, b.done = true
a.js is executed
In main.js, a.done=true, b.done=true

The above code proves two things. One is that in b.js, a.js has not been executed, only the first line is executed. The second is that when main.js executes to the second line, b.js will not be executed again, but the cached execution result of b.js will be output, which is its fourth line.

exports.done = true;

In short, CommonJS input is a copy of the output value, not a reference.

In addition, when the CommonJS module encounters cyclic loading, it returns the value of the currently executed part, rather than the value after all the code is executed, there may be differences between the two. Therefore, you must be very careful when entering variables.

var a = require("a"); // safe writing
var foo = require("a").foo; // Dangerous way of writing

exports.good = function (arg) {
  return a.foo("good", arg); // Use the latest value of a.foo
};

exports.bad = function (arg) {
  return foo("bad", arg); // uses a partial load value
};

In the above code, if cyclic loading occurs, the value of require('a').foo is likely to be rewritten later. It is safer to use require('a') instead.

Cyclic loading of ES6 modules

ES6's handling of "cyclic loading" is fundamentally different from CommonJS. ES6 modules are dynamic references. If you use import to load variables from a module (ie, import foo from'foo'), those variables will not be cached, but will become a reference to the loaded module, requiring the developer himself It is guaranteed that the value can be obtained when the value is actually taken.

Please see the example below.

// a.mjs
import { bar } from "./b";
console.log("a.mjs");
console.log(bar);
export let foo = "foo";

// b.mjs
import { foo } from "./a";
console.log("b.mjs");
console.log(foo);
export let bar = "bar";

In the above code, a.mjs loads b.mjs, and b.mjs loads a.mjs again, which constitutes a cyclic loading. Execute a.mjs and the results are as follows.

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

In the above code, after executing a.mjs, an error will be reported, and the variable foo is undefined. Why?

Let's see line by line how ES6 cyclic loading is handled. First, after executing a.mjs, the engine finds that it has loaded b.mjs, so it will execute b.mjs first, and then execute a.mjs. Next, when executing b.mjs, it is known that it has input the foo interface from a.mjs. At this time, a.mjs will not be executed, but the interface already exists, so continue to Executed under. When the execution reached the third line of console.log(foo), I found that this interface was not defined at all, so an error was reported.

The solution to this problem is to make foo already defined when b.mjs runs. This can be solved by writing foo as a function.

// a.mjs
import { bar } from "./b";
console.log("a.mjs");
console.log(bar());
function foo() {
  return "foo";
}
export { foo };

// b.mjs
import { foo } from "./a";
console.log("b.mjs");
console.log(foo());
function bar() {
  return "bar";
}
export { bar };

Then execute a.mjs to get the expected result.

$ node --experimental-modules a.mjs
b.mjs
foo
a.mjs
bar

This is because the function has a lifting effect. When executing import {bar} from'./b', the function foo is already defined, so no error will be reported when b.mjs is loaded. This also means that if the function foo is rewritten as a function expression, an error will be reported.

// a.mjs
import { bar } from "./b";
console.log("a.mjs");
console.log(bar());
const foo = () => "foo";
export { foo };

The fourth line of the above code is changed to a function expression, which has no promotion effect, and an error will be reported when executed.

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

// even.js
import { odd } from "./odd";
export var counter = 0;
export function even(n) {
  counter++;
  return n === 0 || odd(n - 1);
}

// odd.js
import { even } from "./even";
export function odd(n) {
  return n !== 0 && even(n - 1);
}

In the above code, the function even in even.js has a parameter n, as long as it is not equal to 0, it will subtract 1 and pass in the loaded odd(). odd.js will do similar operations.

Run the above code, the results are as follows.

$ babel-node
> import * as m from'./even.js';
> m.even(10);
true
> m.counter
6
> m.even(20)
true
> m.counter
17

In the above code, when the parameter n changes from 10 to 0, even() will be executed 6 times, so the variable counter is equal to 6. When even() is called for the second time, the parameter n changes from 20 to 0, and even() will be executed 11 times, plus the previous 6 times, so the variable counter is equal to 17.

If this example is rewritten into CommonJS, it will not be executed at all and an error will be reported.

// even.js
var odd = require("./odd");
var counter = 0;
exports.counter = counter;
exports.even = function (n) {
  counter++;
  return n == 0 || odd(n - 1);
};

// odd.js
var even = require("./even").even;
module.exports = function (n) {
  return n != 0 && even(n - 1);
};

In the above code, even.js loads odd.js, and odd.js loads even.js again, forming a "cyclic loading". At this time, the execution engine will output the executed part of even.js (there is no result), so in odd.js, the variable even is equal to undefined, wait until later to call even(n -1) will report an error.

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