在 ES Module 中引用 CommonJS:esModuleInterop
从 ECMA 2015(ES6)JavaScript 开始标准化 ES Modules,
从 JavaScript 语言层面给出了模块的概念和 import
, export
等关键字。
ES Modules 用来取代 AMD、Webpack Require,CommonJS 这些社区规范。
那么 ES Modules 和 CommonJS 有啥区别,以及新的 ES Module 和旧的 CommonJS 之间怎么相互引用呢?
毕竟 npm 上还有成千上万的 CommonJS 模块,本文就来谈 ES Modules 和 CommonJS 之间的互操作问题。
TL; DR
- ESM 需要不同的解析方式,因此 Node.js 中需要用新的 .mjs 后缀名。
- CommonJS 可以通过 import API 引用 ESM,ESM 则可以直接 import CommonJS。
import =
语法受到 Babel 和 TypeScript 支持,但不符合 ES Modules 标准。- TypeScript 中通过
import * as
引用 CommonJS 的方式不符合 ES Modules 标准。 - 使用 default-import + esModuleInterop 来让你的 TS 代码符合 ES Modules 标准。
- 开启 esModuleInterop 之后,allowSyntheticDefaultImports 也会自动开启。
ESM 和 CommonJS 的区别
导出名字绑定
CommonJS 的 exports 是一个普通的 JavaScript 对象,而 ESM 的 export 和 import 是关键字,其声明的名字始终是绑定。
比如对 exports.bar
属性的引用是值引用,
当 exports.bar
改变指向到另一个对象后,此前的引用仍然保持旧的值:
// file: foo.js
setTimeout(function() { exports.bar = 'coo' })
exports.bar = 'bar'
// file: index.js
var bar = require('./foo').bar
// bar 的值总是 'bar',不会跟着 foo.js 中的 exports.bar 改变
而在 ESM 中 export/import 的 bar 始终是同一个变量。 也就是说 export 处对 bar 的变更会直接体现在 import 处。例如:
// file: index.mjs
import { bar } from './foo'
// bar 的值会跟着 foo.js 中的 bar 改变而改变
// file: foo.mjs
export let bar = 'bar'
setTimeout(() => bar = 'coo')
名字绑定的实现细节可以参考 这篇文章。
strict
解析 ES Modules 时总是 "strict" 模式。就像头部写了 "use strict" 一样。
语法不兼容
- CommonJS 中没有
import
,export
关键字(注意 import API 是函数不是关键字),会引发解析错。 - 反过来引用 ESM 必须用
import
,用require
会发生ERR_REQUIRE_ESM
错误。
跨域行为
浏览器下载 ES Modules 时使用 CORS 模式。
因此跨域脚本需要设置 CORS HTTP 头,比如 Access-Control-Allow-Origin: *
,也就不允许 file://
协议因为它的 origin 是 null。
也就是说 脚本不能再随便跨域了,JSONP 之类的魔法在 ESM 上不生效。
this
- 浏览器中
this === window
- CommonJS 中
this === global
- ES Modules 中
this === undefined
。
文件后缀
因为需要不同的模式去解析,所以 JS 引擎在解析一个模块之前就需要知道它是 ES Module 还是 ES5 的 CommonJS。
因此 Node.js 下 ES Modules 要使用新的后缀 .mjs
,浏览器中要设置 script 标签的 type="module"
。
CommonJS 引用 ESM
ES Modules 的设计主要考虑能够引用旧的 CommonJS,而且为了更大程度的兼容 ES Modules 一般会在发布前编译到 CommonJS。 因此 CommonJS 直接引用 ES Modules 的情况比较少。但如果真的存在这种情况,需要使用 import API:
const foo = await import('./foo.mjs')
ESM 引用 CommonJS
Node.js 从 13.2.0 开始支持 ES Modules,之前的版本比如 Node.js 10 中需要通过 --experimental-modules
开关来启用,更之前的版本就得通过 Babel 或 TypeScript 来用了。
总之 Node.js 支持在 ESM 中可以直接使用 import 语句来引入 CommonJS 模块:
// a.js in CommonJS
exports.foo = 'foo'
// b.mjs in ES Modules
import a from './a.js'
console.log(a) // prints {foo: "foo"}
// c.mjs in ES Modules
import * as a from './a.js'
console.log(a) // prints {default: {foo: "foo"}}
上面的代码演示了 ESM 引用 CommonJS 的规则:
- CommonJS 中的
module.exports
对象会被作为默认导出(default export),值为{ foo: 'foo' }
; import a from './a.js'
会让a
引用到 a.js 里的默认导出,即{ foo: 'foo' }
;import * as a from './a.js'
会让a
引用到 a.js 里的 namespace object,即{default: {foo: "foo"}}
。
import =
这是一个非标准的例子,通过 import =
+ require 的方式来引用 CommonJS 模块:
import a = require('./a.js')
console.log(a) // prints {foo: "foo"}
因为这一语法在 Babel 和 TypeScript 中都支持,
而且非常方便从 ES5 的 const a = require('./a.js')
语法迁移代码。
因此这种使用方式非常流行。
尽管如此,它没有定义在 ES Modules 标准中,也就是说 import =
是不标准的。
TypeScript esModuleInterop
TypeScript 的问题
当前较常见的方式是使用 Babel 或 TypeScript 把 ESM 编译到 CommonJS 再用 Node.js 去执行。
但这些工具不都是符合 ES Modules 标准的,比如 TypeScript 下 import * as
和 import from
等价。假设我们有一个 index.ts 的 ESM,需要引用 foo.js:
// file: foo.js
exports.bar = 'bar'
// file: index.ts
import * as foo from './foo.js'
console.log(foo.bar) // 期望输出 "bar"
TypeScript 在默认设置上述代码可以正常运行,即 foo 的值为 {bar: "bar"}
。
而按照 ES Modules 规范 foo
得到的值应该是 namespace object,即 {default: {bar: "bar"}}
。
让 TypeScript 符合 ESM 标准
我们希望在上面的 TypeScript 里得到 {bar: "bar"}
的同时符合 ES Modules 标准,
就要通过 default-import 的方式来引用,上述 index.ts 符合 ESM 规范的写法为:
// file: index.ts
import foo from './foo.js'
console.log(foo.bar) // 期望输出 "bar"
但默认配置下 tsc 编译后代码会在运行时出错, 因为使用了 default-import 语法但 foo.js 没有定义 default。:
注意:如果在编译期就出错了那说明还有其他问题。 比如 TS7016: Could not find a declaration file,需要先把 noImplicitAny 关掉。 也可以加一个 .d.ts 来解决,但这样类型和运行时需要分开考虑,详见下文。
// file: index.ts 编译后的 index.js
let file_foo = require('./foo.js')
console.log(file_foo.default.bar) // file_foo.default 未定义!
这时你需要在 tsconfig 里设置 esModuleInterop 为 true
,
tsc 就会帮你生成一个 default。大概会编译成:
注意:以下代码是示意性的,实际会生成
__importDefault()
和__importStar()
工具函数包装require()
,比如还需要检测 foo.js 是不是 ESM 编译得到的产出。 详见 这个 Stack Overflow。
// file: index.ts 编译后的 index.js
file_foo = { default: require('./foo.js') }
console.log(file_foo.default.bar)
总之会对 foo.js 的 exports 包装一个 default,上述代码就可以正常运行了。
有类型声明的情况
现在来考虑 foo.js 有对应的类型定义的情况,比如有一个 foo.d.ts:
// file: foo.d.ts
export const bar: string
那么 tsc 会发现 foo.js 没有定义 default,会在编译期抛错 TS1259: can only be default-imported using the 'esModuleInterop' flag。 同样地,打开 esModuleInterop 之后就正常了。
有定义 default 的情况
再考虑 foo.js 定义了 default 的情况。这种情况下上述 foo.js 可以写成:
// file: foo.js
exports.bar = 'bar'
exports.default = exports
通常 CommonJS 不会这样做,但是为了兼容 ESM 很多 npm 包都导出了一个额外的 default。 这时如果 foo.js 没有对应的类型声明,tsc 就不会做它的类型检查,运行时也可以读取到 default 属性,一切都很好。
但如果存在一个类型定义(比如你去 Definitely Typed 安装了一个类型文件), 并且这个类型定义里没有写 default。例如:
// file: foo.d.ts
export const bar: string
这时我们不需要 esModuleInterop 来帮忙包装 default(因为 foo.js 中已经定义好了), 但是在类型系统中 tsc 会发现 foo.d.ts 对应的类型中不存在 default,就会抛出 TS2613 错误:"…/foo" has no default export。
这种情况下只需要禁用这个错误,在 tsconfig 里设置 allowSyntheticDefaultImports 为 true
就可以解决。
也就是说这个选项只是让 TypeScript 的类型系统忽略 default 未定义的问题,
我们来确保运行时 default 是有定义的。
注意 开启 esModuleInterop 之后,allowSyntheticDefaultImports 也会自动开启。因为既然会自动生成 default,那么检查是否有 default 声明就没有意义了。
本文采用 知识共享署名 4.0 国际许可协议(CC-BY 4.0)进行许可,转载注明来源即可: https://harttle.land/2019/03/21/esmodule-interop.html。如有疏漏、谬误、侵权请通过评论或 邮件 指出。