如何在浏览器和 Node.js 之中加载 ES6 模块,以及实际开发中经常遇到的一些问题(比如循环加载)
加载规则
浏览器加载 ES6 模块,也使用 <script>
标签,但是要加入 type="module"
属性
<script type="module" src="./foo.js"></script>
由于 type
属性设为 module
,所以浏览器知道这是一个 ES6 模块
浏览器对于带有 type="module"
的 <script>
,都是异步加载,不会造成堵塞浏览器,即等到整个页面渲染完,再执行模块脚本,等同于打开了 <script>
标签的defer
属性
<script type="module" src="./foo.js"></script>
<!-- 等同于 -->
<script type="module" src="./foo.js" defer></script>
如果网页有多个<script type="module">
, 它们会按照在页面出现的顺序依次执行
<script>
标签的 async
属性也可以打开, 这时只要加载完成, 渲染引擎就会中断渲染立即执行. 执行完成后, 再恢复渲染
<script type="module" src="./foo.js" async></script>
- 对于外部的模块脚本(上例是foo.js),有几点需要注意:
代码是在模块作用域之中运行,而不是在全局作用域运行。模块内部的顶层变量,外部不可见。 - 模块脚本自动采用严格模式,不管有没有声明
use strict
- 模块之中, 可以使用
import
命令加载其他模块 (.js后缀不可省略,需要提供绝对 URL 或相对 URL),也可以使用export
命令输出对外接口 - 模块之中,顶层的
this
关键字返回undefined
, 而不是指向window
. 也就是说, 在模块顶层使用this关键字,是无意义的 - 同一个模块如果加载多次,将只执行一次
ES6 模块与 CommonJS 模块的差异
讨论 Node.js 加载 ES6 模块之前, 必须了解 ES6 模块与 CommonJS 模块完全不同
- CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用
- CommonJS 模块是运行时加载,ES6 模块是编译时输出接口
- CommonJS 模块的
require()
是同步加载模块,ES6 模块的import
命令是异步加载,有一个独立的模块依赖的解析阶段。
第二个差异是因为 CommonJS 加载的是一个对象(即 module.exports
属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。
CommonJS
模块输出的是值的拷贝, 也就是说, 一旦输出一个值, 模块内部的变化就影响不到这个值.
ES6 模块的运行机制与 CommonJS 不一样。JS 引擎对脚本静态分析的时候,遇到模块加载命令 import
, 就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。换句话说,ES6 的import
有点像 Unix 系统的 **”符号连接”**,原始值变了, import加载的值也会跟着变. 因此, ES6 模块是动态引用, 并且不会缓存值, 模块里面的变量绑定其所在的模块
Node.js 的模块加载方法
概述
JavaScript 现在有两种模块。一种是 ES6 模块,简称 ESM;另一种是 CommonJS 模块,简称 CJS。
CommonJS 模块是 Node.js 专用的,与 ES6 模块不兼容。语法上面,两者最明显的差异是, CommonJS 模块使用require() 和 module.exports, ES6 模块使用 import 和 export
它们采用不同的加载方案。从 Node.js v13.2
版本开始,Node.js 已经默认打开了 ES6 模块支持。
Node.js 要求 ES6 模块采用 .mjs
后缀文件名。也就是说,只要脚本文件里面使用 import
或者 export
命令,那么就必须采用 .mjs
后缀名. Node.js 遇到 .mjs
文件,就认为它是 ES6 模块,默认启用严格模式,不必在每个模块文件顶部指定**”use strict”**
如果不希望将后缀名改成 .mjs
, 可以在项目的package.json 文件中, 指定 type
字段为 module
{
"type": "module"
}
一旦设置了以后, 该目录里面的 JS 脚本, 就被解释用 ES6 模块
# 解释成 ES6 模块
$ node my-app.js
如果这时还要使用 CommonJS 模块, 那么需要将 CommonJS 脚本的后缀名都改成.cjs
. 如果没有type字段, 或者type字段为commonjs, 则.js
脚本会被解释成 CommonJS 模块
总结为一句话: .mjs
文件总是以 ES6 模块加载, .cjs
文件总是以 CommonJS 模块加载, .js
文件的加载取决于 package.json
里面 type
字段的设置
package.json 的 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 模块。
然后, import
s命令就可以加载这个模块。
package.json 的 exports 字段
exports
字段的优先级高于 main
字段. 它有多种用法
(1) 子目录别名
package.json
文件的 exports
字段可以指定脚本或子目录的别名
// ./node_modules/es-module-package/package.json
{
"exports": {
"./submodule": "./src/submodule.js"
}
}
上面的代码指定 src/submodule.js
别名为submodule
,然后就可以从别名加载这个文件.
import submodule from 'es-module-package/submodule';
// 加载 ./node_modules/es-module-package/src/submodule.js
CommonJS 模块加载 ES6 模块
CommonJS 的 require()
命令不能加载 ES6 模块, 会报错, 只能使用 import()
这个方法加载
(async () => {
await import('./my-app.mjs');
})();
上面代码可以在 CommonJS 模块中运行
require()
不支持 ES6 模块的一个原因是,它是同步加载,而 ES6 模块内部可以使用顶层 await
命令,导致无法被同步加载
ES6 模块加载 CommonJS 模块
ES6 模块的 import
命令可以加载 CommonJS 模块, 但是只能整体加载, 不能只加载单一的输出项
// 正确
import packageMain from 'commonjs-package';
// 报错
import { method } from 'commonjs-package';
这是因为 ES6 模块需要支持静态代码分析,而 CommonJS 模块的输出接口是 module.exports
, 是一个对象,无法被静态分析,所以只能整体加载。
循环加载
“循环加载” (circular dependency)指的是, a
脚本的执行依赖b
脚本, 而b
脚本的执行又依赖a
脚本
通常,“循环加载”表示存在强耦合,如果处理不好,还可能导致递归加载,使得程序无法执行,因此应该避免出现