写在前面
模块化开发思想是一种自顶向下把系统划分成若干模块独立开发的思想,这种思想的落地从早期口头约束的开发模式:文件名划分、命名空间划分,到如今模块化规范的广泛应用:CommonJS、AMD、CMD、UMD、ES6 Module等,都是为了解决前端项目中全局污染、变量私有化、依赖管理等问题,其中CommonJS是Nodejs模块系统所选择的模块化规范,实现核心就是Nodejs提供的require函数。在如今知识碎片化的时代,前端模块化文章满天飞,以最少必要知道原则直接阅读require源码或许是最有效的构建前端模块化知识体系的方式,读完源码之后,也许会颠覆我们对网上老哥关于某技术点总结一些老的或不准确的认知。
在此之前我们还要先弄清一个问题:为什么要这么卷阅读源码?从技术本身的角度来讲,刚上手一个新技术做需求或者刚刚开始熟练API调用,不知道其底层原理是很正常的事,但如果一个技术用了一年三年五年还不知道,那只能说明个人本身对技术缺少一种钻研精神,编程只是一份工作而不是一种热爱,这样的态度会限制以后能达到的技术高度;从职场发展的角度来讲,如果我们对于一个技术或知识点的认知全都来自于网上的20XX前端高频必会面试题,而不是出于对未知技术的好奇,仅仅是沉迷于答案的背诵和搬运,在职场上也就只能干干10到100修修补补的工作,因为0到1和1到10的工作干不了,需要的眼界和技术深度不到位不利于职业发展。授人以鱼不如授人以渔,本文通过require源码分析其运行机制抛砖引玉,希望能对从事相关开发的同学有所帮助和启发。
Node调试步骤
- 首先我们创建一个名为require的文件夹,并在vscode配置一下调试配置文件launch.json,然后创建一个简单的index.js文件,在第一行打上断点用debug模式启动
- 点击Step Info就能进到node内部查看require方法的实现源码(node版本为12.7.0)
入口模块加载流程
- 我们发现在执行文件第一行之前,Node就已经走完了6步函数调用,将入口模块
index.js
加工了一番
1.Module._load(this, null, true)加载入口模块
Module._load()
的执行逻辑如下:
- 如果模块在缓存里找到,返回其
exports对象
- 如果是原生模块,则调用
NativeModule.prototype.compileForPublicLoader()
并返回其exports对象
- 如果不是原生模块,则根据路径创建一个
Module对象
,存入缓存中,在加载完该模块之后返回其exports对象
2.Module.prototype.load()加载主模块
因为入口模块第一次加载没有缓存,入口模块
index.js
也不是原生模块,所以会走第3条逻辑:为入口模块生成一个Module对象
Module对象
这个对象主要包含如下属性:
id
:模块的标识符path
:文件所在文件夹路径exports
:模块的导出对象,默认为{}
parent
:当前模块的父模块filename
:模块的绝对路径loaded
:模块是否加载完成children
:当前模块的已加载子模块数组paths
:模块的搜索路径,路径的多少取决于目录的深度
3.findLonggerstRegisteredExtension()获取文件后缀名
从
findLongestRegisteredExtension()
的第296行Module._extensions
可以看出require()
支持的文件类型有.js .json .mjs .node
四种,如果没有命中上面的四种文件类型,第298行require()
会把该文件默认当.js
文件处理
4.Module._extensions['.js']匹配JS文件策略
5.Module.prototype._compile()编译.js模块
6.compileFunction编译.js模块
Module.prototype._compile()
第720行会把主模块代码包进一个compiled函数,相当于入口模块的代码被包进另一个函数中compiledWrapper = function(exports, require, module, __filename, __dirname) { // 入口模块的代码 };
7.compiledWrapper.call()注入变量
第777行
result = compiledWrapper.call(thisValue, exports, require, module, filename, dirname)
在执行的过程中给主代码注入了5个额外参数(exports, require, module, filename, dirname)
,其中的require
就是index.js
第一行加载子模块const eventEmitter = require("events")
时用到的导入方法,所以下一步加载子模块时会跳到767行的makeRequireFunction()
,自此才正式进入到子模块的加载流程中
子模块加载流程
1.Module._load(id, this, false)加载入口模块
- 从676到678行看得出,
require(path)中,参数path必须是非空的string类型
- 子模块的加载流程和父模块的加载流程非常相似,因为都调用了
Module._load()
,只不过入参不一样
2.relativeResolveCache查询路径缓存
Module._load()
第510到525行,发现有父模块(index.js)在引用自己(events),于是去缓存relativeResolveCache
里找缓存模块cachedModule
,找到了就返回cachedModule.exports
,没找到就继续往下到527行Module._resolveFilename()
查找模块的真实路径
3.Module._resolveFilename查询模块真实路径
Module._resolveFilename()
571行判断是否是可以被加载的Node内置模块,是的话就返回,不是的话继续往下options
为空直接到608行的Module._resolveLookupPaths()
Module._resolveLookupPaths()
中,如果require(path)
的path
不是内置模块也没用相对路径,那么481行就返回node_modules
环境变量路径的集合["/Users/yuangong/Desktop/require/node_modules", "/Users/yuangong/Desktop/node_modules", "/Users/yuangong/node_modules"...]
,否则498行就只返回父模块绝对路径
回到
Module._resolveFilename()
,要么572行返回内置模块名,要么630行返回自定义模块绝对路径
Module._resolveFilename()
执行完回到Module._load()
第527行
4.Module._cache查询缓存模块
- 529行会去找是否有缓存模块,有的话会返回其
module.exports
,缓存没找到就接着往下找
5.compileForPublicLoader编译原生模块
如果是原生模块,会在535行
Module._cache
缓存里拿到其Module
,经过538行的mod.compileForPublicLoader()
再到217行this.compile()
编译,最后221行返回this.exports
- 原生模块
loaded
默认为true
,所以不会编译,289行直接返回this.exports
6.Module.prototype.load()加载非原生模块
如果不是原生模块,会在542行构建一个
module对象
,549行和551行刷一下缓存,556行执行module.load()
,出异常了560行和562行淘汰缓存,最终还是返回module.exports
Module.prototype.load()
第638行断言该模块没有被加载过不然就抛异常- 640行拿到所有可能的
node_modules
目录["/Users/yuangong/Desktop/require/node_modules", "/Users/yuangong/Desktop/node_modules", "/Users/yuangong/node_modules", "/Users/node_modules", "/node_modules"]
- 642行通过
findLongestRegisteredExtension()
获取文件类型
7.compileFunction编译.js模块
Module.prototype._compile()
第720行会把主模块代码包进一个compiled函数- 相当于入口模块的代码被包进另一个函数中
compiledWrapper = function(exports, require, module, __filename, __dirname) { // 入口模块的代码 };
8.compiledWrapper.call()注入变量
- 第777行
result = compiledWrapper.call(thisValue, exports, require, module, filename, dirname)
并在781行返回compiledWrapper()
执行完的结果
9.返回module.exports
最终
Module.load()
执行完毕,子模块的this.loaded
变成true
,Module._load()
执行完毕,const eventEmitter = require('events')
拿到了module.exports
,代码继续往下执行直到index.js
最后一行,入口模块的this.loaded
变为true
加载模块全流程总结
入口模块加载流程
- 获取文件绝对路径
- 根据文件绝对路径检查Module._cache模块缓存
- 检查是不是原生模块
- 根据绝对路径创建Module对象 module
- 将module对象存入Module._cache模块缓存中
- 将文件路径存入relativeResolveCache缓存
- 根据文件扩展名编译文件Module._extensions.js
- 将文件内容字符串编译成可执行的js函数 compiledWrapper
- 调用compiledWrapper.call(exports, require, module, __filename, __dirname)
- 开始执行入口文件中第一行代码遇到require('xxx')
子模块加载流程
- 调用父模块的require方法
- 校验require路径必须为非空字符串
- 根据require路径查relativeResolveCache模块路径缓存
- 根据文件绝对路径检查Module._cache模块缓存
- 检查是不是原生模块
- 根据绝对路径创建Module对象 module
- 将module对象存入Module._cache模块缓存中
- 将文件路径存入relativeResolveCache路径缓存
- 根据文件扩展名编译文件Module._extensions[.js] (this)
- 将文件内容字符串编译成可执行的js函数 compiledWrapper
- 调用compiledWrapper.call(exports, require, module, __filename, __dirname)
- 执行子模块代码,require('xxx')执行结束返回子模块的module.exports属性对象,继续执行父模块下一行代码
小结
俗话说的好,业务才是解决用户问题的核心,技术为业务提供支撑,我们应该了解业务,然后合理分配开发资源。一个业务好不好并不是看它用了什么前沿华丽的技术,而是看它是不是真的能服务于产品和用户。有技术情怀是好事,但终归都是为了解决实际问题而驱动的,所以不要为了跟风而读源码,不要为了炫技而学技术。
Never chase the hot thing whatever it is. That’s like trying to catch the wave and you’ll never catch it. You have to position yourself and wait for the wave. The way you do that is you pick something you are passionate about. Missionaries build better products. I would take a missionary over a mercenary any day.
如果有对前端模块化感兴趣还意犹未尽的同学可以去思考以下这些问题:
- AMD是怎么实现异步加载的?
- CMD是怎么实现按需加载的?
- UMD是怎么兼容AMD和CommonJS两种模块化语法的?
- No-bundle打包工具是怎么将CommonJS转换为ESM的?
- Webpack的CommonJS打包产物是怎么实现require的?
- Webpack的ESM打包产物是怎么实现import的?
- Webpack遇到项目里同时使用了CommonJS和ESM时是怎么处理的?
加餐
Node模块路径查找策略
require方法接收以下几种形式的入参
- 原生模块:http、fs、path、events
- 相对路径的文件模块:./mod.js 或 ../mod.js
- 绝对路径的文件模块:/Users/xutao3/Desktop/demo/index.js
- 目录作为模块:./components/config
- 非原生模块的文件模块:lodash、vue
第527行看得出,filename绝对路径是通过_resolveFinename方法获取的,以下是简化后的_Module._resolveFilename
Module._resolveFilename = function (request, parent, isMain, options) {
// 如果是内置模块,直接返回内置模块的名字
if (NativeModule.canBeRequiredByUsers(request)) {
return request;
}
// 生成模块路径查找范围数组
var paths = Module._resolveLookupPaths(request, parent);
// 从paths中找出模块的绝对路径
const filename = Module._findPath(request, paths, isMain);
// 没找到模块绝对路径抛异常
if (!filename) {
const requireStack = [];
for (var 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- ");
}
var err = new Error(message);
err.code = "MODULE_NOT_FOUND";
err.requireStack = requireStack;
throw err;
}
// 找到模块绝对路径就返回
return filename;
};
Module._resolveLookupPaths = function (request, parent) {
// 如果是内置模块,直接返回null
if (NativeModule.canBeRequiredByUsers(request)) {
return null;
}
// 如果引用模块的路径不是相对路径 ./xxx or ../xxx
if (
request.charAt(0) !== "." ||
(request.length > 1 &&
request.charAt(1) !== "." &&
request.charAt(1) !== "/" &&
(!isWindows || request.charAt(1) !== "\\"))
) {
// 获取由根目录node_modules路径、根目录node_libraries路径、NODE_PATH全局路径组成的的路径数组
let paths = modulePaths;
// 如果不是入口模块,则将路径数组合并进父模块的查找路径数组中
if (parent != null && parent.paths && parent.paths.length) {
paths = parent.paths.concat(paths);
}
// 返回模块查找路径范围数组
return paths.length > 0 ? paths : null;
}
// 如果模块是以相对路径引用 ./xxx or ../xxx 则模块查找路径范围数组只包含 父模块的绝对路径
const parentDir = [path.dirname(parent.filename)];
// 返回模块查找路径范围数组
return parentDir;
};
Module._findPath = function (request, paths, isMain) {
// 判断模块的引用路径是否为绝对路径 /xxx
if (path.isAbsolute(request)) {
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;
var exts;
// 引入的模块是否是目录 require('xxx/')
var trailingSlash =
request.length > 0 &&
request.charCodeAt(request.length - 1) === CHAR_FORWARD_SLASH;
if (!trailingSlash) {
// 模块引用路径的结尾是否是 /.. 或 /. 或 .. 或 .
trailingSlash = /(?:^|\/)\.?\.$/.test(request);
}
// 遍历绝对路径数组
for (var i = 0; i < paths.length; i++) {
const curPath = paths[i];
// 如果该路径不是目录或者不存在则看下一个路径
// stat(): 1表示目录 0表示文件 -2表示不存在
if (curPath && stat(curPath) < 1) continue;
// 模块的绝对路径
var basePath = path.resolve(curPath, request);
var filename;
var rc = stat(basePath);
// 模块绝对路径的结尾不是 /.. 或 /. 或 .. 或 .
if (!trailingSlash) {
// 模块路径的类型是文件 解析模块的绝对路径
if (rc === 0) {
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) {
// 尝试给模块的后缀加上[".js",".json",".node",".mjs"]然后接着解析模块绝对路径
if (exts === undefined) exts = Object.keys(Module._extensions);
filename = tryExtensions(basePath, exts, isMain);
}
}
// 模块路径的类型是目录
if (!filename && rc === 1) {
if (exts === undefined) exts = Object.keys(Module._extensions);
// 查找目录下package.json中的main属性指定的文件名进行定位,如果没有main属性或者没有package.json文件,则将index当作默认文件名
// 尝试给目录里index文件后缀加上[".js",".json",".node",".mjs"]然后接着解析模块绝对路径
filename = tryPackage(basePath, exts, isMain, request);
}
// 解析完成后将路径存入缓存
if (filename) {
Module._pathCache[cacheKey] = filename;
return filename;
}
}
return false;
};
最终,我们能得出一个大致的Node查找模块路径的策略
- 如果是内置模块,直接返回内置模块的名字
- 如果是模块是用相对路径引用的,直接返回父模块的绝对路径
- 获取由根目录node_modules路径、根目录node_libraries路径、NODE_PATH全局路径组成的的路径数组
- 遍历路径数组
- 将模块的路径和数组中的路径拼接成绝对路径,并开始解析该路径
- 如果没解析到路径,则给该路径分别添加 .js .json .node .mjs后缀再解析
- 如果还没解析到路径,而且路径类型是目录,则先读取目录下的package.json文件,取得main参数指定的文件名,如果没有,则将index作为默认文件名,解析目录下的该文件
- 如果还没解析到路径,给目录下的文件添加 .js .json .node .mjs后缀再解析
- 如果解析到路径则存入缓存后返回模块的绝对路径
- 如果仍然没找到模块的绝对路径则抛出异常
Node模块与global对象的关系
- global.process.mainModule === module【主模块】
- global.process.mainModule.children【子模块数组】
引入模块时怎么处理未加后缀名的文件
当引入的文件后缀名不属于 .js .json .mjs .node时,或者没有后缀名时,统一当作.js文件来处理
CommonJS和ES Module区别
- CommonJS只能引入整个模块,ES Module可以引入模块中的某个值
- CommonJs的 this 是当前Module模块对象,ES Module的 this 是 undefined
- CommonJS模块输出的是值的浅拷贝并且可以修改,ES Module模块输出的是值的引用并且值是只读的
- CommonJS模块解析发生在执行阶段,可以配合表达式和变量动态执行,ES Module模块解析发生在编译阶段,只能静态执行
- CommonJS导入的文件可以不加后缀名,ES Module导入的文件必须加后缀名
- CommonJS只能导入CommonJS模块,要导入ESM模块需要用import().then(),ES Module既可以导入CommonJS模块又可以导入ES Module模块
- CommonJS有exports, require, module, __filename, __dirname全局变量
- CommonJS额外支持引入.json文件和.node文件,ES Module不支持
- CommonJS代码不能被Tree-shaking,而ES Module代码可以被Tree-shaking
- CommonJS在浏览器中是同步加载可能会阻塞页面渲染,ES Module相当于是加了defer属性的script标签属于是渲染完了再执行
- CommonJS不能在最外层使用await,而ES Module可以
- 未指定package.json中的type或者type="commonjs"时,
.js
文件默认采用 CJS,.mjs
后缀的文件强制作为 ESM 处理,指定package.json中的type="module"时,.js
文件会被识别为 ESM,.cjs
后缀的文件强制为 CJS 处理