关于 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 项目来说,可以这么解决模块化的坑:

  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 可以进行如下改写:

{
  "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"
}

(完)

posted @ 2022-10-06 00:23  徐航宇  阅读(1076)  评论(0编辑  收藏  举报