首发于大转转FE
2020年我们可以在Node中使用ES Modules了吗

2020年我们可以在Node中使用ES Modules了吗

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.

4 在 NodeJS 中使用 ES Modules

在 Node 中使用 ESM 有两种方式:

1)在 package.json 中,增加 "type": "module" 配置;

文件目录结构:

.
├── index.js
├── package.json
└── utils
    └── speak.js

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

js代码:

// 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 必须明确文件扩展名

4.1 模块作用域

一个模块的作用域,由父级中有 "type: "module" 的 package.json 文件路径定义。而使用 .mjs 扩展文件加载模块,则不受限于包的作用域。

同理,package.json 中没有 type 标志的文件都会默认采用 CommonJS 模块机制, .cjs 类型的扩展文件使用 CommonJS 方式加载模块同样不受限于包的作用域。

4.2 包的入口

定义包的入口有两种方式,在 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 两个模块机制在原理上的区别

1. CommonJS

Node 将每个文件都视为独立的模块,它定义了一个 Module 构造函数,代表模块自身:

function Module(id = '', parent) {
  Home | 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 实现了以下几点:
1) 它保持了顶层的变量(用 var、 const 或 let 定义)作用在模块范围内,而不是全局对象;
2) 它有助于提供一些看似全局的但实际上是模块特定的变量;
3) 实现者可以用于从模块中导出值的 module 和 exports 对象;
4) 包含模块绝对文件名和目录路径的快捷变量 __filename 和 __dirname ;

简言之,每个模块都有自己的函数包装器, Node 通过此种方式确保模块内的代码对它是私有的。

在包装器执行之前,模块内的导出内容是不确定的。除此之外,第一次加载的模块会被缓存到 Module._cache中。

一个完整的加载周期大致如下:

Resolution (解析) –> Loading (加载) –> Wrapping (私有化) –> Evaluation (执行) –> Caching (缓存)

2. ES Modules

在 ESM 中,import 语句用于在解析代码时导入模块依赖的静态链接。文件的依赖关系在编译阶段就确定了。对于 ESM,模块的加载大致分为三步:

Construction (解析) -> Instantiation (实例化、建立链接) -> Evaluation (执行)

这些步骤是异步执行的,每一步都可以看作是相互独立的。这一点跟 CommonJS 有很大不同,对于 CommonJS 来说,每一步都是同步进行的。

7 两种模块间的相互引用

CommonJS 和 ES Modules 都支持 Dynamic import(),它可以支持两种模块机制的导入。

7.1 在 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');
})();

7.2 在 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 间的相互转换。

参考链接

编辑于 2020-12-18 11:05