在 ES2015 标准之前,JavaScript 语言没有原生的组织代码的方式。Node.js 用 CommonJS 模块规范填补了这个空白。我想通过这篇文章和大家分享一下当下的 CommonJS 模块系统的一些机制和细节。
在写这篇文章的时阅读代码 Node.js 版本是 v10.0.0
全文共由三个部分组成:
- 什么是模块系统
- require() 的流程,包括模块加载、编译、执行
- 模块系统的一些细节,包括缓存机制、路径解析
- 全文内容小结
1.什么是模块系统
模块是代码结构的基本组成部分。通过模块系统,我们可以用模块化的方式来组织应用代码。模块可以通过 module.exports
自由地隐藏内部实现、对外暴露接口 。我们只需要通过 require
,就能实现模块加载引入。
当下我们在 Node.js 中最常使用的到的模块系统 commonjs
,是 JavaScript
// add.js
function add (a, b) {
return a + b
}
module.exports = {
add
}
复制代码
当我们需要在其他地方使用 add
方法时,比如:
// app.js
const { add } = require('./add')
console.log(add(1, 2))
// 3
复制代码
我们只需要调用 require('./add')
就能实现对模块的引入。
1.1 从一个错误场景开始看 require()
可能很多同学在开始学 Node.js 的时候,都曾经遇到这样的问题:
Cannot find module '../../add.js'
找不到模块的错误。
loader.js:573 Uncaught Error: Cannot find module '../../add.js'
at Function.Module._resolveFilename (internal/modules/cjs/loader.js:571:15)
at Function.Module._load (internal/modules/cjs/loader.js:497:25)
at Module.require (internal/modules/cjs/loader.js:626:17)
at require (internal/modules/cjs/helpers.js:20:18)
at <anonymous>:1:1
复制代码
一般找不到模块的原因:
- 路径不正确
- 忘记从 npm 安装依赖包
我们可以在这异常错误堆栈看到,当我们用 require()
时,它内部调用了那些方法。
- helpers.js:
require
- loader.js:
Module.require
- loader.js:
Module._load
- loader.js:
Module._resolveFilename
我们可以通过这里的调用堆栈一步步探索 require()
的内部机制
2 require()
的流程
为了使之后代码细节显得不那么TLDR,先通过这个图,对整体流程有一定印象:
虽然在 Node.js 8 之后开始做 Node.js 支持 ES Module 的工作,CommonJs 模块系统的实现还是经过了不小的变化。但是总体流程仍然是和之前几乎一致。
- 路径解析
- 文件加载
- 模块封装
- 编译执行
- 缓存
2.1 require 是从哪来的
我们先到 helpers.js 看看 require()
是怎么来的:
var require = makeRequireFunction(this);
var result = compiledWrapper.call(this.exports, this.exports, require, this, filename, dirname);
复制代码
通过上面 的代码看到,require
是调用 makeRequireFunction(module)
来生成的。
代码中的 this
是一个模块实例(module),至于模块实例是怎么生成的我们之后再细看。
require
和 exports
, filename
, dirname
等作为参数, 执行了 完成编译之后模块代码的包装函数 compiledWrapper
,这里就是我们的模块由文件字符到 JS 代码被执行的阶段。
模块代码通过 Module.wrap
包装,之后调用 vm.runInThisContext
执行得到了模块包装函数。
Module.wrapper = [
'(function (exports, require, module, __filename, __dirname) { ',
'\n});'
];
Module.wrap = function(script) {
return Module.wrapper[0] + script + Module.wrapper[1];
};
复制代码
这里就是在模块代码中常用到的 module
, exports
, require
,__dirname
, __filename
的来源。
经过 Module.wrap()
包装之后的代码使用 vm.runInThisContext()
在当前上下文中编译执行。
// content 来自模块的代码
var wrapper = Module.wrap(content);
var compiledWrapper = vm.runInThisContext(wrapper, {
filename: filename,
lineOffset: 0,
displayErrors: true
});
复制代码
完成上面的铺垫,回归主线,开始解析 makeRequireFunction
做了什么。
2.2 makeRequireFunction
的实现
// internal/modules/cjs/helpers.js:20
// 调用 makeRequireFunction(module) 这里的 | module | 是 Module对象
// 生成当前模块上下文的 require()
function makeRequireFunction(mod) {
const Module = mod.constructor;
// 创建一个模块相关的 require
// 依赖深度机制实现十分巧妙
function require(path) {
try {
exports.requireDepth += 1;
return mod.require(path);
} finally {
exports.requireDepth -= 1;
}
}
// resolve 是对 Module._resolveFilename 的封装
function resolve(request, options) {
if (typeof request !== 'string') {
throw new ERR_INVALID_ARG_TYPE('request', 'string', request);
}
return Module._resolveFilename(request, mod, false, options);
}
require.resolve = resolve;
// resolve.paths 是对 Module._resolveLookupPaths 的封装
function paths(request) {
if (typeof request !== 'string') {
throw new ERR_INVALID_ARG_TYPE('request', 'string', request);
}
return Module._resolveLookupPaths(request, mod, true);
}
resolve.paths = paths;
// process.mainModule 入口模块
require.main = process.mainModule;
// 启用支持以添加额外的扩展类型
// 我们可以实现 require.extensions['.xxx']
// 来实现对自定义文件类型模块的支持
require.extensions = Module._extensions;
// 模块的缓存
// key: 模块路径
// value: 模块实例
require.cache = Module._cache;
return require;
}
复制代码
通过上面的代码我们完整了解了 makeRequireFunction
的实现,它主要实现了:
- 生成对应
module
上下文的require
方法,内部调用module
实例上 的require
方法 - 通过
require.resolve
添加类型校验,封装了Module._resolveFilename
- 通过
require.resolve.paths
封装了Module._resolveLookupPaths
- 在
require.main
添加入口模块process.mainModule
- 通过
require.extensions
暴露Module._extensions
,提供了扩展能力 - 在
require.cache
暴露了Module._cache
,我们可以通过require.cache
操作模块缓存。
2.3 require 来自于 module.require
// Loads a module at the given file path. Returns that module's
// `exports` property.
Module.prototype.require = function(id) {
// 参数校验
// 必须为 非空字符
if (typeof id !== 'string') {
throw new ERR_INVALID_ARG_TYPE('id', 'string', id);
}
if (id === '') {
throw new ERR_INVALID_ARG_VALUE('id', id,
'must be a non-empty string');
}
// 调用 Module._load
return Module._load(id, this, /* isMain */ false);
};
复制代码
可以看到 module.require
参数校验完成之后,就调用 Module._load
2.4 模块的加载之前的工作 Module._load
Module._load 的主要逻辑:
- 如果模块已经在缓存中存在,直接返回缓存中的模块
- 如果是是原生模块,直接调用
NativeModule.require()
- 其他情况,创建一个新的模块实例,加入缓存,调用模块实例的加载方法
Module._load = function(request, parent, isMain) {
if (parent) {
debug('Module._load REQUEST %s parent: %s', request, parent.id);
}
if (experimentalModules && isMain) {
asyncESM.loaderPromise.then((loader) => {
return loader.import(getURLFromFilePath(request).pathname);
})
.catch((e) => {
decorateErrorStack(e);
console.error(e);
process.exit(1);
});
return;
}
var filename = Module._resolveFilename(request, parent, isMain);
var cachedModule = Module._cache[filename];
if (cachedModule) {
updateChildren(parent, cachedModule, true);
return cachedModule.exports;
}
if (NativeModule.nonInternalExists(filename)) {
debug('load native module %s', request);
return NativeModule.require(filename);
}
// Don't call updateChildren(), Module constructor already does.
var module = new Module(filename, parent);
if (isMain) {
process.mainModule = module;
module.id = '.';
}
Module._cache[filename] = module;
tryModuleLoad(module, filename);
return module.exports;
};
复制代码
可以看到,上面代码中的 Module._cache[filename] = module
, 对还没有开始加载的模块就写入缓存可能是不安全的。
tryModuleLoad
这这里做了检测,如果模块加载失败,会清理模块的缓存。
function tryModuleLoad(module, filename) {
var threw = true;
try {
module.load(filename);
threw = false;
} finally {
if (threw) {
delete Module._cache[filename];
}
}
}
复制代码
2.5 模块需要真实加载
module.load
的主要逻辑:
- 写入
module.filename
- 生成当前模块的模块路径, 并缓存在
module.paths
- 根据
filename
文件后缀,选择不同的处理方式 - 如果启用了 ES Module, 调用
ESMLoader
加载模块
// 设置文件名,并选择相应的文件处理
// Given a file name, pass it to the proper extension handler.
Module.prototype.load = function(filename) {
debug('load %j for module %j', filename, this.id);
assert(!this.loaded);
this.filename = filename;
this.paths = Module._nodeModulePaths(path.dirname(filename));
var extension = path.extname(filename) || '.js';
if (!Module._extensions[extension]) extension = '.js';
Module._extensions[extension](this, filename);
this.loaded = true;
if (experimentalModules) {
// ES Module 相关逻辑,下篇文章分析
...
}
};
复制代码
同步读取文件,清除文件中的 BOM
编码字符,然后调用 module._compile
编译
// Native extension for .js
Module._extensions['.js'] = function(module, filename) {
var content = fs.readFileSync(filename, 'utf8');
module._compile(stripBOM(content), filename);
};
复制代码
2.6 模块的编译 module._compile
module._compile
的主要工作:
- 在当前的作用域或沙箱中运行文件内容,暴露当前模块上下文的的工具变量(require,module,exports)到模块文件中。
- 如果有的任何异常,返回异常
以下的代码是 module._compile
的部分代码,去掉了 调试相关和文件 stat 缓存的代码。
Module.prototype._compile = function(content, filename) {
// 去除 Shebang 比如:#!/bin/sh
content = stripShebang(content);
// create wrapper function
// 创建封装函数
var wrapper = Module.wrap(content);
// 在当前上下文编译模块的封装函数代码
// 传入当前模块的文件名,用于定义堆栈跟踪信息
// 如在解析代码的时候发生错误Error,引起错误的行将会被加入堆栈跟踪信息
var compiledWrapper = vm.runInThisContext(wrapper, {
filename: filename,
lineOffset: 0,
displayErrors: true
});
...
var dirname = path.dirname(filename);
var require = makeRequireFunction(this);
var depth = requireDepth;
...
// 运行模块的封装函数
// 并传入 `exports` , `require`, `module` `filename`, `dirname`
var result = compiledWrapper.call(this.exports, this.exports, require, this, filename, dirname);
return result;
};
复制代码
2.7 小结
模块的代码最终在 Module.prototype._compile
中完成了封装、编译、执行。最后我们在回看 Module._load
,之前我们看到的 Module.load
, Module.prototype._compile
都在 tryModuleLoad()
中被调用, 当这些都执行完成之后,Module._load
返回 module.exports
Module._load = function(request, parent, isMain) {
...
tryModuleLoad(module, filename);
return module.exports;
};
复制代码
所以我们在模块可以使用下面的方式暴露模块 API
function add () {}
module.exports.add = add
exports.add = add
this.add = add
module.exports = {
add
}
复制代码
3.模块系统中的一些细节
3.1 各种缓存
- Module._pathCache
- Module._cache
- packageMainCache
模块请求 -> 路径的缓存
Module._findPath = function(request, paths, isMain) {
if (path.isAbsolute(request)) {
paths = [''];
} else if (!paths || paths.length === 0) {
return false;
}
var cacheKey = request + '\x00' +
(paths.length === 1 ? paths[0] : paths.join('\x00'));
var entry = Module._pathCache[cacheKey];
if (entry)
return entry;
...
}
复制代码
模块路径 -> 模块实例的缓存
Module._load = function(request, parent, isMain) {
...
var filename = Module._resolveFilename(request, parent, isMain);
var cachedModule = Module._cache[filename];
if (cachedModule) {
updateChildren(parent, cachedModule, true);
return cachedModule.exports;
}
...
}
复制代码
3.2 启动
当我们启动 Node.js 时,入口文件也是作为一个模块被加载执行。
// bootstrap main module.
Module.runMain = function() {
// Load the main module--the command line argument.
Module._load(process.argv[1], null, true);
// Handle any nextTicks added in the first tick of the program
process._tickCallback();
};
复制代码
3.3 相对路径模块、绝对路径的模块与 node_modules 中的模块
我们之前看到 require.resolve()
封装了 Module._resolveFilename
。从最初调用 require()
输入的字符 request
通过_resolveFilename
匹配到了模块文件路径 ,这在继续深入会发现 Module._resolveLookupPaths
,。
Module._resolveLookupPaths()
会返回一个数组,第一个元素是我们的 request
,第二个元素
console.log(Module._resolveLookupPaths('app', module))
[
"app",
[
"/Users/awe/Desktop/code/test/node-module/node_modules",
"/Users/awe/Desktop/code/test/node_modules",
"/Users/awe/Desktop/code/node_modules",
"/Users/awe/Desktop/node_modules",
"/Users/awe/node_modules",
"/Users/node_modules",
"/node_modules",
"/Users/awe/Desktop/code/test/node-module",
"/Users/awe/.node_modules",
"/Users/awe/.node_libraries",
"/Users/awe/.nvm/versions/node/v10.0.0/lib/node"
]
]
复制代码
// --inspect-brk
if (debug_options.wait_for_connect()) {
READONLY_DONT_ENUM_PROPERTY(process,
"_breakFirstLine", True(env->isolate()));
}
复制代码
回想最开头提到的找不到模块的错误是在 Module._resolveFilename
,我们可以在 node 源码中看看它的实现 lib/internal/modules/cjs/loader.js
Module._resolveFilename
的功能是根据我们在 require
输入的参数,尝试查找可以引入的模块。
Module._resolveFilename = function(request, parent, isMain, options) {
...
// look up the filename first, since that's the cache key.
var filename = Module._findPath(request, paths, isMain);
if (!filename) {
// 可以看到,找不到模块的报错来自于这里
// eslint-disable-next-line no-restricted-syntax
var err = new Error(`Cannot find module '${request}'`);
err.code = 'MODULE_NOT_FOUND';
throw err;
}
return filename;
};
复制代码
4 小结
通过这篇文章,我们从代码实现了解了 Node.js 的 CommonJS 模块系统的实现,再次回顾模块系统的流程:
- 路径解析
- 文件加载
- 模块封装
- 编译执行
- 缓存
下一篇会分享 Node.js 还在试验阶段 ES Module 模块系统的当前实现以及一些还在讨论的新功能展望。
5.参考:
blog.risingstack.com/node-js-at-…
medium.freecodecamp.org/requiring-m…
原文发布时间为:2018年07月02日
原文作者:掘金
本文来源:掘金 如需转载请联系原作者