【译】Node 模块之战:为何 CommonJS 和 ES Module 不能共存

原文地址: 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

ESM 与 CJS 之间能够相互引用,可是有大量的坑

  1. 只能用 import() 调用 ESM 模块,require() 不行,好比 import {foo} from 'foo'
  2. CJS 模块不能使用 import 语法
  3. CJS 模块能够用异步的 dynamic import() 来加载 ESM 模块,可是相对同步的 require 来讲,会有一些坑
  4. ESM 模块能够 import CJS 模块,可是只能经过“默认导入”的模式,好比 import _ from 'lodash',而不支持“命名导入”,好比 import {shuffle} from 'lodash'。
  5. ESM 模块能够 require() CJS 模块,包括“命名导出”的,可是依然会有不少问题,相似 Webpack 或者 Rollup 这样的工具甚至不知道该怎么出处理 ESM 里的 require() 代码。
  6. Node 默认支持的仍是 CJS 规范,你须要选择用 .mjs 这样的后缀,或者在 package.json 里设置 "type": "module" 才能开启 ESM 模式。经过 package.json 开启的话,若是有 CJS 规范的文件,就得相反将后缀改为 .cjs。

对于大部分初级 Node 开发者来讲,这些规则很是的难以理解,下面会详细对这些展开介绍。
不少 Node 生态的围观群众都把这些问题归结到 ESM 自己,可是接下来我会说明清楚,这些坑都是有其存在的缘由,以及将来也很难有完美的解决方案。
最后我也会给框架/库的维护者 3 个建议:node

  • 提供 CJS 版本
  • 基于 CJS 版本简单包一个 ESM 版本出来
  • 在项目的 package.json 里添加一个 exports 映射

基本上就能避开大部分坑。git

  1. Discuss on Reddit
  2. Discuss on Hacker News

背景:CJS 和 ESM 是什么?

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));
复制代码

ESM 和 CJS 设计差别

CJS 的 require() 是同步的,实际执行的时候会从磁盘或者网络中读取文件,而后当即返回执行结果。被读取的模块有本身的执行逻辑,执行完成后经过 module.exports 返回结果。
ESM 的模块加载是基于 Top-level await 设计的,首先解析 import 和 export 指令,再执行代码,因此能够在执行代码以前检测到错误的依赖。
ESM 模块加载器在解析当前模块依赖以后,会下线这些依赖模块并在此解析,构建一个模块依赖图,直到依赖所有加载完成。最后,按照编写的代码,顺序对应的依赖。
根据 ESM 约定,这些依赖的 ES 模块都是并行下载最后顺序执行。 promise

Node 默认 CJS 规范是由于 ESM 的不兼容变动

ESM 对于 JavaScript 来讲是一个巨大的规范变化,ESM 规范默认使用了严格模式,致使 this 指向和做用域都有变化,因此即便在浏览器里,

CJS 没法 require() 基于 Top-level await 设计的ESM 模块

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 实现这个特性。担心包括:

  • Top-level await 可能会中断执行。
  • Top-level await 可能会终端资源下载。
  • 没法和 CJS 模块互通。

提议的 stage 3 版本直接回应了这些问题:

  • 只要模块可以被执行,就不会有中断的问题。
  • Top-level await 在解析模块依赖图的阶段执行。在这个阶段,全部字段都已经下载并创建对应关系,并不会阻断资源下载。
  • Top-level await 限定在 ESM 模块下,不会支持 CJS 模块(没有互通的必要)。

(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,但也不是一个好主意

若是你要在 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 模块不然 CJS 代码执行顺序会和指望的不一样

你能够在 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(),但并不值得这么作

ESM 模块默认没有 require 方法,可是你能够很简单地实现这个方法

import { createRequire } from 'module'; 
const require = createRequire(import.meta.url);  
const {foo} = require('./foo.cjs');
复制代码

这样写的意义不大,而且还比原来的写法要多谢几行代码,而且 Webpack 和 Rollup 这样的工具并不知道该怎么处理 createRequire 的类型。

同时支持 CJS 和 ESM 包最佳实践是什么

若是你当前维护了一个同时支持 CJS 和 ESM 的库,你能够根据下面的指南作的更好。

提供一个 CJS 版本

这样能够确保你的库在旧版本 Node 下跑的更好。
(若是你写的是 TypeScript 或者其余须要编译到 JS 的语言,那么编译到 CJS。)

基于 CJS 封装到 ESM 版本

(将 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 文件是不是同一个文件,那么真正执行的时候,这些代码会被执行两遍,形成一些不可预期的问题。

在 package.json 里增长 exports 映射

以下:

"exports": {  "require": "./index.js",  "import": "./esm/wrapper.js" }
复制代码

注意:增长 exports 映射是一个不兼容变动
默认状况下,开发者是能够访问到依赖包里的任何文件,包括那么包开发者本来只是指望内部使用的。 exports 映射确保了开发者只能引用到明确的入口文件。
这样很好,可是确实是一个不兼容变动
(若是你原本就容许开发者来引用更多的文件,那么能够设置多个入口,能够参考 ESM 文档
确保 exports 映射的文件是包含明确后缀的。用 "index.js" 而不是 "index" 或者相似 "./build" 这样的目录。
若是你按照上面的指南作,能够避开大部分问题。

相关文章
相关标签/搜索