最近升级依赖,发现
execa
、inquirer
的最新版本都已经变成了 pure ESM package,这也导致一些项目报错ERR_REQUIRE_ESM
,构建失败。
我们知道由于早期 JavaScript 缺少模块规范,Node.js 和 NPM package 一直采用 CommonJS 规范,直到 2015 年,ECMAScript 给出了标准的模块规范 ECMAScript Modules ,社区工具开始陆续转向 ESM。
Node.js 对 ESM 的实验性支持 从目前已经不再维护的 v12 开始,到 v12.22.0 和 v 14.13.0 后(2020年底)稳定支持:在 package.json 配置了 type: module
的情况下,对 .mjs
后缀的文件 以 ESM 处理,同时通过 package.exports 语法糖导出模块。
之后随着 Node.js v10.x 下线,看起来在 Node.js 和 NPM 生态下全面使用 ESM 应该问题不大。
2021 年中开始,前端轮子哥 sindresorhus 开始呼吁大家使用 Pure ESM package,并率先把自己维护的一堆轮子(比如 execa)迁移到了 Pure ESM。随后越来越多的包开始 Pure ESM 化,在国内、外社区都引起了很多激烈讨论,主要争论点在于生态中大量现有的 CommonJS 如何兼容 ESM 是个难题,尤其对于大型框架和应用。
在 2022 年,超过 95% 的浏览器已经能支持 ES6/ESNEXT 语法,所谓 Modern JavaScript 是一个泛指,指的是这 95% 的现代浏览器支持的 JavaScript 语法。尽管如此,目前绝大多数网站在生产环境都会打包转译到 ES5 来支持那些 5% 的老版本浏览器,比如死而不僵的 IE 11。这也导致:
直接使用 Modern JavaScript 会避免这些问题,从长远看全面使用 ESM 是必然,这也让 Pure ESM package 有了更重要的意义。
根据 Pure ESM package 的设计,一个 Pure ESM 包的 package.json 中会有 type
和 exports
配置的调整:
在使用时,Node.js v12 以下不再支持,require
不再支持,需要用 import
(包括动态 import)。
但事实上,实际使用场景不通,还有很多坑要填,sindresorhus 的这篇 FAQ 给的比较全面的,下面记录一些我遇到的:
__dirname
和 __filename
在 ESM 中不可直接访问,因为他们并不是真的 globals,而是CommonJS 规范封装进来的:
因此需要通过 path
和 url
获取文件目录:
require
有个 main
属性,经常被用来判断模块是直接执行还是被调用执行。比如 require.main === module
为 true 说明是 CLI 直接执行 node foo.js
,否则就是 require('./foo')
调用执行。这在 ESM 中由于 require
用不了,需要换种方式:
或者通过 es-main 包去判断。
命令行跑 ESM 要加参数:
浏览器和 Node.js 都有强制的文件拓展名规范,需要包含 js。
面向浏览器端的时候,开发应用,我们习惯了 webpack 之类的构建工具处理 ts 文件, 通常不会有感觉。
但在 Node.js 中,如果不借助其他处理工具,想直接引用 TypeScript 也只能改成 .js 拓展名。
再复习下 TS 的几个配置:
如果配置了 ESM 模式,就需要调整拓展名:
常见的报错Error: ERR_MODULE_NOT_FOUND ... Cannot find module '.../src/foo'
:
改 import { bar } from './src/foo'
成 import { bar } from './src/foo.js'
即可。
当然在 Node.js 中.cjs
会被识别为 CommonJs ,.mjs
会被识别为 ESM,所以也可以通过后缀 .cts
(以 .cjs
引入)、 .mts
(以 .mjs
引入),但感觉看起来更乱,还是统一保持 .js
用文件夹区分比较好。
实际项目中我们通常还是会用一些工具,比如 ts-node/ts-eager 来在 Node.js 中运行 TypeScript。
我习惯用 ts-node,ts-node 有一份对 ESM 的支持计划 ts-node/issues/1007,虽然没有完全实现,但通过配置 ESM loader 基本可以解决问题:
一些报错记录:
TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts"
ts-node/esm
loader"module": "commonjs"
ReferenceError: exports is not defined in ES module scope
ts-node/esm
loader ,但配置了 "module": "commonjs"
Error [ERR_REQUIRE_ESM]: Must use import to load ES Module
相比 Pure ESM package 这种完全 ESM 化,社区里还有很多包采用双模式 npm 包 Dual CommonJS/ES module package,同时支持两种规范。
我理解面向浏览器的包,本质是直面用户的包,采用 Pure ESM 是可以接受的,因为 webpack 之类的工具都能处理。
而面向 Node.js 的包,直面用户的包用 Pure ESM 可行,但定位就是要被二次封装、存在上下游 CommonJS 依赖的包,其实更适合采用 Dual package。
同时要面向浏览器和 Node.js 的包,也应该用 Dual package。
Dual package 的改造根据 Node.js 支持的 conditional_exports,通过同时给出 require
和 import
的入口,导出两套规范代码, Node.js 会自行根据父模块的运行模式决定应当加载哪个文件:
而 mjs
、cjs
的后缀名其实也不是必须,只要保证各自的文件符合相应的规范即可。
比如 domhandler
就做了 Dual package 配置 https://github.com/fb55/domhandler/blob/master/package.json#L13:
通过配置 exports
还有个好处就是可以避免不想暴露的目录被外部引用,比如在上面 domhandler 的 exports 配置下,如果直接引用 domhandler/lib/node
,构建工具一般会报错 Module Not Found
: Package path ./lib/node is not exported from package …/node_modules/domhandler
再进一步考虑前面提到的 Modern JavaScript,如果一个包同时要面向 Node.js 和浏览器,同时要兼顾现代和传统浏览器,其实可以把包目录结构和 package.json 设计成这样:
现代 JavaScript 产物在 modern
目录下,通过 exports
导出。
具有传统 ES5 回退产物在 legacy
目录下,其中:
legacy/lib
下是传统 ES5 + CommonJS 的回退,通过 main
导出,兼容老版本 Node.jslegacy/esm
下是传统 ES5 + ESM 的回退,通过 module
导出,注意即便对于传统 ES5,也可以加上 ESM 也就是 import 和 export,可以让 webpack 之类的构建工具做 treeshaking。bundle 产物(umd)在 dist
目录下,通过 browser
导出,这也可以用来跟 bundless 区分。