Read OSS

Node.js 如何加载代码:CJS、ESM 与模块管道

高级

前置知识

  • 第 1 篇:架构概览
  • 第 2 篇:启动与引导
  • 第 3 篇:C++ 对象模型与绑定
  • 理解 CommonJS require() 和 ES Module import 的语义

Node.js 如何加载代码:CJS、ESM 与模块管道

Node.js 不只有一套模块系统,而是有两套完整的模块系统,加上一套专供运行时内部使用的模块系统。CommonJS 加载器自 Node.js 0.1 起便已存在。ES 模块加载器则在多年后以截然不同的语义加入。让两者共存并相互操作,是整个代码库中最复杂的工程挑战之一。

本文将梳理 Node.js 中代码加载的完整路径:从保护内部模块的 primordials 模式,到 CommonJS 和 ESM 加载器,再到支持 TypeScript 类型剥离的自定义钩子。

Primordials:防御原型污染

在任何模块加载之前,Node.js 需要保护其内部代码,防止用户代码的 monkey-patching。如果有人执行 Array.prototype.push = () => { throw new Error('gotcha') },这不应该导致 fs.readFile() 崩溃。解决方案是 primordials.js——它在一切执行之前运行,捕获所有 JavaScript 内置对象的冻结副本。

具体做法是:对每一个内置原型方法,primordials 使用 Function.prototype.call.bind() 创建一个"去柯里化"版本:

// Instead of: array.push(item)        — vulnerable to monkey-patching
// Internal code uses: ArrayPrototypePush(array, item)  — safe
const uncurryThis = bind.bind(call);

因此,每个内部模块都会在文件顶部从 primordials 导入所需方法。看看 lib/internal/modules/cjs/loader.js 的开头几行:

const {
  ArrayIsArray,
  ArrayPrototypeFilter,
  ArrayPrototypeIncludes,
  ArrayPrototypeIndexOf,
  ArrayPrototypeJoin,
  // ... dozens more
} = primordials;

这种方式有真实的性能代价。正如源码注释所说:"使用 primordials 有时会对性能产生显著影响,请对性能敏感区域的所有改动进行基准测试。"调用 ArrayPrototypePush(arr, item)arr.push(item) 慢,因为 V8 无法对去柯里化形式进行同等程度的内联优化。不过,安全性的保障被认为值得这一代价。

提示: 在编写或审查内部模块的补丁时,对内置方法务必使用 primordials。linter 会强制执行这一规则——node-core/prefer-primordials 会标记出直接调用原型方法的代码。

内部模块系统(BuiltinModule)

正如第 3 篇所述,BuiltinLoader 在运行时编译嵌入的 JavaScript。而 JavaScript 侧的编排逻辑位于 realm.js,它负责创建 BuiltinModule 类。

flowchart TD
    REQ["require('internal/fs/utils')"] --> BM["BuiltinModule.require()"]
    BM --> CACHED{"Already compiled<br/>and cached?"}
    CACHED -->|Yes| RET["Return cached exports"]
    CACHED -->|No| COMPILE["BuiltinLoader::CompileAndCall()"]
    COMPILE --> WRAP["Wrap source in function:<br/>(exports, require, module,<br/>__filename, __dirname,<br/>internalBinding, primordials)"]
    WRAP --> V8["V8 ScriptCompiler<br/>Compile + Execute"]
    V8 --> CACHE["Cache in module registry"]
    CACHE --> RET

BuiltinModule 为内部模块提供了一个独立的 require() 函数,与公开的 require() 不同。内部模块可以通过 'internal/fs/utils' 这样的路径互相引用,并自动获得 internalBinding()primordials 的访问权限——这些对用户代码是不可见的。

模块加载列表由 process.moduleLoadList 跟踪,它按顺序记录进程生命周期内加载的每一个 binding 和模块,对调试启动性能非常有帮助。

CommonJS 加载器详解

CommonJS 加载器位于 lib/internal/modules/cjs/loader.js——这个 2158 行的文件从 Node.js 最早期就开始演进至今。

入口点是 Module._load(),它实现了核心的 require 算法:

flowchart TD
    LOAD["Module._load(request, parent)"] --> CACHE_CHECK{"Fast path:<br/>relativeResolveCache hit?"}
    CACHE_CHECK -->|Yes| MODULE_CACHE{"Module._cache[filename]?"}
    CACHE_CHECK -->|No| RESOLVE["resolveForCJSWithHooks()"]
    MODULE_CACHE -->|Yes, loaded| RETURN_EXPORTS["Return cached exports"]
    MODULE_CACHE -->|Yes, loading| CIRCULAR["getExportsForCircularRequire()"]
    MODULE_CACHE -->|No| NEW_MODULE["new Module(filename)"]
    RESOLVE --> HOOKS{"Custom resolve hooks?"}
    HOOKS -->|Yes| CUSTOM["Run custom resolver"]
    HOOKS -->|No| RESOLVE_FN["Module._resolveFilename()"]
    RESOLVE_FN --> BUILTIN{"Is builtin module?"}
    BUILTIN -->|Yes| LOAD_BUILTIN["Load from BuiltinModule"]
    BUILTIN -->|No| FIND_PATH["Module._findPath()<br/>Search node_modules tree"]
    FIND_PATH --> NEW_MODULE
    NEW_MODULE --> COMPILE["module._compile(content, filename)"]
    COMPILE --> RETURN_EXPORTS

Module._resolveFilename() 实现了解析算法:检查是否为内置模块,向上遍历 node_modules 目录树,查找 package.json 中的 mainexports 字段。相对路径解析缓存(relResolveCacheIdentifier = parent.path + '\x00' + request)使得从同一目录重复 require 的开销几乎可以忽略不计。

Module.prototype._compile() 是 CommonJS 包装魔法发生的地方。每个 CJS 模块都会被包裹在一个函数中:

(function(exports, require, module, __filename, __dirname) {
  // Your module code here
});

这就是为什么 exportsrequiremodule__filename__dirname 在每个 CommonJS 文件中都能直接使用,而不需要显式导入——它们是函数参数,而非全局变量。

ESM 加载器架构

ESM 加载器与 CommonJS 有着本质区别。它是异步的,支持 import assertions,拥有基于阶段的生命周期,并通过 ModuleWrap C++ 绑定委托给 V8 的原生模块 API。

负责编排的是 lib/internal/modules/esm/loader.js 中的 ModuleLoader,它管理 resolve → load → translate → instantiate → evaluate 这一完整管道。

sequenceDiagram
    participant USER as import 'specifier'
    participant ML as ModuleLoader
    participant MJ as ModuleJob
    participant TR as Translators
    participant MW as ModuleWrap (C++)
    participant V8 as V8 Module API

    USER->>ML: import('specifier')
    ML->>ML: resolve(specifier) → URL
    ML->>ML: load(URL) → source + format
    ML->>TR: translate(source, format)
    TR->>MW: new ModuleWrap(source, url)
    MW->>V8: v8::Module::Compile()
    ML->>MJ: new ModuleJob(loader, url, moduleWrap)
    MJ->>MJ: link() — resolve all dependencies
    MJ->>MW: instantiate()
    MW->>V8: v8::Module::InstantiateModule()
    MJ->>MW: evaluate()
    MW->>V8: v8::Module::Evaluate()
    V8-->>USER: module namespace

ModuleJob 代表一个正在经历生命周期的单个模块。构造函数会立即开始 linking——解析模块中所有的 import 语句,为依赖项创建 ModuleJob 实例,从而构建出完整的依赖图。

translators 模块将文件格式映射到对应的翻译策略:JavaScript 文件通过 ModuleWrap 以 ES 模块方式编译;JSON 文件被包裹为 export default;WebAssembly .wasm 文件通过 V8 的 WebAssembly API 编译;.node 原生插件通过 process.dlopen() 加载;CJS 文件则经过一个专门的 CJS-to-ESM 翻译器处理。

CJS↔ESM 互操作

两套模块系统之间的互操作是 Node.js 中最棘手的部分之一。根本矛盾在于:CJS 的 require() 是同步的,而 ESM 的 import 是异步的。

flowchart LR
    subgraph "ESM importing CJS"
        ESM1["import cjs from 'pkg'"] --> TRANSLATE["CJS translator<br/>Executes CJS module<br/>Wraps exports"]
        TRANSLATE --> NS1["Module namespace<br/>default = module.exports"]
    end
    
    subgraph "CJS requiring ESM"
        CJS1["require('esm-pkg')"] --> SYNC{"Module already<br/>evaluated?"}
        SYNC -->|Yes| NS2["Return namespace"]
        SYNC -->|No| ERR["ERR_REQUIRE_ESM<br/>(unless --experimental-require-module)"]
    end

ESM 导入 CJS 的方式是:同步执行 CJS 模块,将 module.exports 包装为默认导出。命名导出则通过静态分析 CJS 源码来检测。

CJS 引用 ESM 则更为复杂。require() 是同步的,但 ESM 的求值可能是异步的(顶层 await)。Node.js 现在支持对不使用顶层 await 的 ESM 模块调用 require(),但若模块包含异步求值,则会抛出 ERR_REQUIRE_ASYNC_MODULE

ModuleJobSync 类处理 CJS 同步引用 ESM 的情形,提供了一个精简版的 ModuleJob 生命周期,可以在不使用 Promise 的情况下运行。

模块自定义钩子与 TypeScript 支持

Node.js 支持通过钩子自定义模块解析和加载行为,目前有两套系统:

  1. 同步钩子,通过 lib/internal/modules/customization_hooks.js 实现——resolveload 钩子运行在主线程中。

  2. 异步钩子,通过 --experimental-loader 实现——这些钩子运行在独立的 worker 线程中,避免潜在的异步解析阻塞主事件循环。

flowchart TD
    REGISTER["register() hook API"] --> SYNC{"Sync or async?"}
    SYNC -->|Sync| MAIN["Hooks run in main thread<br/>customization_hooks.js"]
    SYNC -->|Async| WORKER["Hooks run in worker thread<br/>Communicates via MessagePort"]
    
    MAIN --> RESOLVE["resolve(specifier, context, next)"]
    MAIN --> LOAD["load(url, context, next)"]
    WORKER --> RESOLVE2["resolve(specifier, context, next)"]
    WORKER --> LOAD2["load(url, context, next)"]
    
    LOAD --> TS{"TypeScript file?"}
    TS -->|Yes| AMARO["deps/amaro<br/>Strip type annotations"]
    TS -->|No| CONTINUE["Continue normal loading"]
    AMARO --> CONTINUE

TypeScript 支持正是建立在这套钩子基础设施之上的。当启用 --strip-types(或文件扩展名为 .ts)时,Node.js 使用 lib/internal/modules/typescript.js,通过内置的 amaro 依赖(位于 deps/amaro/)剥离类型注解。这是类型剥离,而非完整的 TypeScript 编译——它只移除类型注解,不进行类型检查,也不转换 enum 等 TypeScript 特有语法。

提示: 如果你在构建自定义加载器(例如用于某种编译到 JavaScript 的语言),请优先使用 register() API,而非已废弃的 --experimental-loader 标志。register() API 同时支持同步和异步钩子,是未来的正式方向。

入口点模块解析

当你执行 node app.js 时,Node.js 如何决定以 CJS 还是 ESM 方式加载它?逻辑位于 lib/internal/modules/run_main.js

flowchart TD
    ENTRY["node app.js"] --> RESOLVE["resolveMainPath(main)"]
    RESOLVE --> EXT{".mjs extension?"}
    EXT -->|Yes| ESM["Load as ESM"]
    EXT -->|No| CJS_EXT{".cjs extension?"}
    CJS_EXT -->|Yes| CJS["Load as CJS"]
    CJS_EXT -->|No| WASM{".wasm extension?"}
    WASM -->|Yes| ESM
    WASM -->|No| TS{".mts extension?<br/>(--strip-types)"}
    TS -->|Yes| ESM
    TS -->|No| CTS{".cts extension?"}
    CTS -->|Yes| CJS
    CTS -->|No| PKG_TYPE["Check nearest<br/>package.json 'type' field"]
    PKG_TYPE -->|"module"| ESM
    PKG_TYPE -->|"commonjs" or absent| CJS

shouldUseESMLoader() 函数负责做出这一判断。首先检查文件扩展名(.mjs → ESM,.cjs → CJS);若扩展名为 .js,则回退到最近的 package.jsontype 字段。--experimental-loader--import 标志的存在也会强制走 ESM 加载器路径。

接着,run_main_module.js 调用 Module.runMain(),根据判断结果直接通过 CJS 加载器执行文件,或将控制权交给 ESM 加载器。

下一篇

至此,我们已经完整梳理了代码进入 Node.js 进程的全过程——从 C++ 绑定到用户模块。下一篇文章将深入探讨代码实际执行时发生的事情:I/O 系统、streams、定时器,以及驱动 Node.js 异步 I/O 运转的事件循环机制。