如何使用 ESM 构建更加现代的 nodejs 应用(tsc + nodejs + nestjs)

1,430 阅读10分钟

如何使用 ESM 构建更加现代的 nodejs 应用(tsc + nodejs + nestjs)

现代的前端工程基本和打包工具绑定,写的时候是一套,打包后变成另一套在特定环境运行的代码。

看似很强大,实则确实强大,可里边的掣肘和限制你是否都知道呢?

Node 端开发的那些事

模块兼容问题

当下 nodejs 属于 esm/cjs 两者并存的形式存在,文件默认以 cjs 的形式运行,现代的包倾向于使用 esm,可绝大多数包因为历史原因仍然是 cjs,而做得好的库作者则会打出 cjs 并兼容 esm 的包

而开发工程我们都是要依赖打包工具的,通过不同的配置和打包工具,产物会存在细节上的差异,例如模块查找策略、生成物的格式

这意味着:我们写的和打包出来是不一样的,用不同打包工具会出现有的能跑有的跑不了,通过 node 的某些配置会出现有的能跑有的不能跑

这些大多数都是由于模块差异所导致的,里边的细节都是,如何做模块兼容,如何确保我们在特性环境下运行的就是我们想要的模式(node 中你觉得代码是以 esm/cjs 可结果并不一定) 这里不赘述

因为我们经常写的是 esm,官方的规范首推 esm,随着时间的推移 cjs 会越来越少,我们应该跟着版本走

typescript 和打包工具的矛盾

这个观点可能有些诡异,它两哪来的矛盾

ts 有着官方的编译器 tsc,能够输出类型,做全文的类型检查,收集类型信息用于装饰器,问题是它太慢了,扩展能力也不够

打包工具与之完全相反,有着强大的扩展能力,超快的打包速度,可这是牺牲类型检查带来的好处

(PS. 所有打包工具对 ts 的类型检查都是用的 tsc,只不过配置了只检查类型,其他的什么都不干。所以如果配置了类型检查的功能,会发现速度会显著下降,这里说的就是 vite/webpack

而矛盾也由此而生

ts 代码检查给系统带来的稳定性,和想要追求更加优质开发体验使用打包工具,它们之间做抉择的矛盾心理

(PS. 两者能够通过牺牲一些性能共存,但后边会阐述,也许我们并不需要)

开发遇到的问题

我自己经常用 nestjs 写后端,也经常写些库代码——虽然发出来的寥寥无几,所以会经常遇到 nodejs 环境的模块困扰

因为历史原因 cjs 的残留应用相当多,所以就让写 esm 的代码包作者很难受

因为 cjs 能通过打包实现和 esm 的互操作性,反之则不行。而现在已经有越来越多的库开始使用纯 esm 来发包,为了使用更先进的包必须要用 esm

一开始的想到的解法有两个

  1. 把依赖包通过打包工具提前打成自己想要的—— 从 vite 得到的启发
  2. 服从现状,用 cjs 写代码也不会少块肉

作为喜欢追新的开发者,服从是不会服从的,那么只能通过预打包的形式,可 nodejs 下的预打包有很多坑

  1. esm 打包到 cjs 很轻松,反之就会有各种莫名其妙的报错,这里我先后尝试使用 webpack rollup esbuild tsc rspack,最后能成功的只有 tsc
  2. 预打包是针对于自己项目的手段,对于库作者来说非常的不可取
  3. 各种打包工具对比下,综合维度在 nodejs 中最好用的只有 webpack 和 tscwebpack 打不出来 esm 格式的包,而 tsc 比较慢

这里给出比较的维度,

  1. 打包速度
  2. cjs/esm 两者的打包能力
  3. 类型检查能力
  4. 生成物格式的对比,是否支持打包出 esm/cjs
  5. 对装饰器和元数据的支持能力

esbuild 不支持装饰器和元数据

rspack 的官方案例有很多 external,不用就跑不起来

rollup 打包速度,热更新,缓存等都比不过 webpack

webpack 打不出来 esm 格式的包!!!所以经常会发现用 esm 写的好好的,引入 cjs 时有时候就挂掉了,原因是由于,webpack 会把依赖尽可能转换成一个 bundle,自然就没了模块的区别,可 nodejs 不需要都打成一个 bundle,只需要转换自己写的代码即可。所以会发现 nestjs 项目用 esm 跑就挂掉了,因为产出的引入方式全是 require

最后只剩下个比较慢的 tsc

如何用 tsc 开发纯 esm nodejs 项目

经过上面的比较,tsc 慢归慢,强是真的强,因为只有它能打包后在 esm 规范下运行 cjs 的应用

至于速度方面经过我的一番比较后发现好像问题没想象中大

配置运行环境

为了让 nodejs 默认在 esm 下工作我们需要配置 package.json 的**type**字段,不然默认是用 cjs 运行代码的,因为该 type 字段不写默认是 commonjs

{
  "type": "module"
}

完成后可以随便创建个两个 js 文件用 import/export 导入导出打印看看,会发现不报错了

如果去掉会发现立即报错

修改 tsconfig. Json 的模块查找方案

{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext"
  },
}

nestjs 应用找同名字段,库作者自己把这两行带上就行了

module 表示让 ts 使用什么模块规范

  • NodeNext/Node16 可以当成一个东西,前者包含后者,写文章时的当前时间节点目前这两作用一模一样。它表示使用 esm 规范,适用于 nodejs 的版本

  • es 开头的也是 esm 规范,但它是适用于浏览器的版本

  • commonjs 就是 cjs 规范

  • 其他方案,我们项目和库中基本不用,真需要用肯定要上打包工具了,所以略过

moduleResolution 表示使用什么路径查找方案

就是从哪里引入文件的查找规则,例如 import a from 'b',这里的 b 不要想当然的是从 node_modules,不同规则会用不同的方式找 b 这个模块

这玩意水很多,可以搜相关文章详细了解,这里只做核心概括

  • NodeNext/Node16 可以当成一个,前者包含后者,写文章时的当前时间节点目前这两作用一模一样。它表示使用标准的 esm 规范查找路径,对写代码的影响是,我们引入需要带上后缀,不然会找不到文件

    import a from './b' 变成 import a from './b.js',这里会有代码提示,引入 ts 文件的后缀也是 js

    它需要和 module: NodeNext 结合使用

    支持 package.json 的 expres 字段

  • Bundler 这是最符合我们日常开发的方案,行为同 NodeNext/Node16 但是不用写后缀名了,它通常是和打包工具结合使用,属于社区倒逼标准的产物,因为我们习惯打包工具帮我们隐式添加后缀,但标准要求要加后缀

    所以这东西不和打包工具结合打出来的包涉及到导入导出会可能会运行不了,因为 nodejs 的查找规则是按标准来的,打包后没有后缀 nodejs 会找不到模块

    支持 package.json 的 expres 字段

  • node 它也是按照 nodejs 的标准查找,**通用方案,什么模块规则都能用,引入模块是不用添加文件后缀,不支持 package. Json 的 exports 字段 **

支持 exports 字段意味着,不支持的方案只会应用 main/module/types 字段

  • 支持多个模块导出
  • 支持模糊导出
  • 支持多个类型导出
  • 支持条件导出
{
  "main": "dist/index.js"
  "module": "dist/index.mjs",
  "types": "dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.js"
    },
    "./client": "./types/client.d.ts",
    "./aaa": "./types/client.js",
    "./static/*": {
      "types": "./aa/bb/cc/static/*.d.ts",
      "default": "./aa/bb/cc/static/*.js"
    }
  }
}

这对库作者有着很大收益,因为我们可以根据用户的使用环境和不同的模块方案,让用户的打包工具使用不同的文件入口

配置增量编译

配置 tsconfig.json

{
  "compilerOptions": {
    "incremental": true
  }
}

增量编译在第一次编译之后会生成一个存储编译信息的文件

第二次编译会在第一次的基础上进行增量编译,可以显著的提高编译的速度,同时还能保留代码校验的能力

Tsc 编译的利与弊,如何抉择

tsc 裸用只能用来编译 ts/tsx 文件,并且有监听模式进行热编译,而缺点就是打包工具给的功能都没有,没有热更新,不能加载插件,不能做代码转换

可是我们要想这样的问题,开发 nodejs 的代码和给浏览器执行的代码是有很大区别的,有些问题对浏览器来说是问题,对跑在 nodejs 上来说就不是问题

指标浏览器使用打包工具node 使用 tsc
处理静态资源打包工具会把内容输出到指定目录中可以放在文件夹不用动,每次从相对路径中取即可
摇树 tree shaking很需要,无用的代码多会影响性能无所谓
ts 的强校验打包工具会擦除类型,放弃校验以求最快的编译对校验需求较高(你不能指望打包工具编译通过不报错就觉得没事了,服务端的数据非常宝贵,出错就可以写简历跑路了,而校验会给人很大的安全感)而速度上可以依赖增量编译进行处理
热更新打包工具自带没有,但可以挂个 nodemon 很轻松的通过命令实现
代码转换各种支持不支持,但没有大碍,因为只要 node 版本够,开发能跑线上一样能跑,所见即所得
大型项目的支持能力/monorepo/微服务/等等很好支持需要配合打包工具,或者自己写脚本,选哪个都有一定的成本

大致总结下,在 nodejs 由于不像在浏览器中跑,需要针对不同种类的文件做诸多特定的优化措施,所以很多操作其实都属于,有没有无所谓的状态,提升的主要是开发舒适度而不是性能,以及特殊语法特殊结构项目的支持。而核心其实只要最终的 js 能正常运行就够了,不需要那么多花里胡哨的功能也能够运行的好好的

而面对特殊要求的项目(主要是微任务,monorepo)没法通过一个 tsc 命令行直接搞定,想省事就上打包工具,有能力就自己写写脚本

nestjs 的项目举例子,纵使模块有几百个都没什么问题,热更新也是自带的,配置改成 NodeNext 即可

最令人诟病的是 tsc 的编译性能,但其实官方每个版本都在改进,性能只会越来越好。既然都看这里了,不妨自己开个项目试一试,说一千道一万,都没有自己用的舒服来得实在~