注:1. 本文涉及的 nodejs 源码如无特别说明则全部基于 v10.14.1
欢迎关注公众号:前端情报局
Nodejs 中对模块的实现
本节主要基于 NodeJs 源码,对其模块的实现做一个简要的概述,如有错漏,望诸君不吝指正。
当我们使用 require 引入一个模块的时候,概况起来经历了两个步骤:路径分析和模块载入
路径分析
路径分析其实就是模块查找的过程,由_resolveFilename 函数实现。
我们通过一个例子,展开说明:
const http = require(‘http’);
const moduleA = requie(‘./parent/moduleA’);
这个例子中,我们引入两种不同类型的模块:核心模块 -http 和自定义模块 moduleA
对于核心模块而言,_resolveFilename 会跳过查找步骤,直接返回,交给下一步处理
if (NativeModule.nonInternalExists(request)) {
// 这里的 request 就是模块名称 ‘http’
return request;
}
而对于自定义模块而言,存在以下几种情况(_findPath)
文件模块
目录模块
从 node_modules 目录加载
全局目录加载
这些在官方文档中已经阐述的很清楚了,这里就不再赘述。
如果模块存在,那么_resolveFilename 会返回该模块的绝对路径,比如 /Users/xxx/Desktop/practice/node/module/parent/moduleA.js。
载入模块
获取到模块地址后,Node 就开始着手载入模块。
首先,Node 会查看模块是否存在缓存中:
// filename 即模块绝对路径
var cachedModule = Module._cache[filename];
if (cachedModule) {
updateChildren(parent, cachedModule, true);
return cachedModule.exports;
}
存在则返回对应缓存内容,不存在则进一步判断该模块是否是核心模块:
if (NativeModule.nonInternalExists(filename)) {
return NativeModule.require(filename);
}
如果模块既不存在于缓存中也非核心模块,那么 Node 会实例化一个全新的模块对象
function Module(id, parent){
// 通常是模块绝对路径
this.id = id;
// 要导出的内容
this.exports = {};
// 父级模块
this.parent = parent;
this.filename = null;
// 是否已经加载成功
this.loaded = false;
// 子模块
this.children = [];
}
var module = new Module(filename, parent);
而后 Node 会根据路径尝试载入。
function tryModuleLoad(module, filename) {
var threw = true;
try {
module.load(filename);
threw = false;
} finally {
if (threw) {
delete Module._cache[filename];
}
}
}
对于不同的文件扩展名,其载入方法也有所不同。
.js 文件 (_compile)
通过 fs 同步读取文件内容后将其包裹在指定函数中:
Module.wrapper = [
‘(function (exports, require, module, __filename, __dirname) {‘,
‘\n});’
];
调用执行此函数:
compiledWrapper.call(this.exports, this.exports, require, this,
filename, dirname);
.json 文件
通过 fs 同步读取文件内容后, 用 JSON.parse 解析并返回内容
var content = fs.readFileSync(filename, ‘utf8’);
try {
module.exports = JSON.parse(stripBOM(content));
} catch (err) {
err.message = filename + ‘: ‘ + err.message;
throw err;
}
.node
这是用 C /C++ 编写的扩展文件,通过 dlopen() 方法加载最后编译生成的文件。
return process.dlopen(module, path.toNamespacedPath(filename));
.mjs
这是用于处理 ES6 模块的扩展文件,是 NodeJs 在 v8.5.0 后新增的特性。对于这类扩展名的文件,只能使用 ES6 模块语法 import 引入,否则将会报错(启用 –experimental-modules 的情况下)
throw new ERR_REQUIRE_ESM(filename);
如果一切顺利,就会返回附加在 exports 对象上的内容
return module.exports;
模块循环依赖
接下来我们来探究一下模块循环依赖的问题:模块 1 依赖模块 2,模块 2 依赖模块 1,会发生什么?
这里只探究 commonjs 的情况
为此,我们创建了两个文件,module-a.js 和 module-b.js,并让他们相互引用:
module-a.js
console.log(‘ 开始加载 A 模块 ’);
exports.a = 2;
require(‘./module-b.js’);
exports.b = 3;
console.log(‘A 模块加载完毕 ’);
module-b.js
console.log(‘ 开始加载 B 模块 ’);
let moduleA = require(‘./module-a.js’);
console.log(moduleA.a,moduleA.b)
console.log(‘B 模块加载完毕 ’);
运行 module-a.js,可以看到控制台输出:
开始加载 A 模块
开始加载 B 模块
2 undefined
B 模块加载完毕
A 模块加载完毕
这时因为每个 require 都是同步执行的,在 module- a 完全加载前需要先加载./module-b,此时对于 module- a 而言,其 exports 对象上只附加了属性 a,属性 b 是在./module- b 加载完成后才赋值的。
QA
如何删除模块缓存?
可以通过 delete require.cache(moduleId) 来删除对应模块的缓存,其中 moduleId 表示的是模块的绝对路径,一般的,如果我们需要对某些模块进行热更新,可以使用此特性,举个例子:
// hot-reload.js
console.log(‘this is hot reload module’);
// index.js
const path = require(‘path’);
const fs = require(‘fs’);
const hotReloadId = path.join(__dirname,’./hot-reload.js’);
const watcher = fs.watch(hotReloadId);
watcher.on(‘change’,(eventType,filename)=>{
if(eventType === ‘change’){
delete require.cache[hotReloadId];
require(hotReloadId);
}
});
Node 中可以使用 ES6 模块吗?
从 8.5.0 版本开始,NodeJs 开始支持原生 ES6 模块,启用该功能需要两个条件:
所有使用 ES6 模块的文件扩展名都必须是.mjs
命令行选项 –experimental-modules
node –experimental-modules index.mjs
node –experimental-modules index.mjs
但是截止到 NodeJs v10.15.0,ES6 模块的支持依旧是实验性的,笔者并不推荐在公司项目中使用
参考
nodejs-loader.js
朴灵. 深入浅出 Node.js