如何使用 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
一开始的想到的解法有两个
- 把依赖包通过打包工具提前打成自己想要的—— 从 vite 得到的启发
- 服从现状,用
cjs
写代码也不会少块肉
作为喜欢追新的开发者,服从是不会服从的,那么只能通过预打包的形式,可 nodejs
下的预打包有很多坑
- 从
esm
打包到cjs
很轻松,反之就会有各种莫名其妙的报错,这里我先后尝试使用webpack rollup esbuild tsc rspack
,最后能成功的只有tsc
- 预打包是针对于自己项目的手段,对于库作者来说非常的不可取
- 各种打包工具对比下,综合维度在
nodejs
中最好用的只有webpack 和 tsc
,webpack
打不出来esm
格式的包,而tsc
比较慢
这里给出比较的维度,
- 打包速度
- 对
cjs/esm
两者的打包能力 - 类型检查能力
- 生成物格式的对比,是否支持打包出
esm/cjs
- 对装饰器和元数据的支持能力
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
的编译性能,但其实官方每个版本都在改进,性能只会越来越好。既然都看这里了,不妨自己开个项目试一试,说一千道一万,都没有自己用的舒服来得实在~