互相打架的 Node.js 模块化规范
互相打架的 Node.js 模块化规范
什么是模块化
模块化(Modularity)是指将代码拆分为独立、可复用的单元(模块),每个模块专注于单一功能。这种设计方式像用乐高积木搭建系统,开发者通过组合不同模块构建应用程序。
模块化的核心价值体现在:
- 代码隔离:每个模块拥有独立作用域,避免全局污染
- 依赖管理:明确声明模块间的依赖关系
- 可维护性:局部修改不影响整体系统
- 复用性:模块可被不同项目重复使用
JavaScript 的模块化
在 JavaScript 生态中,曾出现过多种模块化方案:
- IIFE(立即执行函数):早期浏览器端的封装方式
- AMD(Require.js):异步加载的代表
- CMD(Sea.js):按需加载的尝试
- UMD:兼容多种环境的过渡方案
- CommonJS:Node.js 的默认方案,浏览器不支持
- ES Module:语言标准的终极答案,浏览器 or Nodejs 都支持
到了今天,Node.js 中基本由两种模块化统治:CommonJS 与 ES Module。
CJS:
1 |
|
ESM:
1 |
|
面临的问题
现如今绝大部分 Node.js 的第三方库都是由 CJS 写成的——这是因为 Node 曾经很长一段时间里不支持 ESM。
不过到了 2022 年,这对于前端来说其实根本不是问题了,因为 webpack、esbuild、rollup 之类的打包工具是非常强大的。不管你是什么模块化规范,只要用了打包工具,都给我统统 bundle 进来!
所谓 bundle,其实就是打包工具可以将自己写的 n 个文件的代码、第三方的 n 个库,都编译输出到一起,比如全放到一个文件里。
可是后端项目不能乱 bundle 啊,具体为什么可以参考这个废弃的库,README 里有说明:https://github.com/ZenSoftware/bundled-nest 。
理由我大概总结一下:很多第三方库会使用 native extension,比如 C++ Addons,这些是不跨平台的,必须到了目标平台再 build,如果把 dependencies 都 bundle 起来,对于 Node.js 项目来说很容易出现问题,最好是到了目标平台再 npm install
。
问题 1:如何交叉引入(ESM 引入 CJS、CJS 引入 ESM)
所以我们现在就面临了第一个问题:在打包工具不能参与的情况下,第三方库又可能是 CJS 规范、又可能是 ESM 规范,我们该如何处理呢?
通常,一些成熟的项目都会有两套代码:一套在你 require 它的时候生效,使用 CJS 规范;另一套使用 ESM 规范,在你 import 他的时候生效。
就像这样:
1 |
|
可有的项目他很“叛逆”,或者用户量不多,所以写了其中一种规范。比如坑爹的 node-fetch@3
,彻底放弃了对 CJS 的支持,也就是禁止你 require 引入它了。
对于 CJS 的第三方库规范来说,ESM 对其支持还是可以的。
你可以比较正常的 import CJS 暴露出来的模块。
1 |
|
唯一有点问题就是不能随便在 import 的时候进行析构赋值:
1 |
|
这是因为 ESM 是后出的,必须考虑到需要兼容 CJS 的情况。
但 CJS 导入 ESM 就没这么好运了,非常困难。
目前能做到的比较好的方法就是使用 dynamic import。
1 |
|
首先这要求 node 的版本支持 dynamic import,其次他只能是异步的导入,对我们很多代码书写来说是存在问题的。
这其实是一个很大的问题:新的第三方库都必须想办法兼容 CJS,不然的话很多老项目就没办法使用你了。这就大幅度拖慢了 ESM 统一的节奏。
问题 2:ESM 必须带上文件扩展名进行 import
在 CJS 规范中,我们 require JS 文件是不需要写扩展名的。
1 |
|
可 ESM 不行,因为 Node 认为你不止可以 import JS 文件,所以没有默认解析其为 .js
的能力。
这可麻烦大了。
因为现在 TypeScript 如日中天,非常好用。我们的很多 Node 项目都是 TS 写好了之后,tsc 编译成 JS 再来跑的。
但 TS 里面你 import 是不需要扩展名的——甚至写了 .ts
的扩展名还会报错。
1 |
|
因此,tsc 编译出来的文件也是没有扩展名的:
1 |
|
为了解决这个问题,Node 提供了一个 flag: --es-module-specifier-resolution=node
。
只需要运行 node --es-module-specifier-resolution=node main.js
就可以使得 import 不需要扩展名。只是很遗憾,这个功能还是一个实验性功能,随时可能会在新版本中移除。
总结
对于大部分 Node 项目来说,可以这么解决模块化的坑:
- 使用 ESM
- package.json 中 type 字段设为
module
- 对只有 commonjs 的包(比如 lodash)谨慎进行 import 析构
- 如果是由 tsc 编译出来的,import 不具备扩展名,使用
node --es-module-specifier-resolution=node dist/main.js
进行启动
如果使用 nestjs 这类拥有自己 cli 工具的项目,可以查阅文档如何为 node 启动添加参数,例如 nestjs 可以进行如下改写:
1 |
|
(完)