JavaScript ESM 很好,但它现在也许没那么好
本文最后更新于:2022年10月1日 下午
前言
可能许多前端开发者都知道,自从去年 sindresorhus 发表 esm only 的宣言 一年多以来,许多项目开始转向了 esm only,即仅支持 esm 而不支持 cjs,以此来迫使整个生态更快的迁移到 esm only。
一些流行的项目已经这样做了
- sindresorhus 维护的上千个 npm 包
- node-fetch
- remark 系列
- 更多。。。
它们声称:你可以仍然使用现有版本而不升级到最新版,大版本更新不会影响到你。事实如何?
吾辈之前碰到过几次无法使用 esm only 包的问题,每当吾辈想尝试 esm only 时,总是还有一些问题,最痛苦的是,一些包是 esm only,而另一些是 cjs only,总要选择放弃一边,fuck esm only。主要问题一些是 cjs only 的包,以及必须兼容的包 typescript/jest/ts-jest/wallaby 未能正确支持 esm。当然,吾辈可以选择寻找 esm only 包的替代品,例如 globby => fast-glob、remark => markdown-it、node-fetch => node-fetch@2,lodash-es => lodash,但这终究不是一个长久的选择,更何况有些包很难真正找到替代品,例如 remark 系列。
那么,使用旧版本的包有什么问题呢?
主要问题是很难找到正确的版本,当然,如果使用的是相对独立的包,例如 node-fetch 这个,就可以直接使用 v2 版本即可。但如果使用的是 vuepress/remark 这种 monorepo 中包含许多小型包的项目,你很难找到每个子项目正确的版本。
吾辈最近在做 epub 生成器的时候需要从 markdown 并操作 ast 做一些转换,最后转换为 html,因此再次使用 remark,也决定真正尝试使用 esm,下面是一些尝试的过程。
目标
使用 esm 必须解决以下几个问题,否则在生产环境中使用是不可能的
- typescript 支持 – 基本上全部的 web 项目都使用了 ts,不支持的话是不可接受的
- jest 支持 – 同样大量使用的测试工具
- wallaby 支持 – 一个付费的所见即所得的测试工具
- 允许引用 cjs 模块 – 需要支持现有包
- 双模块包仍然能支持两种 esm/cjs 的项目 – 需要支持 cjs 项目引用
- 支持不打包的模块 – monorepo 中有些私有模块不会 bundle
- esbuild 支持 – esbuild 正在成为 lib bundle 标准
修改 package 声明
第一步是修改包的模块类型,修改 "type": "module"
即可将包声明为 esm,所有 js 代码将默认以 esm 模块运行。
1 |
|
TypeScript 支持
从 ts4.7 开始支持 NodeNext,所以需要更改 tsconfig.json
1 |
|
另外,在 ts 文件中导入其他 ts 文件必须使用 .js 后缀
这是一个奇怪的限制,参考 ts 4.7 发布文档
1 |
|
看起来是否会很奇怪,但现在只能这样写,typescript 甚至会这样提示
jest/wallaby 支持
例如使用 pnpm jest src/__tests__/lodash.test.ts
命令运行以下代码
1 |
|
出现错误
1 |
|
从 jest 28 开始支持实验性的 esm 支持,wallaby/ts-jest 也都可以通过配置支持,按照以下步骤即可处理
配置 ts-jest
1
2
3
4
5
6
7
8
9
10
11
12
13
14{
"jest": {
"preset": "ts-jest/presets/default-esm",
"globals": {
"ts-jest": {
"useESM": true
}
},
"moduleNameMapper": {
"^(\\.{1,2}/.*)\\.js$": "$1"
},
"testMatch": ["<rootDir>/src/**/__tests__/*.test.ts"]
}
}修改命令为
node --experimental-vm-modules node_modules/jest/bin/jest.js src/__tests__/lodash.test.ts
配置 wallaby
1
2
3
4
5
6
7
8
9{
"wallaby": {
"env": {
"params": {
"runner": "--experimental-vm-modules"
}
}
}
}由于 esm 的导入是静态的,所以还需要卸载
@types/jest
使用@jest/globals
包导入测试需要的函数,例如it/expect/describe/beforeEach
等等1
2
3
4
5import { it, expect } from '@jest/globals'
it('basic', () => {
expect(1 + 2).toBe(3)
})
nodejs 支持
nodejs 自从 14 就开始支持 esm,但直到目前 18 为止迁移仍然不是平滑的,主要遇到了以下问题。
导入 cjs only 模块
遗憾的是,现存的大量包都是 cjs only 模块的,不可能短时间迁移,而 nodejs 中 esm 与 cjs 的互操作性并不太好,所以需要处理一下。下面以 fs-extra 为例:
之前一般会这样写
1 |
|
使用 tsx 运行时会出现错误 SyntaxError: The requested module 'fs-extra' does not provide an export named 'readdir'
,这似乎是一个已知错误,参考:https://github.com/esbuild-kit/tsx/issues/38
现在需要修改为
1 |
|
或者修改为以下代码使用 ts-node --esm <file>
运行(tsx 不支持这种方式)
1 |
|
使用 __dirname
是的,你没看错,在 esm 模块下 __dirname
不可用了,取而代之的是 import.meta.url
,总而言之,现在的使用方式是
1 |
|
参考文章 https://flaviocopes.com/fix-dirname-not-defined-es-module-scope/,之后在谈到 esbuild 时再说打包 cjs bundle 如何处理 import.meta.url
(在 cjs 中不支持,又是二选一)。
lib 维护与使用
新的 esm 与 cjs 双包支持配置
之前,我们通过 main/module 字段区分模块
1 |
|
但在 esm 项目中引用会报错
1 |
|
esm 项目不认这个,它新定义了 exports 字段,所以需要增加(注意 main 字段仍需保留兼容旧版本 node)exports 字段
1 |
|
参考该回答:https://stackoverflow.com/a/70020984
esbuild 支持
原以为 esbuild 天生支持 esm 所以应该会很简单,但实际上也遇到了相当多的问题。
捆绑以下代码为 cjs 会出现错误
1 |
|
命令
1 |
|
错误
1 |
|
这里是因为 cjs 不能包含顶级 await,修改为
1 |
|
捆绑没有问题,但运行会出错
1 |
|
首先是第一个错误
1 |
|
它说这是一个 esm 包,默认代码是 esm 模块,如果希望是以 cjs 模块执行,需要修改为 cjs 后缀。
修改命令
1 |
|
然后出现了第二个错误
1 |
|
相关代码
1 |
|
根据这个 issue 中作者的回答,修改命令
1 |
|
遗憾的是,这不再生效,bundle 的代码如下
1 |
|
可以明显看到,注入的脚本的变量名被修改了,从 import_meta_url
=> import_meta_url2
,这是奇怪的问题。。。
或许可以替换 --inject
=> --banner
1 |
|
这样就生效了
那么,运行 esm bundle 呢?
也会出现错误
1 |
|
按照这里的解决方法修改命令
1 |
|
现在,bundle 后的代码可以终于运行了。
结语
或许 esm only 看起来很好,也有 tree shaking 看起来很棒的想法,但现在,它都还没有真正在生产中可用。包括一系列重要的项目都没有迁移,包括 react/vscode/electron/vite 等等。实际上,在此之前,许多人(吾辈亦然)也都使用 esm 模块来编写代码,只是最终的 bundle 产物可能不是 esm,例如在浏览器中可能是 iife,在 nodejs 中是 cjs,但绝大多数的应用层开发者并不关心这些,只有 lib 的维护者才会关心,esm only 则将包的复杂度也转移给了使用者,而且在 cjs 中引用 esm only 的包并没有真正可用的方案。相比于 esbuild/vite 这种解决实际问题的项目而言,esm only 运动更像是一场 web 前端圈内的狂欢。