原创不易,但愿能关注下咱们,再顺手点个赞~~ |
本文首发于政采云前端团队博客: Node.js 模块系统源码探微前端
Node.js 的出现使得前端工程师能够跨端工做在服务器上,固然,一个新的运行环境的诞生亦会带来新的模块、功能、抑或是思想上的革新,本文将带领读者领略 Node.js (如下简称 Node) 的模块设计思想以及剖析部分核心源码实现。node
Node 最初遵循 CommonJS 规范来实现本身的模块系统,同时作了一部分区别于规范的定制。CommonJS 规范是为了解决 JavaScript 的做用域问题而定义的模块形式,它可使每一个模块在它自身的命名空间中执行。json
该规范强调模块必须经过 module.exports
导出对外的变量或函数,经过 require()
来导入其余模块的输出到当前模块做用域中,同时,遵循如下约定:数组
require(module.id)
会从源出 module.id
的那个模块返回 exports 对象(就是说 module.id 能够被传递到另外一个模块,并且在要求它时必须返回最初的模块)。function Module(id = "", parent) {
// 模块 id,一般为模块的绝对路径
this.id = id;
this.path = path.dirname(id);
this.exports = {};
// 当前模块调用者
this.parent = parent;
updateChildren(parent, this, false);
this.filename = null;
// 模块是否加载完成
this.loaded = false;
// 当前模块所引用的模块
this.children = [];
}
复制代码
在 Node 中,可以使用 module.exports 对象总体导出一个变量或者函数,也可将须要导出的变量或函数挂载到 exports 对象的属性上,代码以下所示:缓存
// 1. 使用 exports: 笔者习惯一般用做对工具库函数或常量的导出
exports.name = 'xiaoxiang';
exports.add = (a, b) => a + b;
// 2. 使用 module.exports:导出一整个对象或者单一函数
...
module.exports = {
add,
minus
}
复制代码
经过全局 require 函数引用模块,可传入模块名称、相对路径或者绝对路径,当模块文件后缀为 js / json / node 时,可省略后缀,以下代码所示:bash
// 引用模块
const { add, minus } = require('./module');
const a = require('/usr/app/module');
const http = require('http');
复制代码
注意事项:服务器
exports
变量是在模块的文件级做用域内可用的,且在模块执行以前赋值给 module.exports
。exports.name = 'test';
console.log(module.exports.name); // test
module.export.name = 'test';
console.log(exports.name); // test
复制代码
exports
赋予了新值,则它将再也不绑定到 module.exports
,反之亦然:exports = { name: 'test' };
console.log(module.exports.name, exports.name); // undefined, test
复制代码
module.exports
属性被新对象彻底替换时,一般也须要从新赋值 exports
:module.exports = exports = { name: 'test' };
console.log(module.exports.name, exports.name) // test, test
复制代码
如下是 require
函数的代码实现:前端工程师
// require 入口函数
Module.prototype.require = function(id) {
//...
requireDepth++;
try {
return Module._load(id, this, /* isMain */ false); // 加载模块
} finally {
requireDepth--;
}
};
复制代码
上述代码接收给定的模块路径,其中的 requireDepth 用来记载模块加载的深度。其中 Module 的类方法 _load
实现了 Node 加载模块的主要逻辑,下面咱们来解析 Module._load
函数的源码实现,为了方便你们理解,我把注释加在了文中。app
Module._load = function(request, parent, isMain) {
// 步骤一:解析出模块的全路径
const filename = Module._resolveFilename(request, parent, isMain);
// 步骤二:加载模块,具体分三种状况处理
// 状况一:存在缓存的模块,直接返回模块的 exports 属性
const cachedModule = Module._cache[filename];
if (cachedModule !== undefined)
return cachedModule.exports;
// 状况二:加载内建模块
const mod = loadNativeModule(filename, request);
if (mod && mod.canBeRequiredByUsers) return mod.exports;
// 状况三:构建模块加载
const module = new Module(filename, parent);
// 加载过以后就进行模块实例缓存
Module._cache[filename] = module;
// 步骤三:加载模块文件
module.load(filename);
// 步骤四:返回导出对象
return module.exports;
};
复制代码
上面的代码信息量比较大,咱们主要看如下几个问题:async
_load
加载函数针对三种状况给出了不一样的加载策略,分别是:Module._resolveFilename(request, parent, isMain) 是怎么解析出文件名称的?
咱们看以下定义的类方法:
Module._resolveFilename = function(request, parent, isMain, options) {
if (NativeModule.canBeRequiredByUsers(request)) {
// 优先加载内建模块
return request;
}
let paths;
// node require.resolve 函数使用的 options,options.paths 用于指定查找路径
if (typeof options === "object" && options !== null) {
if (ArrayIsArray(options.paths)) {
const isRelative =
request.startsWith("./") ||
request.startsWith("../") ||
(isWindows && request.startsWith(".\\")) ||
request.startsWith("..\\");
if (isRelative) {
paths = options.paths;
} else {
const fakeParent = new Module("", null);
paths = [];
for (let i = 0; i < options.paths.length; i++) {
const path = options.paths[i];
fakeParent.paths = Module._nodeModulePaths(path);
const lookupPaths = Module._resolveLookupPaths(request, fakeParent);
for (let j = 0; j < lookupPaths.length; j++) {
if (!paths.includes(lookupPaths[j])) paths.push(lookupPaths[j]);
}
}
}
} else if (options.paths === undefined) {
paths = Module._resolveLookupPaths(request, parent);
} else {
//...
}
} else {
// 查找模块存在路径
paths = Module._resolveLookupPaths(request, parent);
}
// 依据给出的模块和遍历地址数组,以及是否为入口模块来查找模块路径
const filename = Module._findPath(request, paths, isMain);
if (!filename) {
const requireStack = [];
for (let cursor = parent; cursor; cursor = cursor.parent) {
requireStack.push(cursor.filename || cursor.id);
}
// 未找到模块,抛出异常(是否是很熟悉的错误)
let message = `Cannot find module '${request}'`;
if (requireStack.length > 0) {
message = message + "\nRequire stack:\n- " + requireStack.join("\n- ");
}
const err = new Error(message);
err.code = "MODULE_NOT_FOUND";
err.requireStack = requireStack;
throw err;
}
// 最终返回包含文件名的完整路径
return filename;
};
复制代码
上面的代码中比较突出的是使用了 _resolveLookupPaths
和 _findPath
两个方法。
_findPath
使用的遍历范围数组。// 模块文件寻址的地址数组方法
Module._resolveLookupPaths = function(request, parent) {
if (NativeModule.canBeRequiredByUsers(request)) {
debug("looking for %j in []", request);
return null;
}
// 若是不是相对路径
if (
request.charAt(0) !== "." ||
(request.length > 1 &&
request.charAt(1) !== "." &&
request.charAt(1) !== "/" &&
(!isWindows || request.charAt(1) !== "\\"))
) {
/**
* 检查 node_modules 文件夹
* modulePaths 为用户目录,node_path 环境变量指定目录、全局 node 安装目录
*/
let paths = modulePaths;
if (parent != null && parent.paths && parent.paths.length) {
// 父模块的 modulePath 也要加到子模块的 modulePath 里面,往上回溯查找
paths = parent.paths.concat(paths);
}
return paths.length > 0 ? paths : null;
}
// 使用 repl 交互时,依次查找 ./ ./node_modules 以及 modulePaths
if (!parent || !parent.id || !parent.filename) {
const mainPaths = ["."].concat(Module._nodeModulePaths("."), modulePaths);
return mainPaths;
}
// 若是是相对路径引入,则将父级文件夹路径加入查找路径
const parentDir = [path.dirname(parent.filename)];
return parentDir;
};
复制代码
// 依据给出的模块和遍历地址数组,以及是否顶层模块来寻找模块真实路径
Module._findPath = function(request, paths, isMain) {
const absoluteRequest = path.isAbsolute(request);
if (absoluteRequest) {
// 绝对路径,直接定位到具体模块
paths = [""];
} else if (!paths || paths.length === 0) {
return false;
}
const cacheKey =
request + "\x00" + (paths.length === 1 ? paths[0] : paths.join("\x00"));
// 缓存路径
const entry = Module._pathCache[cacheKey];
if (entry) return entry;
let exts;
let trailingSlash =
request.length > 0 &&
request.charCodeAt(request.length - 1) === CHAR_FORWARD_SLASH; // '/'
if (!trailingSlash) {
trailingSlash = /(?:^|\/)\.?\.$/.test(request);
}
// For each path
for (let i = 0; i < paths.length; i++) {
const curPath = paths[i];
if (curPath && stat(curPath) < 1) continue;
const basePath = resolveExports(curPath, request, absoluteRequest);
let filename;
const rc = stat(basePath);
if (!trailingSlash) {
if (rc === 0) { // stat 状态返回 0,则为文件
// File.
if (!isMain) {
if (preserveSymlinks) {
// 当解析和缓存模块时,命令模块加载器保持符号链接。
filename = path.resolve(basePath);
} else {
// 不保持符号连接
filename = toRealPath(basePath);
}
} else if (preserveSymlinksMain) {
filename = path.resolve(basePath);
} else {
filename = toRealPath(basePath);
}
}
if (!filename) {
if (exts === undefined) exts = ObjectKeys(Module._extensions);
// 解析后缀名
filename = tryExtensions(basePath, exts, isMain);
}
}
if (!filename && rc === 1) {
/**
* stat 状态返回 1 且文件名不存在,则认为是文件夹
* 若是文件后缀不存在,则尝试加载该目录下的 package.json 中 main 入口指定的文件
* 若是不存在,而后尝试 index[.js, .node, .json] 文件
*/
if (exts === undefined) exts = ObjectKeys(Module._extensions);
filename = tryPackage(basePath, exts, isMain, request);
}
if (filename) { // 若是存在该文件,将文件名则加入缓存
Module._pathCache[cacheKey] = filename;
return filename;
}
}
const selfFilename = trySelf(paths, exts, isMain, trailingSlash, request);
if (selfFilename) {
// 设置路径的缓存
Module._pathCache[cacheKey] = selfFilename;
return selfFilename;
}
return false;
};
复制代码
阅读完上面的代码,咱们发现,当遇到模块是一个文件夹的时候会执行 tryPackage
函数的逻辑,下面简要分析一下具体实现。
// 尝试加载标准模块
function tryPackage(requestPath, exts, isMain, originalPath) {
const pkg = readPackageMain(requestPath);
if (!pkg) {
// 若是没有 package.json 这直接使用 index 做为默认入口文件
return tryExtensions(path.resolve(requestPath, "index"), exts, isMain);
}
const filename = path.resolve(requestPath, pkg);
let actual =
tryFile(filename, isMain) ||
tryExtensions(filename, exts, isMain) ||
tryExtensions(path.resolve(filename, "index"), exts, isMain);
//...
return actual;
}
// 读取 package.json 中的 main 字段
function readPackageMain(requestPath) {
const pkg = readPackage(requestPath);
return pkg ? pkg.main : undefined;
}
复制代码
readPackage 函数负责读取和解析 package.json 文件中的内容,具体描述以下:
function readPackage(requestPath) {
const jsonPath = path.resolve(requestPath, "package.json");
const existing = packageJsonCache.get(jsonPath);
if (existing !== undefined) return existing;
// 调用 libuv uv_fs_open 的执行逻辑,读取 package.json 文件,而且缓存
const json = internalModuleReadJSON(path.toNamespacedPath(jsonPath));
if (json === undefined) {
// 接着缓存文件
packageJsonCache.set(jsonPath, false);
return false;
}
//...
try {
const parsed = JSONParse(json);
const filtered = {
name: parsed.name,
main: parsed.main,
exports: parsed.exports,
type: parsed.type
};
packageJsonCache.set(jsonPath, filtered);
return filtered;
} catch (e) {
//...
}
}
复制代码
上面的两段代码完美地解释 package.json 文件的做用,模块的配置入口( package.json 中的 main 字段)以及模块的默认文件为何是 index,具体流程以下图所示:
定位到对应模块以后,该如何加载和解析呢?如下是具体代码分析:
Module.prototype.load = function(filename) {
// 保证模块没有加载过
assert(!this.loaded);
this.filename = filename;
// 找到当前文件夹的 node_modules
this.paths = Module._nodeModulePaths(path.dirname(filename));
const extension = findLongestRegisteredExtension(filename);
//...
// 执行特定文件后缀名解析函数 如 js / json / node
Module._extensions[extension](this, filename);
// 表示该模块加载成功
this.loaded = true;
// ... 省略 esm 模块的支持
};
复制代码
能够看出,针对不一样的文件后缀,Node.js 的加载方式是不一样的,一下针对 .js, .json, .node
简单进行分析。
fs.readFileSync
实现。Module._extensions[".js"] = function(module, filename) {
// 读取文件内容
const content = fs.readFileSync(filename, "utf8");
// 编译执行代码
module._compile(content, filename);
};
复制代码
JSONParse
便可拿到结果。Module._extensions[".json"] = function(module, filename) {
// 直接按照 utf-8 格式加载文件
const content = fs.readFileSync(filename, "utf8");
//...
try {
// 以 JSON 对象格式导出文件内容
module.exports = JSONParse(stripBOM(content));
} catch (err) {
//...
}
};
复制代码
Module._extensions[".node"] = function(module, filename) {
//...
return process.dlopen(module, path.toNamespacedPath(filename));
};
复制代码
从上面的三段源码,咱们看出来而且能够理解,只有 JS 后缀最后会执行实例方法_compile
,咱们去除一些实验特性和调试相关的逻辑来简要的分析一下这段代码。
模块加载完成后,Node 使用 V8 引擎提供的方法构建运行沙箱,并执行函数代码,代码以下所示:
Module.prototype._compile = function(content, filename) {
let moduleURL;
let redirects;
// 向模块内部注入公共变量 __dirname / __filename / module / exports / require,而且编译函数
const compiledWrapper = wrapSafe(filename, content, this);
const dirname = path.dirname(filename);
const require = makeRequireFunction(this, redirects);
let result;
const exports = this.exports;
const thisValue = exports;
const module = this;
if (requireDepth === 0) statCache = new Map();
//...
// 执行模块中的函数
result = compiledWrapper.call(
thisValue,
exports,
require,
module,
filename,
dirname
);
hasLoadedAnyUserCJSModule = true;
if (requireDepth === 0) statCache = null;
return result;
};
// 注入变量的核心逻辑
function wrapSafe(filename, content, cjsModuleInstance) {
if (patched) {
const wrapper = Module.wrap(content);
// vm 沙箱运行 ,直接返回运行结果,env -> SetProtoMethod(script_tmpl, "runInThisContext", RunInThisContext);
return vm.runInThisContext(wrapper, {
filename,
lineOffset: 0,
displayErrors: true,
// 动态加载
importModuleDynamically: async specifier => {
const loader = asyncESM.ESMLoader;
return loader.import(specifier, normalizeReferrerURL(filename));
}
});
}
let compiled;
try {
compiled = compileFunction(
content,
filename,
0,
0,
undefined,
false,
undefined,
[],
["exports", "require", "module", "__filename", "__dirname"]
);
} catch (err) {
//...
}
const { callbackMap } = internalBinding("module_wrap");
callbackMap.set(compiled.cacheKey, {
importModuleDynamically: async specifier => {
const loader = asyncESM.ESMLoader;
return loader.import(specifier, normalizeReferrerURL(filename));
}
});
return compiled.function;
}
复制代码
上述代码中,咱们能够看到在_compile
函数中调用了 wrapwrapSafe
函数,执行了 __dirname / __filename / module / exports / require
公共变量的注入,而且调用了 C++ 的 runInThisContext 方法(位于 src/node_contextify.cc 文件)构建了模块代码运行的沙箱环境,并返回了 compiledWrapper
对象,最终经过 compiledWrapper.call
方法运行模块。
至此,Node.js 的模块系统分析告一段落,Node.js 世界的精彩和绝妙无穷无尽,学习的路上和诸君共勉。
政采云前端团队(ZooTeam),一个年轻富有激情和创造力的前端团队,隶属于政采云产品研发部,Base 在风景如画的杭州。团队现有 50 余个前端小伙伴,平均年龄 27 岁,近 3 成是全栈工程师,妥妥的青年风暴团。成员构成既有来自于阿里、网易的“老”兵,也有浙大、中科大、杭电等校的应届新人。团队在平常的业务对接以外,还在物料体系、工程平台、搭建平台、性能体验、云端应用、数据分析及可视化等方向进行技术探索和实战,推进并落地了一系列的内部技术产品,持续探索前端技术体系的新边界。
若是你想改变一直被事折腾,但愿开始能折腾事;若是你想改变一直被告诫须要多些想法,却无从破局;若是你想改变你有能力去作成那个结果,却不须要你;若是你想改变你想作成的事须要一个团队去支撑,但没你带人的位置;若是你想改变既定的节奏,将会是“ 5 年工做时间 3 年工做经验”;若是你想改变原本悟性不错,但老是有那一层窗户纸的模糊… 若是你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的本身。若是你但愿参与到随着业务腾飞的过程,亲手推进一个有着深刻的业务理解、完善的技术体系、技术创造价值、影响力外溢的前端团队的成长历程,我以为咱们该聊聊。任什么时候间,等着你写点什么,发给 ZooTeam@cai-inc.com