在现代 JavaScript 开发中,ECMAScript Module 已经逐渐成为了公认的业界标准。自 ESM 被引入 Node.js 以来,它的异步加载特性和模块解析逻辑广受大家好评。
然而,由于历史原因,很多既有代码和第三方库仍依赖于 CommonJS 模块系统,然而因为 ESM 的异步加载的设计,两个模块化方案一直是无法共存的,这也成了很多开发者的一大痛点。
最近, joyeecheung 提交的一个关键的 Pull Request(https://github.com/nodejs/node/pull/51977) 来解决这个问题。
在开始介绍前,我们先回顾一下 JavaScript 的两大模块化方案:CJS 和 ESM。
在 JavaScript 的世界里,模块化是构建大型应用程序的基础。模块化可以帮助开发者在不影响全局命名空间的前提下管理代码,便于功能分离、代码复用和依赖管理。在 Node.js 和浏览器环境中,有两种主流的模块系统:CommonJS(CJS)和 ECMAScript Module(ESM)。
CommonJS 是 Node.js 原生支持的模块系统,起初为了满足服务端模块化的需求而被引入。CJS 使用 require 函数来加载模块,用 module.exports 或 exports 对象将代码暴露为模块。CommonJS 模块的特点是同步加载,这意味着代码会在模块被加载完成后立即执行:
// math.js
function add(x, y) {
return x + y;
}
module.exports = { add };
// app.js
const math = require('./math.js');
console.log(math.add(0, 17));// 打印出 17
在服务器环境中,同步加载通常不是问题,因为文件大都在本地。然而,在浏览器环境中,同步加载可能会导致性能问题,因为它会阻塞浏览器的事件循环,直到脚本完全下载和解析。
ESM 是现代 JavaScript 的官方标准模块系统,也被最新版本的浏览器原生支持。与 CommonJS 不同,它们设计成静态的,这意味着你不能在运行时动态地加载或创建模块。ESM 使用 import 和 export 语句进行模块的导入和导出,支持异步加载:
// math.js
export function add(x, y) {
return x + y;
}
// app.js
import { add } from './math.js';
console.log(add(0, 17));// 打印出17
ESM 的设计允许浏览器优化加载和解析过程,如通过 HTTP/2 进行有效的并行加载,以及进行 tree shaking 以剔除未使用的代码,从而增强性能和效率。但是,在 Node.js 中,ESM 的异步特性与现有的大量 CommonJS 模块存在不兼容问题。
当前在 Node.js 中启用 ESM 的方法要复杂一些,因为代表性的 .js 文件扩展名默认与 CommonJS 模块关联。为了解决此问题,Node.js 允许使用 .mjs 文件扩展名或在 package.json 中明确指定 "type": "module" 属性来表示 ESM 模块。
由于 ESM 是在 Node.js 中提供支持的,所以我们可以 import cjs,但不可能 require(esm)。这种 ERR_REQUIRE_ESM 的挫败感困扰着许多人,并且可能是 Node.js 生态系统中浪费时间的主要原因。
如果包作者想要确保 CJS 和 ESM 用户都可以使用他们的包,他们要么必须继续将其模块作为 CJS 发布,要么将 CJS 和 ESM 版本即作为双模块发布(这可能会导致一些问题,但现在这是一种非常常见的做法)。
同时,许多转译器(例如 TypeScript 编译器)仍然配置为生成 CJS 代码作为其最终输出。这些转译器的用户使用 ESM 语法编写代码,但他们不一定知道他们的代码最终由 Node.js 作为 CJS 运行。当他们的代码使用真正的 ESM 第三方模块(无法 require)时,他们会看到一个 ERR_REQUIRE_ESM 。这可能会非常令人困惑,因为他们可能假设他们的代码是作为真正的 ESM 运行的。
自然地,人们可能会问:为什么 require() 就不能支持加载 ESM 呢?