1 前言
不同时期的的 JS 发展,诞生了不同的模块化机制;近些年,随着前端模块化的标准落地,不同端的 JS 对此也都做了各自的实现。今天我们就来聊聊这个话题。
本文我们将主要探讨以下四个方面:
- JavaScript 模块化机制概览;
- 如何在 Node 中使用 ES Modules;
- CommonJS 和 ES Modules 的模块机制的异同;
- CommonJS 和 ES Modules 的模块文件相互引用;
2 JavaScript 模块化机制概览
JavaScript 常见的模块化机制主要有以下三种:
- AMD (Asynchronous Module Definition): 在浏览器中使用,并用
define
函数定义模块; - CJS (CommonJS): 在 NodeJS 中使用,用
require
和module.exports
引入和导出模块; - ESM (ES Modules): JavaScript 从 ES6(ES2015) 开始支持的原生模块机制,使用
import
和export
引入和导出模块;
3 Node 对 ES Modules 支持
Node verison 13.2.0 开始正式支持 ES Modules 特性。
注:虽然移除了 --experimental-modules 启动参数,但是由于 ESM loader 还是实验性的,所以运行 ES Modules 代码依然会有警告:
(node:47324) ExperimentalWarning: The ESM module loader is experimental.
come from speak )。
4 在 NodeJS 中使用 ES Modules
1)在 package.json 中,增加 type: "module"
配置;
文件目录结构:
.
├── index.js
├── package.json
└── utils
└── speak.js
// utils/speak.js
export function speak() {
console.log('Come from speak.')
}
// index.js
import { speak } from './utils/speak.js';
speak(); //come from speak
2)在 .mjs 文件可以直接使用 import
和 export
;
文件目录结构:
.
├── index.mjs
├── package.json
└── utils
└── sing.mjs
// utils/sing.mjs
export function sing() {
console.log('Come from sing')
}
// index.mjs
import { sing } from './utils/sing.mjs';
sing(); //come from sing
注意:
- 若不添加上述两项中任一项,直接在 Node 中使用 ES Modules,则会抛出警告:
Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension.
- 根据 ESM 规范,使用 import 关键字并不会像 CommonJS 模块那样,在默认情况下以文件扩展名填充文件路径。因此,ES Modules 必须明确文件扩展名。
模块作用域:
一个模块的作用域,由父级中有type: "module"
的 package.json 文件路径定义。而使用.mjs
扩展文件加载模块,则不受限于包的作用域。
同理,package.json
中没有type
标志的文件都会默认采用 CommonJS 模块机制,.cjs
类型的扩展文件使用 CommonJS 方式加载模块同样不受限于包的作用域。
包的入口
定义包的入口有两种方式,在 package.json 中定义main
字段或者exports
字段:
{
"main": "./main.js",
"exports": "./main.js"
}
需要注意的是,当exports
字段被定义后,包的所有子路径都将被封装,子路径的文件不可再被导入。
例如: require('pkg/subpath.js') 将会报错:
ERR_PACKAGE_PATH_NOT_EXPORTED error.`
5. 两个模块机制在执行时机上的区别
- ES Modules 导入的模块会被预解析,以便在代码运行前导入;
- 在 CommonJS 中,模块将在运行时解析;
举一个简单的例子来直观的对比下二者的差别:
// ES Modules
// a.js
console.log('Come from a.js.');
import { hello } from './b.js';
console.log(hello);
// b.js
console.log('Come from b.js.');
export const hello = 'Hello from b.js';
输出:
Come from b.js.
Come from a.js.
Hello from b.js
同样的代码使用 CommonJS 机制:
// CommonJS
// a.js
console.log('Come from a.js.');
const hello = require('./b.js');
console.log(hello);
// b.js
console.log('Come from b.js.');
module.exports = 'Hello from b.js';
输出:
Come from a.js.
Come from b.js.
Hello from b.js
可以看到 ES Modules 预先解析了模块代码,而 CommonJS 是代码运行的时候解析的。
需要注意的是,根据 EMS 规范 import / export 必须位于模块顶级,不能位于作用域内;其次模块内的 import/export 会提升到模块顶部,这是在编译阶段完成的。
6.两个模块机制在原理上的区别
- CommonJS
Node 将每个文件都视为独立的模块,它定义了一个 Module 构造函数,代表模块自身:
function Module(id = '', parent) {
this.id = id;
this.path = path.dirname(id);
this.exports = {};
this.parent = parent;
this.filename = null;
this.loaded = false;
this.children = [];
};
而 require 函数接收一个代表模块ID或者路径的值作为参数,它返回的是用module.exports
导出的对象。在执行代码模块之前,NodeJs 将使用一个包装器对模块中的代码对其进行封装:
(function(exports, require, module, __filename, __dirname) {
// Module code actually lives in here
});
“通过这样做,Node.js 实现了以下几点:
- 它保持了顶层的变量(用 var、 const 或 let 定义)作用在模块范围内,而不是全局对象。
- 它有助于提供一些看似全局的但实际上是模块特定的变量,例如:
- 实现者可以用于从模块中导出值的 module 和 exports 对象。
- 包含模块绝对文件名和目录路径的快捷变量 __filename 和 __dirname 。
简言之,每个模块都有自己的函数包装器, Node 通过此种方式确保模块内的代码对它是私有的。
在包装器执行之前,模块内的导出内容是不确定的。除此之外,第一次加载的模块会被缓存到 Module._cache
中。
一个完整的加载周期大致如下:
Resolution (解析) –> Loading (加载) –> Wrapping (私有化) –> Evaluation (执行) –> Caching (缓存)
- ES Modules
在 ESM 中,import 语句用于在解析代码时导入模块依赖的静态链接。文件的依赖关系在编译阶段就确定了。对于 ESM,模块的加载大致分为三步:
Construction (解析) -> Instantiation (实例化、建立链接) -> Evaluation (执行)
这些步骤是异步执行的,每一步都可以看作是相互独立的。这一点跟 CommonJS 有很大不同,对于 CommonJS 来说,每一步都是同步进行的。
7. 两种模块间的相互引用
CommonJS 和 ES Modules 都支持Dynamic import()
,它可以支持两种模块机制的导入。
在 CommonJS 文件中导入 ES Modules 模块
由于 ES Modules 的加载、解析和执行都是异步的,而 require() 的过程是同步的、所以不能通过 require() 来引用一个 ES6 模块。ES6 提议的 import() 函数将会返回一个 Promise,它在 ES Modules 加载后标记完成。借助于此,我们可以在 CommonJS 中使用异步的方式导入 ES Modules:
// 使用 then() 来进行模块导入后的操作
import(“es6-modules.mjs”).then((module)=>{/*…*/}).catch((err)=>{/**…*/})
// 或者使用 async 函数
(async () => {
await import('./es6-modules.mjs');
})();
在 ES Modules 文件中导入 CommonJS 模块
在 ES6 模块里可以很方便地使用 import 来引用一个 CommonJS 模块,因为在 ES6 模块里异步加载并非是必须的:
import { default as cjs } from 'cjs';
// The following import statement is "syntax sugar" (equivalent but sweeter)
// for `{ default as cjsSugar }` in the above import statement:
import cjsSugar from 'cjs';
console.log(cjs);
console.log(cjs === cjsSugar);
8. Node 中 ES Modules 的现状和未来
在引入 ES6 标准之前,服务器端 JavaScript 代码都是依赖 CommonJS 模块机制进行包管理的。如今,随着 ES Modules 的引入,开发人员可以享受到与发布规范相关的许多好处。但需要注意的是,截止至本文发布时间,在最新版 Node v15.1.0 中,该特性依然是实验性的(Stability: 1),不建议在生产环境中使用该功能。
最后,由于两种模块格式之间存在不兼容问题,将当前项目从 CommonJS 到 ES Modules 转换将是一个挑战。可以借助 Babel 相关插件(plugin-transform-modules-commonjs, babel-plugin-transform-commonjs)实现 CommonJS 和 ES Modules 间的相互转换。
参考
- Node Documentation
- Node version13+ release log
- Node version14+ release log
- Node modules wrapper
- Node Source code: cjs
- ECMA262 Modules
- TC39 Proposal Dynamic import