原文地址: redfin.engineering/node-module…javascript
翻译的比较快,后面会持续修正,建议阅读原文html
在 Node 14 的项目里,咱们依然能看到混杂着 CommonJS(CJS) 和 ES Modules(ESM) 风格的代码。CJS 使用的是 require() 和 module.exports;ESM 用的是是 import 和 exports。
首先 ESM 和 CJS 彻底是两套不一样的设计。表面上,ESM 使用起来虽然有点接近 CJS,可是实现差别巨大。java
对于大部分初级 Node 开发者来讲,这些规则很是的难以理解,下面会详细对这些展开介绍。
不少 Node 生态的围观群众都把这些问题归结到 ESM 自己,可是接下来我会说明清楚,这些坑都是有其存在的缘由,以及将来也很难有完美的解决方案。
最后我也会给框架/库的维护者 3 个建议:node
基本上就能避开大部分坑。git
Node 从诞生开始就使用了 CJS 规范来编写模块。咱们用 require() 引用模块,用 exprts 来定义对外暴露的方法,有 module.exports.foo = 'bar' 或者 module.exports = 'baz'。
下面是一个CJS 的示例,区分两种不一样的 exports 方式对于使用上的差别。
命名导出:github
// @filename: util.cjs
module.exports.sum = (x, y) => x + y;
// @filename: main.cjs
const {sum} = require('./util.cjs');
console.log(sum(2, 4));
复制代码
默认导出:npm
// @filename: util.cjs
module.exports = (x, y) => x + y;
// @filename: main.cjs
const whateverWeWant = require('./util.cjs');
console.log(whateverWeWant(2, 4));
复制代码
ESM 规范使用的是 import 和 export,和 CJS 同样也有两种 export 的模式。
命名导出:json
// @filename: util.mjs
export const sum = (x, y) => x + y;
// @filename: main.mjs
import {sum} from './util.mjs'
console.log(sum(2, 4));
复制代码
默认导出:api
// @filename: util.mjs
export default (x, y) => x + y;
// @filename: main.mjs
import whateverWeWant from './util.mjs'
console.log(whateverWeWant(2, 4));
复制代码
CJS 的 require() 是同步的,实际执行的时候会从磁盘或者网络中读取文件,而后当即返回执行结果。被读取的模块有本身的执行逻辑,执行完成后经过 module.exports 返回结果。
ESM 的模块加载是基于 Top-level await 设计的,首先解析 import 和 export 指令,再执行代码,因此能够在执行代码以前检测到错误的依赖。
ESM 模块加载器在解析当前模块依赖以后,会下线这些依赖模块并在此解析,构建一个模块依赖图,直到依赖所有加载完成。最后,按照编写的代码,顺序对应的依赖。
根据 ESM 约定,这些依赖的 ES 模块都是并行下载最后顺序执行。 promise
ESM 对于 JavaScript 来讲是一个巨大的规范变化,ESM 规范默认使用了严格模式,致使 this 指向和做用域都有变化,因此即便在浏览器里,
CJS 没法 require() ESM 模块,最简单的缘由就是 ESM 支持 Top-level await,可是 CJS 不支持。
Top-level await 支持在非 async 函数中使用 await。
ESM 支持多重解析的加载器,在不带来更多问题的状况下,让 Top-level await 变得可能。引用 V8 团队博客的内容: 或许你层级看到过 > Rich Harris 写的 > gist,表达了一系列对于 Top-level await 的担心,并抵制 JavaScript 实现这个特性。担心包括:
提议的 stage 3 版本直接回应了这些问题:
(Rich 如今已经接受了目前的 Top-level await 实现)
因为 CJS 不支持 top-level await,因此基本也没法把 ESM 的 top-level await 编译成 CJS 代码。那么,你会如何用 CJS 重写下面的代码?
export const foo = await fetch('./data.json');
复制代码
使人沮丧的是,绝大多数 ESM 代码并无用到 top-level await 的写法,不过这不是一个须要纠结的问题。
目前还有一个如何 require() ESM 模块的讨论(在评论前尽可能阅读完整的文章内容以及对应的讨论连接)。若是你深刻了解,会发现 top-level await 并非惟一的问题。若是你同步 require 了一个 ESM 模块,而这个模块又异步 import 了一个 CJS 模块,而后这个 CJS 模块又同步 require 了一个 ESM 模块,你能设想执行结果么。
因此,最后的结论仍是在任何状况下不要用 require() 来引入一个 ESM 模块。
若是你要在 CJS 代码里 import 一个 ESM 模块,须要使用异步的 dyniamic import()。
(async () => {
const {foo} = await import('./foo.mjs');
})();
复制代码
这么写或许没啥问题,只要你不须要 exports 一些执行结果。若是须要,那么你须要对外导出一个 Promise,对使用者来讲就是一个不小的成本。
module.exports.foo = (async () => {
const {foo} = await import('./foo.mjs');
return foo;
})();
复制代码
你能够在 ESM 里引入一个以下的 CJS 模块:
import _ from './lodash.cjs'
复制代码
可是你不能引用一个 CJS 模块具体导出的接口
import {shuffle} from './lodash.cjs'
复制代码
这是由于 CJS 代码是在执行的时候计算导出结果,可是ESM是在解析期进行。
不过咱们也有一些应对方案,虽然有点烦,但至少能用,就像下面的代码:
import _ from './lodash.cjs';
const {shuffle} = _;
复制代码
这样的代码没啥缺点,CJS 库甚至能够被封装成 ESM 模块。
这样挺好,不过还能够有一些更好的方式。
有些开发者提议过在执行 ESM 导入以前执行 CJS 导入。按照这个模式,CJS 的命名式导出就能够和在 ESM 的解析期执行。
可是这样会引入另一个问题:
import {liquor} from 'liquor';
import {beer} from 'beer';
复制代码
若是 liquor 和 beer 都是 CJS 模块,那么将 liquor 改为 ESM 会将原来 liquor, beer 的执行顺序改为 beer, liquor,若是 beer 依赖 liquor 的一些执行结果,就会有问题。
有一些另外的提议来想办法解决执行顺序问题,叫作动态模块。
在 ESM 规范中,经过静态声明的方式声明了全部命名导出。在动态模块规范下,引用模块时能够定义导出的名字。ESM 加载器会默认信任动态模块(CJS 代码)会暴露全部须要的命名导出,若是没有暴露,就会抛出错误。
不幸的是,动态模块须要 JavaScript 语言作一些修改才能被 TC 39 委员会接受,然而并无被接受。
比较特别的是,ESM 代码支持这样的写法:
export * from './foo.cjs'
复制代码
这样意味着会覆盖原来导出的名字,这样叫作“星号导出 ”。
惋惜在这个写法下,加载器依然不知道具体导出了什么。
动态模块也给规范的可塑性上带来了问题,好比
export * from 'omg';
export * from 'bbq';
复制代码
这样写会致使 omg 和 bbq 下同名的导出冲突。容许名字被开发者从新定义,也意味着导出校验基本能够忽略不用了。
动态模块的支持者提议去掉“星号导出”,可是 TC39 委员会拒绝了。其中一个 TC39 成员称这个提议像“语法毒药”,由于“星号导出”会由于动态模块带来一些反作用。
(我认为咱们一直处于语法毒药的世界,在 Node 14 下,命名导出是有反作用的,在动态模块下,星号导出也是有反作用的。因为命名导出使用的频繁但星号导出用的少,因此动态模块对生态的影响相对更小)
这也是并非动态模块的尽头。有一个提议是全部 Node 模块都应该是动态模块,即便是 ESM 模块,也就是要放弃 ESM 的多重解析加载器。使人意外的是,这个提议并无明显的反作用,除了会有一些性能问题,毕竟ESM 加载器是面向弱网环境设计的。
不过不幸的是,动态模块的 Github 讨论 issue 已经由于一年没有讨论而关闭了。
社区里还有另一个提议,升级 CJS 模块解析器来支持解析导出内容,不过这个常识基本不太可能实现(最近的一次 PR对应的测试结果,只能在 npm 排名前 1000 的模块中经过62%)。因为该方案的可靠性不足,部分 Node 工做组的成员反对了这个方案。
ESM 模块默认没有 require 方法,可是你能够很简单地实现这个方法。
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const {foo} = require('./foo.cjs');
复制代码
这样写的意义不大,而且还比原来的写法要多谢几行代码,而且 Webpack 和 Rollup 这样的工具并不知道该怎么处理 createRequire 的类型。
若是你当前维护了一个同时支持 CJS 和 ESM 的库,你能够根据下面的指南作的更好。
这样能够确保你的库在旧版本 Node 下跑的更好。
(若是你写的是 TypeScript 或者其余须要编译到 JS 的语言,那么编译到 CJS。)
(将 CJS 封装到 ESM 很容易,可是 ESM 库是无法封装到 CJS 库的。)
import cjsModule from '../index.js';
export const foo = cjsModule.foo;
复制代码
把 ESM 封装放到 esm 子目录下,同时在 package.json 里声明 {"type": "module"} 。(在 Node 14 下你也能够用 .mjs 后缀,不过有一些工具不必定支持 .mjs 文件,建议仍是用子目录的方式)
若是你在用 TypeScript,是能够把 TypeScript 编译出 CJS 和 ESM 两个版本,可是这样可能会致使开发者不当心同时引用了 ESM 和 CJS 版本。
Node 一般会作一些模块的合并,可是没法判断同个库的 CJS 和 ESM 文件是不是同一个文件,那么真正执行的时候,这些代码会被执行两遍,形成一些不可预期的问题。
以下:
"exports": { "require": "./index.js", "import": "./esm/wrapper.js" }
复制代码
注意:增长 exports 映射是一个不兼容变动。
默认状况下,开发者是能够访问到依赖包里的任何文件,包括那么包开发者本来只是指望内部使用的。 exports 映射确保了开发者只能引用到明确的入口文件。
这样很好,可是确实是一个不兼容变动。
(若是你原本就容许开发者来引用更多的文件,那么能够设置多个入口,能够参考 ESM 文档)
确保 exports 映射的文件是包含明确后缀的。用 "index.js" 而不是 "index" 或者相似 "./build" 这样的目录。
若是你按照上面的指南作,能够避开大部分问题。