关于 NodeJS 模块化不得不说的坑
关于 NodeJS 模块化不得不说的坑
本文写于:2022-10-05
在过去的几年时间里,我一直是一名全栈工程师,工作内容偏向于前端。但是在后端技术栈上,我其实一直没有太多的接触 Node.js,更多的是使用 Golang、Ruby 进行后台的编写。
最近在机缘巧合之下,接手了一个使用 Node.js 开发的后端项目,因此这个国庆期间就进行了一番学习与折腾。
不折腾不要紧,一折腾血压就高的不行——Node 在一个简单的模块化问题上,居然有这么多坑。
简单介绍一下背景。
Node 发展到了今天,基本由两种模块化统治:CommonJS 与 ES Module。
CJS:
const fs = require("node:fs");
module.exports = { foo: 1, bar: 2 };
ESM:
import fs from "node:fs";
export const foo = 1;
export const bar = 2;
那二者孰优孰略呢?
简单来说:CommonJS 是曾经的老大,ES Module 是未来的方向。
面临的问题
现如今绝大部分 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 他的时候生效。
就像这样:
- foo.cjs
- foo.mjs
可有的项目他很“叛逆”,或者用户量不多,所以写了其中一种规范。比如坑爹的 node-fetch@3
,彻底放弃了对 CJS 的支持,也就是禁止你 require 引入它了。
对于 CJS 的第三方库规范来说,ESM 对其支持还是可以的。
你可以比较正常的 import CJS 暴露出来的模块。
// lib.cjs
module.exports = function sayHello() {};
// main.js
import sayHello from "./lib.cjs";
唯一有点问题就是不能随便在 import 的时候进行析构赋值:
// lib.cjs
module.exports = {
a: 1,
b: 2,
};
// main.js
import { a, b } from "./lib.cjs"; // 报错
import lib from "./lib.cjs"; // 成功
const { a, b } = lib;
console.log(a, b);
这是因为 ESM 是后出的,必须考虑到需要兼容 CJS 的情况。
但 CJS 导入 ESM 就没这么好运了,非常困难。
目前能做到的比较好的方法就是使用 dynamic import。
// lib.mjs
export default function sayHello() {}
// main.js
import("./lib.mjs").then((lib) => {
lib.default();
});
首先这要求 node 的版本支持 dynamic import,其次他只能是异步的导入,对我们很多代码书写来说是存在问题的。
这其实是一个很大的问题:新的第三方库都必须想办法兼容 CJS,不然的话很多老项目就没办法使用你了。这就大幅度拖慢了 ESM 统一的节奏。
问题 2:ESM 必须带上文件扩展名进行 import
在 CJS 规范中,我们 require JS 文件是不需要写扩展名的。
const foo = require("./foo");
可 ESM 不行,因为 Node 认为你不止可以 import JS 文件,所以没有默认解析其为 .js
的能力。
这可麻烦大了。
因为现在 TypeScript 如日中天,非常好用。我们的很多 Node 项目都是 TS 写好了之后,tsc 编译成 JS 再来跑的。
但 TS 里面你 import 是不需要扩展名的——甚至写了 .ts
的扩展名还会报错。
import foo from "./foo.ts"; // 报错
因此,tsc 编译出来的文件也是没有扩展名的:
// main.ts
import foo from "./foo";
// main.js
import foo from "./foo"; // ESM 下报错
为了解决这个问题,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 可以进行如下改写:
{
"start:dev": "nest start --watch",
"start:dev:esm": "nest start --watch -e 'node --es-module-specifier-resolution=node'",
"start:prod": "node dist/main",
"start:prod:esm": "node --es-module-specifier-resolution=node dist/main"
}
(完)