互相打架的 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
2
3
const fs = require('node:fs');

module.exports = { foo: 1, bar: 2 };

ESM:

1
2
3
4
5
import fs from 'node:fs';

export const foo = 1;

export const bar = 2;

面临的问题

现如今绝大部分 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
2
- foo.cjs
- foo.mjs

可有的项目他很“叛逆”,或者用户量不多,所以写了其中一种规范。比如坑爹的 node-fetch@3,彻底放弃了对 CJS 的支持,也就是禁止你 require 引入它了。

对于 CJS 的第三方库规范来说,ESM 对其支持还是可以的。

你可以比较正常的 import CJS 暴露出来的模块。

1
2
3
4
5
// lib.cjs
module.exports = function sayHello() {};

// main.js
import sayHello from './lib.cjs';

唯一有点问题就是不能随便在 import 的时候进行析构赋值:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 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。

1
2
3
4
5
6
7
// 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 文件是不需要写扩展名的。

1
const foo = require('./foo');

可 ESM 不行,因为 Node 认为你不止可以 import JS 文件,所以没有默认解析其为 .js 的能力。

这可麻烦大了。

因为现在 TypeScript 如日中天,非常好用。我们的很多 Node 项目都是 TS 写好了之后,tsc 编译成 JS 再来跑的。

但 TS 里面你 import 是不需要扩展名的——甚至写了 .ts 的扩展名还会报错。

1
import foo from './foo.ts'; // 报错

因此,tsc 编译出来的文件也是没有扩展名的:

1
2
3
4
5
// 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 项目来说,可以这么解决模块化的坑:

  1. 使用 ESM
  2. package.json 中 type 字段设为 module
  3. 对只有 commonjs 的包(比如 lodash)谨慎进行 import 析构
  4. 如果是由 tsc 编译出来的,import 不具备扩展名,使用 node --es-module-specifier-resolution=node dist/main.js 进行启动

如果使用 nestjs 这类拥有自己 cli 工具的项目,可以查阅文档如何为 node 启动添加参数,例如 nestjs 可以进行如下改写:

1
2
3
4
5
6
{
"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"
}

(完)


互相打架的 Node.js 模块化规范
https://www.hangyu.art/2022-10-05/互相打架的Nodejs模块化规范/
作者
徐航宇
发布于
2022年10月5日
许可协议