Node.js 如何与 ES Modules 交互

image

今天看到 Joyee Cheung 大佬写的 require(esm) in Node.js 这篇文章, 其实也没仔细看… 不禁让我开始思考 Node.js 是如何与 ES Modules 进行的交互? 于是阅读了 Node.js 这部分实现的代码记录一下

举个例子, Node.js 一开始都只是支持 CommonJs 规范, 它能方便的管理各个模块, 其实现原理和 webpack 管理模块是类似的, 以我们熟悉的 webpack 产物来说

我们的源码

1
2
3
// src/index.js

export const test = '1';

打包后的代码

1
2
3
4
5
6
7
8
// dist/main.js
{

"./src/index.js":
(function(__webpack_module__, __webpack_exports__, __webpack_require__) {
__webpack_exports__.test = '1';
})
}

而 webpack 的运行时通过如下封装传入 module.exports 变量然后执行结束就能轻松拿到 src/index.js 模块的导出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// dist/runtime.js

var installedModules = {};
function __webpack_require__(moduleId) {
if(installedModules[moduleId]) {
return installedModules[moduleId].exports;
}

var module = installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {}
};
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

module.l = true;
return module.exports;
}

Node.js 几乎也是这样, 也是把用户的代码包裹在一个闭包函数中

1
2
3
4
5
6
// lib/internal/modules/cjs/loader.js

const wrapper = [
'(function (exports, require, module, __filename, __dirname) { ',
'\n});',
];

而 ES Modules 的 import 和 export 只能存在顶级作用域, 故上述 CommonJs 的实现就行不通了。因为 v8 原生支持 import 与 export, 模块的控制权就由 Node.js 转交到了 v8 的手上

问题1: 模块寻址问题

Node.js 源码实现的 require 函数会去依次从核心模块(例如 fs、http)、文件模块、node_modules 等寻找确认模块路径, 那怎么保证 v8 实现的 import 能共用同一套寻址逻辑?

通过阅读 Node.js 代码发现其实是借助了 v8 的 SetHostInitializeImportMetaObjectCallback 函数来修改 import 的元数据, 包括寻址路径等

Node.js 首先在如下文件调用了 SetHostInitializeImportMetaObjectCallback 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// src/module_wrap.cc

void ModuleWrap::SetInitializeImportMetaObjectCallback(
const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);
Isolate* isolate = env->isolate();

CHECK_EQ(args.Length(), 1);
CHECK(args[0]->IsFunction());
Local<Function> import_meta_callback = args[0].As<Function>();
env->set_host_initialize_import_meta_object_callback(import_meta_callback);

isolate->SetHostInitializeImportMetaObjectCallback(
HostInitializeImportMetaObjectCallback);
}

其真实的回调是 initializeImportMeta 函数, 这样 v8 实现的 import 也能复用 require 函数的寻址逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// lib/internal/modules/esm/initialize_import_meta.js

function initializeImportMeta(meta, context, loader) {
const { url } = context;

// Alphabetical
if (StringPrototypeStartsWith(url, 'file:') === true) {
// These only make sense for locally loaded modules,
// i.e. network modules are not supported.
const filePath = fileURLToPath(url);
meta.dirname = dirname(filePath);
meta.filename = filePath;
}

if (!loader || loader.allowImportMetaResolve) {
meta.resolve = createImportMetaResolve(url, loader, experimentalImportMetaResolve);
}

meta.url = url;

return meta;
}

问题2: 动态 import 函数

Node.js 实现了全局的 require 函数, 并未实现动态 import 函数。动态 import 函数和静态 import 都是 v8 原生实现, 那如何感知和修改这部分逻辑了?

这里的 Node.js 的实现和上面说的类似, 其实是借助的 v8 SetHostImportModuleDynamicallyCallback 来修改了 import 函数的执行逻辑, 相当于用 JS 自定义了 onImport 勾子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// src/module_wrap.cc

void ModuleWrap::SetImportModuleDynamicallyCallback(
const FunctionCallbackInfo<Value>& args) {
Isolate* isolate = args.GetIsolate();
Realm* realm = Realm::GetCurrent(args);
HandleScope handle_scope(isolate);

CHECK_EQ(args.Length(), 1);
CHECK(args[0]->IsFunction());
Local<Function> import_callback = args[0].As<Function>();
realm->set_host_import_module_dynamically_callback(import_callback);

isolate->SetHostImportModuleDynamicallyCallback(ImportModuleDynamically);
}
1
2
3
4
5
6
7
// lib/internal/modules/esm/utils.js

function defaultImportModuleDynamically(specifier, attributes, referrerName) {
const parentURL = normalizeReferrerURL(referrerName);
const cascadedLoader = require('internal/modules/esm/loader').getOrInitializeCascadedLoader();
return cascadedLoader.import(specifier, parentURL, attributes);
}

问题3: 如何获取 ES Module 模块导出

当使用 ES Modules 时是直接借助 v8 的 GetModuleNamespace 函数调用获取到模块的导出 export, 所以 Node.js 是从自己实现的 require 函数获取模块信息转为了从 v8 api 中获取模块信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/module_wrap.cc

void ModuleWrap::GetNamespaceSync(const FunctionCallbackInfo<Value>& args) {
Realm* realm = Realm::GetCurrent(args);
Isolate* isolate = args.GetIsolate();
ModuleWrap* obj;
ASSIGN_OR_RETURN_UNWRAP(&obj, args.This());
Local<Module> module = obj->module_.Get(isolate);

// ...

Local<Value> result = module->GetModuleNamespace();
args.GetReturnValue().Set(result);
}

问题4: 如何 import 内置的 CommonJs 模块

如果是正常的文件模块, 当 import 获取到正确的寻址路径后 v8 倒是能能够读取文件内容然后编译运行, 如果是核心模块比如 fs 被打包进 Node.js 的二进制文件中( 详细可见: lib 模块运行), 并且 fs 代码还是 CommonJs 的实现这又如何兼容了?

1
2
3
4
5
6
7
8
9
10
11
// lib/fs.js

module.exports = fs = {
appendFile,
appendFileSync,
access,
accessSync,
chown,
chownSync,
...
}

比如当我们运行 node test.js

1
2
3
4
5
// test.js

import fs from "fs"

consolelog(fs);

Node.js 的实现是首先通过 v8 的 GetModuleRequests 函数获取到入口模块 test.js 所有的 import 信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src/module_wrap.cc

void ModuleWrap::Link(const FunctionCallbackInfo<Value>& args) {
// ...

Local<Function> resolver_arg = args[0].As<Function>();

Local<Context> mod_context = obj->context();
Local<Module> module = obj->module_.Get(isolate);

Local<FixedArray> module_requests = module->GetModuleRequests();
const int module_requests_length = module_requests->Length();
MaybeStackBuffer<Local<Value>, 16> promises(module_requests_length);

// ...
}

然后根据 import 信息提前构造出 v8 Module

1
2
3
4
5
6
7
// lib/internal/modules/esm/module_job.js

const promises = this.module.link(async (specifier, attributes) => {
const job = await this.loader.getModuleJob(specifier, url, attributes);
ArrayPrototypePush(dependencyJobs, job);
return job.modulePromise;
});

对于被 import 的 fs 由于不存在文件系统中又是 CommonJs 实现, 其实是借助的 v8 CreateSyntheticModule 函数构造了一个 Module, 而 CreateSyntheticModule 构造函数需要传入导出, 则是通过 Object.keys(require('fs')) 获得, 这样我们基于一个 CommonJs 模块构造出了一个 ES Module

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// src/module_wrap.cc

if (synthetic) {
CHECK(args[2]->IsArray());
Local<Array> export_names_arr = args[2].As<Array>();

uint32_t len = export_names_arr->Length();
std::vector<Local<String>> export_names(len);
for (uint32_t i = 0; i < len; i++) {
Local<Value> export_name_val =
export_names_arr->Get(context, i).ToLocalChecked();
CHECK(export_name_val->IsString());
export_names[i] = export_name_val.As<String>();
}

const MemorySpan<const Local<String>> span(export_names.begin(),
export_names.size());
module = Module::CreateSyntheticModule(
isolate, url, span, SyntheticModuleEvaluationStepsCallback);
}

当调用 module->InstantiateModule(context, ResolveModuleCallback)) 实例化入口模块 test.js 时, v8 的 InstantiateModule 的第二个参数 ResolveModuleCallback 允许拦截修改模块的依赖。比如 fs 是 test.js 的依赖模块, 通过 ResolveModuleCallback 函数就可以修改 v8 默认的 fs Module 的构造逻辑, 篡改返回值为上一步提前为 fs 构造出的 CreateSyntheticModule 即可成功偷梁换柱

小结

当上面几个问题确认后, 才明白了 Node.js 原来是这样支持的 ES Modules。概括一句话是 v8 提供了各种回调函数用于允许 Node.js 修改默认的行为