Pure ESM package

最近升级依赖,发现 execainquirer 的最新版本都已经变成了 pure ESM package,这也导致一些项目报错 ERR_REQUIRE_ESM,构建失败。

为什么需要 Pure ESM package

CommonJS -> 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 是个难题,尤其对于大型框架和应用。

Modern JavaScript with ESM

在 2022 年,超过 95% 的浏览器已经能支持 ES6/ESNEXT 语法,所谓 Modern JavaScript 是一个泛指,指的是这 95% 的现代浏览器支持的 JavaScript 语法。尽管如此,目前绝大多数网站在生产环境都会打包转译到 ES5 来支持那些 5% 的老版本浏览器,比如死而不僵的 IE 11。这也导致:

  • ES5 通常比等效的 Morden JavaScript 代码体积大 20% 左右,速度更慢,如果有工具缺陷、错误配置,这差距会进一步扩大。
  • 通常生产代码中的 90% 来自依赖库。库代码会由于 polyfill 和 helper 重复而产生更高的开销。

直接使用 Modern JavaScript 会避免这些问题,从长远看全面使用 ESM 是必然,这也让 Pure ESM package 有了更重要的意义。

如何使用 Pure ESM package

根据 Pure ESM package 的设计,一个 Pure ESM 包的 package.json 中会有 typeexports 配置的调整:

Diff
{
+  	"type": "module",
-  	"main": "./index.js",
+  	"exports": "./index.js",
    "engines": {
        "node": "^12.20.0 || ^14.14.0 || >=16.0.0"
    }
    ...
}

在使用时,Node.js v12 以下不再支持,require不再支持,需要用 import(包括动态 import)。

但事实上,实际使用场景不通,还有很多坑要填,sindresorhus 的这篇 FAQ 给的比较全面的,下面记录一些我遇到的:

Node 内置和全局对象

__dirname__filename在 ESM 中不可直接访问,因为他们并不是真的 globals,而是CommonJS 规范封装进来的

Javascript
(function(exports, require, module, __filename, __dirname) { 
  ... 
});

因此需要通过 pathurl 获取文件目录:

Javascript
import { dirname } from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(fileURLToPath(import.meta.url));

require有个 main属性,经常被用来判断模块是直接执行还是被调用执行。比如 require.main === module 为 true 说明是 CLI 直接执行 node foo.js,否则就是 require('./foo')调用执行。这在 ESM 中由于 require 用不了,需要换种方式:

Javascript
import { fileURLToPath } from 'url';
import process from 'process';

if (import.meta.url.startsWith('file:')) {
  if (process.argv[1] === fileURLToPath(import.meta.url)) {
    // The script was run directly.
  }
}

或者通过 es-main 包去判断。

CLI 参数

命令行跑 ESM 要加参数:

  • node 命令
    • -r(—require)启动 CommonJS 模块
    • —experimental-specifier-resolution=node, 执行 ESM

TypeScript 和 ESM

浏览器和 Node.js 都有强制的文件拓展名规范,需要包含 js。
面向浏览器端的时候,开发应用,我们习惯了 webpack 之类的构建工具处理 ts 文件, 通常不会有感觉。
但在 Node.js 中,如果不借助其他处理工具,想直接引用 TypeScript 也只能改成 .js 拓展名。
再复习下 TS 的几个配置:

  • target: 编译后的JS 遵循什么规范
  • module: 文件中的模块采用何种方式实现
  • moduleResolution: 如何找依赖的模块位置
JSON
// tsconfig.json  ->  CommonJs 模式
"moduleResolution": "node",
"module": "commonjs",


// tsconfig.json   ->  ESM 模式
"moduleResolution": "node",
"module": "esnext",

如果配置了 ESM 模式,就需要调整拓展名:

Javascript
// ./foo.ts
export function helper() {
    // ...
}

// ./bar.ts
import { helper } from "./foo"; // only works in CommonJS, Fail in ESM

// ./bar.ts
import { helper } from "./foo.js"; // works in ESM & CommonJS

常见的报错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 和 ESM

实际项目中我们通常还是会用一些工具,比如 ts-node/ts-eager 来在 Node.js 中运行 TypeScript。
我习惯用 ts-node,ts-node 有一份对 ESM 的支持计划 ts-node/issues/1007,虽然没有完全实现,但通过配置 ESM loader 基本可以解决问题:

Bash
# only works for CommonJS, Fail in ESM
ts-node ./index.ts

# works for ESM
node --loader ts-node/esm ./index.ts

# works for ESM
ts-node --esm ./index.ts

一些报错记录:

  • TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts"
    • 对 ESM 没用 ts-node/esmloader
    • 对 CommonJS 没配置 "module": "commonjs"
  • ReferenceError: exports is not defined in ES module scope
    • 用了 ts-node/esmloader ,但配置了 "module": "commonjs"
  • Error [ERR_REQUIRE_ESM]: Must use import to load ES Module
    • 我们开头提到的,引用了 Pure ESM package,把他换成 CommonJS 版本,或者把当前项目改造成 Pure ESM。

Dual Mode package

相比 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,通过同时给出 requireimport的入口,导出两套规范代码, Node.js 会自行根据父模块的运行模式决定应当加载哪个文件:

JSON
{
  // package.json
  "name": "foo",
  "exports": {
    "require": "./main.cjs",
    "import": "./main.mjs"
  }
}

mjscjs的后缀名其实也不是必须,只要保证各自的文件符合相应的规范即可。
比如 domhandler就做了 Dual package 配置 https://github.com/fb55/domhandler/blob/master/package.json#L13

JSON
"exports": {
  "require": "./lib/index.js",
  "import": "./lib/esm/index.js"
 },

通过配置 exports 还有个好处就是可以避免不想暴露的目录被外部引用,比如在上面 domhandler 的 exports 配置下,如果直接引用 domhandler/lib/node,构建工具一般会报错 Module Not Found : Package path ./lib/node is not exported from package …/node_modules/domhandler

All Mode package

再进一步考虑前面提到的 Modern JavaScript,如果一个包同时要面向 Node.js 和浏览器,同时要兼顾现代和传统浏览器,其实可以把包目录结构和 package.json 设计成这样:

JSON
{
  "name": "my-pkg",
  "type": "module",
  "exports": {
    "types": "legacy/lib/index.d.ts",
    "require": "legacy/lib/index.js",
    "import": "legacy/esm/index.js",
    "modern": "modern/index.js",
	 }
  "main": "legacy/lib/index.js",
  "types": "legacy/lib/index.d.ts",
  "module": "legacy/esm/index.js",
  "browser": "./dist/index.umd.js",

}
Bash
├── dist
│   └── index.umd.js
├── legacy           
│   └── esm
│   └── lib
├── modern
├── package.json
├── src
│   └── index.ts
└── tsconfig.json

现代 JavaScript 产物在 modern 目录下,通过 exports导出。
具有传统 ES5 回退产物在 legacy目录下,其中:

  • legacy/lib下是传统 ES5 + CommonJS 的回退,通过 main导出,兼容老版本 Node.js
  • legacy/esm下是传统 ES5 + ESM 的回退,通过 module导出,注意即便对于传统 ES5,也可以加上 ESM 也就是 import 和 export,可以让 webpack 之类的构建工具做 treeshaking。

bundle 产物(umd)在 dist目录下,通过 browser导出,这也可以用来跟 bundless 区分。