概述
在 Node.js 开发中,我们经常会遇到需要自定义模块解析的场景。例如我想不提前编译就直接在 node 环境运行 ts 代码,或者我在开发中用到了很多的别名,但是调试的时候我又想脱离构建工具直接运行。
本文将深入探讨如何使用 node:module
的钩子系统来构建自己的模块解析器,包括基础概念、实现方法和实际应用场景。
模块解析器基础概念
什么是模块解析器?
模块解析器是负责将模块标识符(如 'fs'
、'./utils'
、'@components/Button'
)转换为实际文件路径的组件。Node.js 的模块解析过程包括:
- 路径解析: 确定模块的绝对路径
- 格式检测: 判断模块是 CommonJS 还是 ES 模块
- 加载执行: 读取文件内容并执行
- 缓存管理: 避免重复加载
Node.js 默认解析策略
javascript
// Node.js 默认的模块解析顺序
const resolveOrder = [
"内置模块 (fs, path, etc.)",
"相对路径 (./utils, ../config)",
"绝对路径 (/home/user/app)",
"node_modules 查找",
"package.json main 字段",
"index.js 文件",
];
如何通过钩子修改默认解析策略
什么是钩子(Hooks)?
钩子是 Node.js 中为了方便开发者自定义 Node.js 的内置模块解析能力而新增的方法。
根据 Node.js 文档,模块解析和加载可以通过以下方式进行自定义:
- 使用 node:module 中的 register 方法注册一个导出一组异步钩子函数的文件。
- 使用 node:module 中的 registerHooks 方法注册一组同步钩子函数。
其中,register 方法主要用添加异步钩子,用来做 es module 相关模块的自定义解析,相关的解析也会跑在一个独立的进程上。
registerHooks 则是用来定义传统 commonjs 的解析,是直接跑在 Node.js 的主进程上。
同步钩子注册 (registerHooks)
同步的钩子比较简单,我们可以直接调用 registerHooks 方法传入对应的 load 和 resolve 方法,其中,load 是用来定义模块的加载,revolve 是用来定义模块的查询。
我们看下简单的代码示例:
javascript
const { registerHooks } = require("node:module");
// 注册同步钩子
registerHooks({
resolve: (specifier, context, nextResolve) => {
return nextResolve(specifier, context);
},
load: (url, context, nextLoad) => {
const result = nextLoad(url, context);
result.source =
`console.log('${path.basename(url)}' + ' module loaded')\n` +
result.source;
return result;
},
});
上述示例中,我们就通过 load 方法将所有加载的 js 代码最前面添加了一行module loaded
的输出。
添加了上述的 hooks 之后,此时我们再去加载一个本地的 js 模块,例如:test-hooks.js
,运行代码之后,我们就能看到:
bash
test-hooks.js module loaded
test-hooks
异步钩子注册 (register)
异步钩子,相对同步钩子会更复杂一下。
同步钩子直接定义一个钩子对象传入,异步钩子则需要传入一个定义了钩子方法的 js 文件。
例如:
javascript
const { register } = require("node:module");
// 注册异步钩子(ES 模块)
register("./test-async-hooks.js");
在test-async-hooks.js
中我们需要实现具体的 resolve、load 等方法。
javascript
import path from "path";
export async function resolve(specifier, context, nextResolve) {
// 自定义解析逻辑
return nextResolve(specifier, context);
}
export async function load(url, context, nextLoad) {
// 自定义加载逻辑
const result = await nextLoad(url, context);
result.source =
`console.log('${path.basename(url)}' + ' module loaded async')\n` +
result.source;
return result;
}
我们同样的通过load
方法在源码中添加了module loaded async
输出,执行之后:
javascript
test-hooks.js module loaded async
构建自定义模块解析器
了解了如何通过钩子来修改 Node.js 的默认加载行为之后,我们就可以通过注册相关的钩子来实现一个自己的 Node.js 模块解析器了。
1. 实现一个简单的模块解析器
让我们从构建一个基础的模块解析器开始:
javascript
// resolver.js
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
class ModuleResolver {
constructor() {
this.cache = new Map();
this.aliases = new Map();
this.extensions = [".js", ".ts", ".json", ".node"];
}
// 注册路径别名
registerAlias(alias, target) {
this.aliases.set(alias, target);
}
transform(url) {
if (url.endsWith(".ts")) {
// 编译ts
return compileTS(url);
}
return null;
}
// 解析模块路径
resolve(specifier, parentURL) {
const cacheKey = `${specifier}:${parentURL}`;
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey);
}
let resolvedPath = null;
// 1. 处理别名
for (const [alias, target] of this.aliases) {
if (specifier.startsWith(alias)) {
specifier = specifier.replace(alias, target);
break;
}
}
// 2. 处理相对路径
if (specifier.startsWith("./") || specifier.startsWith("../")) {
resolvedPath = resolve(dirname(parentURL), specifier);
} else if (specifier.startsWith("/")) {
resolvedPath = specifier;
} else {
// 3. 处理 node_modules
resolvedPath = this.resolveNodeModules(specifier, parentURL);
}
// 4. 尝试不同扩展名
if (resolvedPath && !this.hasExtension(resolvedPath)) {
resolvedPath = this.tryExtensions(resolvedPath);
}
this.cache.set(cacheKey, resolvedPath);
return resolvedPath;
}
// 在 node_modules 中查找模块
resolveNodeModules(specifier, parentURL) {
let currentDir = dirname(parentURL);
while (currentDir !== dirname(currentDir)) {
const nodeModulesPath = resolve(currentDir, "node_modules", specifier);
if (this.exists(nodeModulesPath)) {
return this.resolvePackageMain(nodeModulesPath);
}
currentDir = dirname(currentDir);
}
return null;
}
// 解析 package.json 的 main 字段
resolvePackageMain(packagePath) {
const packageJsonPath = resolve(packagePath, "package.json");
if (this.exists(packageJsonPath)) {
try {
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
const mainFile = packageJson.main || "index.js";
return resolve(packagePath, mainFile);
} catch (error) {
// 忽略 JSON 解析错误
}
}
// 尝试 index.js
const indexPath = resolve(packagePath, "index.js");
if (this.exists(indexPath)) {
return indexPath;
}
return packagePath;
}
// 尝试不同扩展名
tryExtensions(path) {
for (const ext of this.extensions) {
const fullPath = path + ext;
if (this.exists(fullPath)) {
return fullPath;
}
}
return path;
}
// 检查文件是否存在
exists(path) {
try {
return require("node:fs").existsSync(path);
} catch {
return false;
}
}
// 检查路径是否有扩展名
hasExtension(path) {
return path.includes(".") && !path.endsWith("/");
}
}
export default BasicModuleResolver;
2. 集成到 Node.js 钩子系统
现在让我们将自定义解析器集成到 Node.js 的钩子系统中:
javascript
const ModuleResolver = require("./resolver");
const { registerHooks } = require("node:module");
const resolver = new ModuleResolver();
// 设置别名
resolver.registerAlias("@", "/src");
resolver.registerAlias("@utils", "/src/utils");
resolver.registerAlias("@components", "/src/components");
registerHooks({
resolve(specifier, parentURL) {
return resolver.resolve(specifier, parentURL);
},
load(specifier, context, nextLoad) {
const source = resolver.transform(specifier);
return source
? { source, format: "commonjs" }
: nextLoad(specifier, context);
},
});
这样我们就可以实现了 ts 的编译和自定义模块的查找功能。
兼容性问题
Node.js 的模块自定义解析钩子功能虽然很强大,不过很遗憾的,我们需要至少在 22.15 版本以上的 Node.js 中才能使用,否则会有一系列的兼容性问题。
当然,这种情况我们依然可以通过使用 Node.js 老版本的 api 去修改模块的自定义解析。这部分我们后续有机会再继续展开。
总结
构建自定义的 Node.js 模块解析器是一个强大的工具,可以帮助我们:
- 理解模块系统: 深入理解 Node.js 模块系统的工作原理
- 扩展功能: 添加自定义的解析逻辑和转换功能
- 支持工具: 为构建工具、测试框架等提供强大的扩展能力
通过合理使用 node:module
的钩子系统,我们可以构建出功能强大的自定义模块解析器,为 Node.js 应用程序提供更好的开发体验。