Nodejs纯esm模块的迁移方法、社区冲击、评价浅论

1,381 阅读10分钟

背景

npm 轮子哥 Sindre Sorhus 承担社区有相当规模的一部分的底层轮子维护,他的一举一动将深刻影响社区数以万计的顶层工具。

按 Sindre Sorhu 的评价和思考,他现在是讨厌 cjs 的,要完全拥抱 pure esm ,并且在最近几个月将他所维护的几乎所有轮子都强制迁移到了 pure esm 版本,此举可以公开的信息如下:

  1. 大版本变化:进行了大版本的 Breaking change ,所以如果你正在使用 cjs 编写,需要安装他的前一个版本(如现在为 ^3.0.0 ,你需要安装 ^2.0.0 )。

  2. 社区大冲击:造成了很多顶层工具链路被破坏,如 nextjs 等,在几天内迅速发布了 alpha 修复的 esm 版应对。

  3. 强制席卷 esm:虽然人们知道 esm 才是 JavaScript 的未来,但是在 nodejs 界写 cjs 是上古传承的地位了,即使 typescript 的转换够强大,但是 Sindre Sorhus 认为以后只需 pure esm,放弃 cjs 。

下面我们就一些角度进行探讨。

如何迁移 pure esm

既然 esm 才是 JavaScript 的未来,那么如何迁移,是我们需要人人皆知的一个终极问题。

在最近一次命令行工具王者 execa 发生了 pure esm 的改变后,社区对 to pure esm 的评价越来越激烈。

Sindre Sorhus 本人也发布了迁移 pure esm 的推荐手法:esm-package.md

如何将 cjs 工具转换为 esm ,下面我将就 Sindre Sorhus 的建议之上进行一个详细的介绍和解读。

JavaScript 编写的工具

对于原来是 js 编写的 cjs 工具,你需要做以下几件事:

  1. 添加 "type": "module" 到你的 package.json :这样 node 去运行你的工具时才知道使用 esm 的加载器运行(默认是 cjs )

  2. package.json 中替换 "main": "index.js""exports": "./index.js" :这一步是限制 esm 的导出范围,防止隐式的 hack 导入引发不确定的行为,当然 exports 也可以指定为对象进行详细的范围指定(关于详细方式可自行在搜索引擎搜索后学习),在 ts 4.5 中也推荐了使用 exports 防止隐式导入(当然其 nodenext 系 module 转换被暂时搁浅到下个版本了)。

  3. package.json 中使用 engines 字段限制 nodejs 运行版本号:如 "node": "^12.20.0 || ^14.13.1 || >=16.0.0"

  4. 删除全部代码中的 'use strict'; :在 esm 界我们不需要这个严格声明。

  5. 将代码中所有的 cjs 导入导出 require() / module.exports 转换为 esm 的 import / export

  6. 使用完整的相对文件路径引入文件,如:import x from './index.js' ,此处要写明文件名和拓展名。

  7. 将所有的 .d.ts 文件转换为 esm 的导出格式:对于社区使用 js 编写的的流行工具包一般官方或者社区会帮助维护一份 types 包,如 @types/* 等,这些辅助的格式声明文件也须进行 esm 的迁移。

  8. 建议使用 node:* 协议进行导入 node 内置模块:这是防止混淆的做法,如 import path from 'path'import path from 'node:path' ,此举可以明确限制告诉 node 我在导入一个内置的模块(因为有的时候可能 npm 的公共包也和 node 内置模块是一个名字)。

Typescript 编写的工具

对于原来是 ts 编写的 cjs 工具,迁移方法大致和 js 的工具相同:

  1. 添加 module 指示:如上,和 js 编写的工具的第 1 步相同。

  2. 限制 esm 导出:如上,和 js 编写的工具的第 2 步相同。

  3. 限制 node engines 版本:如上,和 js 编写的工具的第 3 步相同。

  4. 使用严格导入:如上,和 js 编写的工具的第 6 步相同。

  5. 删除 namespace 用法并使用 export :这就意味着纯 .d.ts 的命名空间 type 法被开除 esm 户籍,相信有很多新手在编写 type 时图简单使用 .d.ts 存放,结果要承担不可 copy 、不可 export 、有时 ide 会不识别的问题。

  6. 更改 tsconfig.json 中编译的目标为 es 格式,即 module: "es2020" (未来 ts 4.6+ 将支持新的 node 系 module 选项来更好的应对 to esm 转换)。

综上来看,似乎很多改动都是小问题,而唯一工作量大的即是:必须使用绝对指明的文件导入,即以前我可以默认在导入 ./index.js 直接写成 ./ ,而现在必须要写成 ./index.js ,连文件名都不能省略。即使在 typescript 中,也必须写为 ./index.js

泛谈 to pure 成本

迁移绝对导入的成本

我们讨论一下成本最大的,也就是将原来的隐式默认导入如 ./ 迁移到绝对导入,这在 js 编写的库中是看似容易的,只要排着文件去手工替换修改就可以。

而到了 ts 编写的库中,typescript 官方在原计划 4.5 中打算新增一个 modulenode12 / nodenext 的选项,来帮助我们更好的进行 esm 的迁移:

// tsconfig.json

{
  "compilerOptions": {
    "module": "node12" // or nodenext
    
    // moduleResolution 会被 module 的 node 系指示所一并设定,不需要再显示设定
    // "moduleResolution": "node12" // or nodenext
  }
}

但是最终被推迟到下个版本了,目前你可以在 ts 4.6-dev 中使用到这个特性,下面有两处介绍你可以进行回溯 :

可以看到原计划中指定 node12 / nodenext 时会帮你自动做绝对路径的转换,在 tsconfig.json refer 中也是给出了自动转换的例子:

import { valueOfPi } from "./constants";
export const twoPi = valueOfPi * 2;

指定 "module": "node12" 可以得到:

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.twoPi = void 0;
// ↓ 注意这一行的路径变化!
const constants_js_1 = require("./constants.js");
exports.twoPi = constants_js_1.valueOfPi * 2;

可以看到 constants 的引入被自动添加了完整后缀。

但是这个特性现在被推迟了,在 ts 4.6-dev 中,是并不会自动添加后缀的,取而代之的是,在构建时,没有使用绝对后缀引入的地方会警告提示你要使用绝对路径指示。

下面说一下引发这个报警提示的前置条件:

  1. 需要在 package.json 中指明 esm 格式:"type": "module"

  2. 需要开启 node12 / nodenext 的 node 系转换:如 "module": "node12" ,无需再显示指定 moduleResolution 了。

当你满足以上两个条件时,你便可以在 tsc 构建时得到未使用绝对路径导入的报警提示。

有人会问在 ts 中使用 import x from './index.js' 去导入一个 index.ts 这合理吗?在 ts 4.5 中已经开始兼容了这种 .js 后缀的写法,并不会报错,一切按计划进行!

一个报警的示例:

其实我们再捋一下,既然 ts 4.6 开始准备兼容 to esm 的转换,并且都已经做出了报警提示,所以我们借助 typescript 4.6+ 系是可以很好的迁移到 pure esm 的,在绝对路径的迁移上有 ts 加持还是很得力的。

综上,进行绝对路径的迁移,成本是有,但不是特别高。

迁移 cjs 内置变量的成本

相信这个成本大家已经早有心理准备,心知肚明了,那就是:

  1. requireimport 的转换

  2. __dirname / __filenameimport.meta.url 的转换

关于第二点的 import.meta.url 迁移,目前社区有一种 hack 的做法:混用 cjs 导出 __dirname 进行 hack

当然这种 hack 是非常规手段,常规的迁移手段应该是:

import path from 'node:path'
import { fileURLToPath } from 'node:url'

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

其实本质上 import.meta.url 利用内置工具转为 file 的绝对路径后再用 path.dirname 转到文件夹名即可。

额外需要注意的几个信息如下:

  1. import.meta.url 是 es2020 (es11) 的行为,当然 esnext 也支持,在其他要编译为 esm 模块的地方使用时你或许需要牢记这一点。

  2. 严禁使用 new URL(import.meta.url).pathname 这种写法,会引发不同平台表现不一致和错误,详见 url.fileURLToPath(url) (很多地方都告诉你这么写其实是不对的)

这个时候深度玩家会问了,以前的 enhanced-resolve 现在用什么保证一定导包寻址成功?目前社区有一个轮子可以试用:import-meta-resolve ,他是 import.meta.resolve 的 polyfill ,可以帮你完成导包寻址行为。

最后我们总结一下,迁移 esm 的成本有很多细节,需要我们涉猎大量的知识后进行迁移。

但我们不是无脑拥抱变化,好好的 cjs 不用,去迁移 esm ?在迁移之前,先看看社区的评价。

泛谈 to esm 的社区评价

我们简单总结下现在社区用户在迁移 esm 过程中的吐槽:

ts-node

ts-node 对 esm 的支持性不好,因为他需要注入特定的 esm loader,而注入 loader 在 nodejs 中目前仍然是一个实验性行为 --experimental-loader官方文档) ,至今为止你还可以看到运行 esm loader 的警告提示。

ts-node-dev

不支持 esm

next.js

nextjs 受轮子哥 Sindre Sorhus 偷袭,紧急发布了 esm 的新测试版本,但是搭配 esm 工具使用时仍然存在很多问题。

Jest

jest 无法识别缺少 main 字段的库,周边工具对 esm 的支持性很差或几乎无法运作。

等等...

关于 esm 的不支持和反论仍有不计其数。不支持者在列举种种 “现在不应该这么做” 的理由。

总结

那么我们既不是社区的大哥,也不是什么深度使用用户,所以从我们纯 “简单使用实践者” 的角度出发应该如何做?

这里提出几个建议。

永远相信 Typscript

Sindre Sorhus 打响了变革的第一枪,ts 也在疯狂跟进,根据我们对 ts 进行的历史种种变革来看,你永远都不知道 ts 会做的多么好,相信 ts 在 4.6 后会更加渐进的引导 to esm 的规范改革和迁移,而这一切都是近似 “平滑有引导” 的,你可以 永远相信 Typescript

做一个 cjs / esm 双面人

由于 exports 的识别优先级比 main 高,所以我们可以借助一些工具的转译和替换机制,在我们 cjs 的写法前提下,将其 polyfill 到 esm 格式(如 __dirname 的 esm 兼容),从而只需编写一次 cjs 即可得到 cjs 和 esm 两种产物,类似于我们在 webpack 项目中使用应用类型的第三方库含有 mainmodule 两种入口一样。

目前社区已经有一些 esbuild 生态工具在做这些实践,但是还仍不成熟,corner case 很多,这或许是 ts 未来可能发展的一种方向也说不定。

思维灵活的降级

既然轮子哥要席卷 esm ,那我们不升级大版本不就得了 😅 ,你升任你升,我每次安装包前都去 github 的 release 看一下当前版本支不支持 cjs ,是不是 pure esm 就可以了,是的话就降级一个大版本安装。

不过这也真的是累。

其实从 nodejs 原生支持 cjs 和历史遗留来看,迁移 esm 的路途必定是任重而道远的,有人吃螃蟹引发变革我们并不反感,因为如果让你来做,你并没有那么大的社区影响力,所以变革最终还是需要越来越多的 社区大哥 跳出来,站出来,而我们一般人,keep tracking 和拥抱变化就可以了。

其他

typescript 4.5 介绍:Announcing TypeScript 4.5