浏览器加载
<script>标签打开 defer 或 async 属性,脚本就会异步加载。
defer 与 async 的区别是:defer 要等到整个页面在内存中正常渲染结束(DOM 结构完全生成,以及其他脚本执行完成),才会执行;async 一旦下载完,渲染引擎就会中断渲染,执行这个脚本以后,再继续渲染。一句话,defer 是“渲染完再执行”,async 是“下载完就执行”。另外,如果有多个 defer 脚本,会按照它们在页面出现的顺序加载,而多个 async 脚本是不能保证加载顺序的。
浏览器加载 ES6 模块,也使用<script>标签,但是要加入 type="module"属性。
带有 type="module"的<script>,都是异步加载,不会造成堵塞浏览器,即等到整个页面渲染完,再执行模块脚本,等同于打开了<script>标签的 defer 属性。
<script>标签的 async 属性也可以打开,这时只要加载完成,渲染引擎就会中断渲染立即执行。执行完成后,再恢复渲染。
ES6 模块与 CommonJS 模块的差异
它们有两个重大差异
- CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
- CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。
第二个差异是因为 CommonJS 加载的是一个对象(即 module.exports 属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。
第一个差异。CommonJS 模块输出的是值的拷贝,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。
Node.js 加载
概述
Node.js 对 ES6 模块的处理比较麻烦,因为它有自己的 CommonJS 模块格式,与 ES6 模块格式是不兼容的。目前的解决方案是,将两者分开,ES6 模块和 CommonJS 采用各自的加载方案。从 v13.2 版本开始,Node.js 已经默认打开了 ES6 模块支持。
.mjs 文件总是以 ES6 模块加载,.cjs 文件总是以 CommonJS 模块加载,.js 文件的加载取决于 package.json 里面 type 字段的设置。
main 字段
package.json 文件有两个字段可以指定模块的入口文件:main 和 exports。比较简单的模块,可以只使用 main 字段,指定模块加载的入口文件。
// ./node_modules/es-module-package/package.json
{
"type": "module",
"main": "./src/index.js"
}
上面代码指定项目的入口脚本为./src/index.js,它的格式为 ES6 模块。如果没有 type 字段,index.js 就会被解释为 CommonJS 模块。
exports 字段
exports 字段的优先级高于 main 字段。它有多种用法。 (1)子目录别名
package.json 文件的 exports 字段可以指定脚本或子目录的别名。
// ./node_modules/es-module-package/package.json
{
"exports": {
"./submodule": "./src/submodule.js"
}
}
上面的代码指定 src/submodule.js 别名为 submodule,然后就可以从别名加载这个文件。 (2)main 的别名 exports 字段的别名如果是.,就代表模块的主入口,优先级高于 main 字段,并且可以直接简写成 exports 字段的值。
{
"exports": {
".": "./main.js"
}
}
// 等同于
{
"exports": "./main.js"
}
(3)条件加载 利用.这个别名,可以为 ES6 模块和 CommonJS 指定不同的入口。目前,这个功能需要在 Node.js 运行的时候,打开--experimental-conditional-exports 标志。
{
"type": "module",
"exports": {
".": {
"require": "./main.cjs",
"default": "./main.js"
}
}
}
ES6 模块加载 CommonJS 模块
目前,一个模块同时支持 ES6 和 CommonJS 两种格式的常见方法是,package.json 文件的 main 字段指定 CommonJS 入口,给 Node.js 使用;module 字段指定 ES6 模块入口,给打包工具使用,因为 Node.js 不认识 module 字段。
有了上一节的条件加载以后,Node.js 本身就可以同时处理两种模块。
// ./node_modules/pkg/package.json
{
"type": "module",
"main": "./index.cjs",
"exports": {
"require": "./index.cjs",
"default": "./wrapper.mjs"
}
}
ES6 模块可以加载这个文件。
// ./node_modules/pkg/wrapper.mjs
import cjsModule from "./index.cjs";
export const name = cjsModule.name;
import 命令加载 CommonJS 模块,只能整体加载,不能只加载单一的输出项。
还有一种变通的加载方法,就是使用 Node.js 内置的 module.createRequire()方法。
// 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
CommonJS 模块加载 ES6 模块
CommonJS 的 require 命令不能加载 ES6 模块,会报错,只能使用 import()这个方法加载。
(async () => {
await import("./my-app.mjs");
})();
Node.js 的内置模块
Node.js 的内置模块可以整体加载,也可以加载指定的输出项。
// 整体加载
import EventEmitter from "events";
const e = new EventEmitter();
// 加载指定的输出项
import { readFile } from "fs";
readFile("./foo.txt", (err, source) => {
if (err) {
console.error(err);
} else {
console.log(source);
}
});
循环加载
CommonJS 模块的循环加载
CommonJS 的一个模块,就是一个脚本文件。require 命令第一次加载该脚本,就会执行整个脚本,然后在内存生成一个对象。
{
id: '...',
exports: { ... },
loaded: true,
...
}
以后需要用到这个模块的时候,就会到 exports 属性上面取值。即使再次执行 require 命令,也不会再次执行该模块,而是到缓存之中取值。
CommonJS 模块的重要特性是加载时执行,即脚本代码在 require 的时候,就会全部执行。一旦出现某个模块被"循环加载",就只输出已经执行的部分,还未执行的部分不会输出。
ES6 模块的循环加载
ES6 处理“循环加载”与 CommonJS 有本质的不同。ES6 模块是动态引用,如果使用 import 从一个模块加载变量(即 import foo from 'foo'),那些变量不会被缓存,而是成为一个指向被加载模块的引用,需要开发者自己保证,真正取值的时候能够取到值。
请看下面这个例子。
// 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";
上面代码中,a.mjs 加载 b.mjs,b.mjs 又加载 a.mjs,构成循环加载。执行 a.mjs,结果如下。
$ node --experimental-modules a.mjs
b.mjs
ReferenceError: foo is not defined