TypeScript + ES Modules + Node.js + Webpack 工作流

 

最近在更新 thu-learn-lib 的时候,遇到了一个比较棘手的问题。我的整个项目是用 TypeScript 写的,并且希望编译成现代 ES Module (ESM) 的形式供下游使用。通常来说,这个库有几种用法:

  • 直接使用 Node.js 编写脚本,或;
  • 引入复杂的前端项目中,此后用 Webpack 等类似工具打包,或;
  • 在浏览器中直接使用 <script> 标签引入使用。

对于前两种用途,理论上只需要把 TypeScript 编译成 ES Module 的形式;而对于第三种,通常需要开发者使用 Webpack 等工具进行打包后生成捆绑版本(bundle version),包含所有的依赖源码。 因此,需求就简化成了编译输出一份 ESM 供(下游的) Node.js 和打包工具使用,并使用(自己的)打包工具将 TypeScript 代码打包为捆绑版本。当然其实也可以从编译后得到的 ESM 进行再次打包,但我有强迫症,不想这么做。

为了使得 TypeScript 编译器 tsc 生成 ES Modules,我的 tsconfig.json 配置如下:

{
    "compilerOptions": {
      "target": "es2018",
      "module": "esnext",
      "moduleResolution": "node",
      "declaration": true,
      "outDir": "./lib",
      "strict": true,
      "esModuleInterop": true
    },
    "include": ["src"],
    "exclude": ["node_modules", "**/__tests__/*"]
}

其中关键的选项包括 module 决定了输出的类型,这里使用 esnext 保证它是最新的;esModuleInterop 选项保证了一些原本 CommonJS 中采用的 default import 特性能被编译器接受(具体可见 官方文档)。

此时,对于 src 目录下的每一个 TypeScript 文件,tsc 会在 lib 目录下生成一个 .js 和一个 .d.ts 文件。对于 Webpack 打包的前端项目,事实上这已经足够正常工作了。然而对于 Node.js 来说,直接使用 import 导入这个库会因为找不到库内部的依赖而报错。 具体来说,tsc 不会修改脚本中的导入语句,任何类似 import Foo from './foo'; 的语句,编译后会被原样保留。 然而根据 Node.js ESM Resolver Algorithm Specification 中的规定,Node.js 并不会通过 ./foo 解析到 ./foo.js,因此导入就失败了。

第一次尝试时,我直接将原本 TypeScript 中的 import 改为 ./foo.jstsc 不会报错,Node.js 也能够正常工作。然而,用来生成捆绑版本的 Webpack 脚本此时却报告找不到 ./foo.js。 这也是可以理解的,因为 Webpack 只关注原始的 src 目录(只包含 TypeScript 代码),并不会查看 lib 目录下生成的 js,并且我也不想让它依赖 tsc 后的产物。

如果 tsc 能在编译时自动修改这些本地的导入路径,在后面添加 .js,那么上面的两个问题就能同时得到解决。我在一个前端 Telegram 群内询问后,热心网友 Jack Works 告知目前还做不到,并推荐了以下两种方案:

{
    "transform": "@magic-works/ttypescript-browser-like-import-transformer",
    "after": true,
    "appendExtensionName": "true",
    "extName": ".mjs",
    "folderImport": true,
    "rules": {
        "[a-z].+": false,
    }
}
  • 使用一个尚处于 PR 阶段的 TypeScript 功能(可安装 @typescript-deploys/[email protected] 这个包获得),生成 node12 格式的 module,也会自动进行这样的处理。

但无论是哪种方法,对我这么小的项目(一共四个 .ts)来说都有一些重了。最终经过思考后,我修改了 packages.json 中的构建命令:

"scripts": {
    "build": "tsc && sed -i -E \"s/from '(\\.\\/.*)'/from '\\1.js'/g\" lib/*.js",
}

即在运行 tsc 后,使用 sed 把所有编译生成的 .js 文件中,形如 from './foo' 的导入语句修改为 from './foo.js'。虽然有一些脏,但能够最轻量级地解决上述的问题。

最后:CommonJS 都是异端,ES Modules 才是未来!