如何让 Typescript 的构建产物同时支持 CommonJS 和 ESM
我最近在 Github 收到了一个 Issue,指出了我的开源项目中 ESM
格式的构建产物存在问题。
这个 Issue 非常有意思,并在@Evert Pot的帮助下比较优雅的解决了这个问题。
这个 Issue 也反映了使用 Typescript 在同一个项目中,同时输出 EJS
和 ESM
格式在当下并不是一件轻松的事情。
tsc
而不是 webpack
/rollup
/tsup
为何选择使用 我的大多数开源项目都会选择直接使用 tsc
,而不是 webpack
/rollup
等构建工具。
这是因为 tsc
会最大程度的保留原始的文件代码结构和目录结构。当开发者在使用库时遇到问题,错误栈将会非常有帮助。
并且,开发者可以直接跳转至 node_modules
中的阅读源码,这对于调试和解决问题都非常有价值。
为什么要同时支持 ESM 和 CommonJS
直面现实, 拥抱未来。
CommonJS 是当下 Javascript 社区的现实。如果不借助 webpack
/rollup
/vite
等工具,在 CommandJS 中是无法直接使用 ESM 包。
历史上沉淀下的大量工具包是不可能在短时间内全面支持 ESM,当下在全世界无数服务器中的项目也不可能马上用 ESM 重写。(npm-esm-vs-cjs展示了当下开源库中支持 ESM 的比例)
ESM 在当前看来,是 Javascript 的未来。JS 的社区是非常激进且活跃的,支持 ESM 即是让敢于尝试的人放手一搏,也是避免自己被淘汰。
当下越来越多的第三方库仅支持 ESM。我并不提倡这种做法,它太激进了。但这已经导致部分开发者不得不面对 ESM 和 CommonJS 二选一的局面。 我希望我的开源库不会给人们带来这种困扰。都是牛马,何必相互为难呢。
tscofnig.json
中使用 esModuleInterop
不要在 esModuleInterop
配置允许将 import * as path from 'node:path'
写成 import path from 'node:path'
。
虽然这个配置可以少些几个字符,但是这个配置具有传染性!也就是说,如果库中使用了esModuleInterop
,使用这个库的项目也必须添加esModuleInterop
。
如果库不使用esModuleInterop
,那么使用这个库的项目可以自由的选择是否开启esModuleInterop
。
作为开源库的开发者,我认为应该将是否使用esModuleInterop
配置的权力给予用户。
package.json
中配置 exports
在 package.json
有许多的相似的字段,这也导致很容易配置错误。你是否能准确地说清楚main
/module
/types
/exports.require
/exports.import
这几个字段的差异?:
main
: 库的默认入口文件。 但是,如果用户使用CommandJS
,会优先使用exports.require
;如果用户 ESM,会优先使用exports.import
或者是module
。 在不支持exports
的旧版本的 NodeJS 和构建工具中,依旧优先使用main
。 因此我们通常将默认的CommonJS
构建产物指定给main
。module
: 虽然我们经常见到这个字段,但它并不是标准package.json
字段。 它是exports.import
出现前的 JS 社区的代替方案。(参考资料) 大多数流行的打包工具都是支持module
字段的。也如你所想,他的优先级是低于exports.import
的。exports.require
/exports.import
: Node.js 12+ 支持exports
作为main
的替代方案,可以支持定义子路径导出和条件导出,同时封装内部未导出的模块。types
是 Typescript 查找.d.ts
文件的入口。
我的建议是,不放弃任何一个字段。
我 Github 收到的 Issue 便是因为没有添加
export.import
导致在部分场景下ESM
无法正确import
。我竟然一直认为module
是标准的package.json
字段,因为看上去确实与main
更搭配。
.js
后缀
记得添加 // 缺少扩展名,编译成的 ESM 产物无法使用 import Foo from "./foo"; // ESM不会默认引入文件夹下的 index.js import * from './directory' // 正确的写法 import Foo from "./foo.js"; import * from './directory/index.js'
tsc
编译时并不会自动的帮你添加.js
后缀,但 ESM 要求必须有扩展名。这确实是一个麻烦。另外,在 Typescript 使用 .js
而不是 .ts
令人代码看上去不太优雅。
然而不幸的是,Typescript 团队并没有计划解决这个问题。不过我相信随着 ESM 更加广泛的应用,社区会推进 Typescript 团队改善这个问题。
至于现在,可以使用 ts-patch
+ typescript-transformer-esm
解决这个问题。
我选择添加
.js
扩展名的方案,因为 VsCode 可以将auto import
配置改成自动添加.js
(默认是省略扩展名)。
垫片
ESM 并不支持 __dirname
和 __filename
两个全局变量,取而代之的是import.meta.url
。不过,我们想要同时支持 CommonJS
和 ESM
,建议更换成 cross-dirname
。
构建脚本
由于 tsc
并不能将文件扩展名重写成 .cjs
/.mjs
。
因此我们需要分别在CommonJS
和ESM
的构建产物目录下添加package.json
文件。
这确保了 NodeJS 可以正确的判定 .js
是 CommonJS
还是 ESM
。
tsc --module commonjs --outDir cjs/ echo '{"type": "commonjs"}' > cjs/package.json tsc --module esnext --outDir esm echo '{"type": "module"}' > esm/package.json
后记
ESM
确实有许多令人眼前一亮的特性,但是目前依旧有许多工程性的问题需要解决(例如 Typescript 的扩展名)。
希望这篇文章可以解决你 CommonJS
和 ESM
二选一的困扰,成熟的开发者当然是选择全都要。
将这个恼人的决定权交给你的用户吧。