CJS和ESM

Category: JavaScript

前言

最近在做的一个 nest.js 项目,其中遇到了不同标准的模块共用的“坑”。

比如我要使用 unified 这个模块及其生态来解析 markdown 文件,但是这个模块呢,它写明了 ESM-only

由于该 nest 项目的package.json的 type 不是type: "module",如果直接使用的话就会报错如下

const unified_1 = require("unified");
                  ^
Error [ERR_REQUIRE_ESM]: require() of ES Module node_modules\unified\index.js from xxx.service.js not supported.
Instead change the require of index.js in xxx.js to a dynamic import() which is available in all CommonJS modules.

根据错误提示,我去翻了一下,看到在 Node12 之后,支持了 ESM 的 dynamic import。也就是说,我们可以如下导入 ESM

const { foo } = await import("npm-package");

那我尝试改一下

const { unified } = await import("unified");

但还是报错了

xxx.service.js:41
    const { unified } = await Promise.resolve().then(() => require('unified'));
                            ^
Error [ERR_REQUIRE_ESM]: require() of ES Module node_modules\unified\index.js from xxx.service.js not supported.
Instead change the require of index.js in xxx.service.js to a dynamic import() which is available in all CommonJS modules.

我看一看 TypeScript 到底被编译成了什么

const { unified } = await Promise.resolve().then(() => require("unified"));

emm…编译之后的 js 代码还是 用了require() 来导入 ESM,那有什么办法让它不把 dynamic import 给转译到 require() 吗?

应该是 tsconfig 的问题,上网找了一下,发现了这个 issue也有不少 campaign,不过细看可以发现,在 Typescript4.5 之后支持了一个新的特性:ECMAScript Module Support in Node.js

即可以在 "type": "commonjs" 的 Node 项目里头同时支持 CommonJS 与 ESM,我们只需要在 tsconfig 里头更改如下即可

{
  "compilerOptions": {
    "module": "node12"
  }
}

最后编译的产物如下,这个 ESM-only 的模块就能正常使用了

const { unified } = await import("unified");

两者的区别

在此坑出现之前,我对 CommonJS 和 ESM 的概念,并没有认识太多,只有:CommonJS 是 Node 的模块规范,ESM 是 ES 的模块规范,后者的出现,代表着模块终于被作为 ES 的核心特性了。

Node 和 ESM 对于模块的主要定义是差不多的,即每一个文件都是一个拥有私有命名空间的独立模块,模块也有作用域,是模块文件本身。

我们在在文件里头定义的变量、常量、函数和类都是私有的,除非它们被显式地导出。

接下来,它们主要的区别就是导入导出的 syntax 了。

CommonJS 的导入,它在 node 中是使用 require() 导入的

const fs = require("fs");
const foo = require("./foo.js");
// or
const { sum } = require("./foo.js");

导出,通过设置全局 Export 对象的属性或者完全替换 module.export 对象,来导出公共 API

// 直接设置全局 exports 对象的属性
exports.foo = () => console.log("Hello from foo");
exports.bar = () => exports.foo();

// 或者直接设置模块的默认导出
module.exports = /** class, function, variable */
// or export as an object
const sum = (x, y) => x + y;
const avg = (x, y) => (x + y) / 2;
module.exports = { sum, avg };

我们在一个文件中,导入的 ESM 的值的标识符都是常量

导入只能在模块的顶层导入(动态导入除外),并且要是一个带 / 的绝对路径或者开头为 ./ ../ 的相对路径,这样做可以避免歧义。

导入的函数也是会被“提升”到顶部的。

我们还可以导入一个没有任何导出的模块,一般用途是执行一个模块里头的代码(比如注册一些事件啥的)

并且,我们还能对导入进来的标识符进行重命名。

import foo from "./foo.js";
import { sum } from "./foo.js";
import Bar, { sum, avg } from "./foo.js";
import * as util from "./foo.js";
// 导入一个没有任何导出的函数
import "./foo.js";
// 重命名
import { sum as s } from "./foo.js";
import { default as Foo, avg } from "./foo.js";

导出也可以导出变量、常量、函数和类,模块只能有一个默认导出,以及多个常规导出。ESM 的 export 关键字只能出现在 JS 代码的顶层,即不能出现在函数,类,块里头。对导出的标识符,我们也可以进行重命名。

export class Bar {}

// OR
export default class Bar {}
const sum = (x, y) => x + y;
const avg = (x, y) => (x + y) / 2;
export { sum, avg };
export * from "./bar.js";
export { mean } from "./bar.js";
export { sum as s };

ESM 还有一个特性就是import.meta这个特殊的语法,它引用了一个特殊的对象,这个对象包含了当前执行模块的元数据,经常用到的有import.meta.url即加载模块时使用的 URL(类比一下 Node 模块的__dirname)

动态导入

ESM 和 CommonJS 的导入和导出都是静态的,ES2020 又把动态导入加了进来,ESM 可以被异步地动态导入,使用import(),值得注意的是这个是一个操作符,不是函数!

比如我们本来就可以静态导入一个对象

import { avg } from "./foo.js";

而现在我们现在还可以动态地导入一个对象了

import("./foo.js").then((foo) => {
  let meanValue = foo.avg(n1, n2);
});

还有更多的细节可以看看这里

做一个 Pure ESM

那么问题来了,前言中提到的这个包,它是怎么样实现 ESM-only 的呢?

如果是新建一个的话,其实很简单,如果不是,还要考虑如 tsconfig.json 的配置,更改模块内的 import 的 syntax,可以参考这个gist,写得很详细

把这个模块中的 CommonJS 的 export 给去掉,换成 ESM 的 export,再把 package.jsontype 给改成 module,以及将 main 改为 exports 即可

function hello(name) {
  console.log(`Hello, ${name}`);
}

export { hello };

package.jsontype 以及将 main 改为 exports

"exports": "./index.js",
"type": "module"

这下我们在一个 CommonJS 项目中直接导入

const { hello } = require("../esm-only-package");

hello("situ2001");

就会报错

const { hello } = require('../esm-only-package');
                  ^

Error [ERR_REQUIRE_ESM]: require() of ES Module C:\Users\situ\Desktop\tmp\esm-only-package\index.js from C:\Users\situ\Desktop\tmp\node-test\index.js not supported.
Instead change the require of C:\Users\situ\Desktop\tmp\esm-only-package\index.js in C:\Users\situ\Desktop\tmp\node-test\index.js to a dynamic import() which is available in all CommonJS modules.
    at Object.<anonymous> (C:\Users\situ\Desktop\tmp\node-test\index.js:1:19) {
  code: 'ERR_REQUIRE_ESM'
}