模块加载&编译机制&require
路径分析
NodeJS
中的模块可以通过 文件路径 或者 名字 获取到模块,模块的引用会映射到一个JS
的文件- 主要分为两种类型的模块:
- 由
Node
提供的模块,称为核心模块,内置模块公开了一些常用的API
给开发者,并且它们在Node
进程开始的时候就预加载了 - 由用户编写的模块,称为 文件模块 ,通常是
npm
包或者是本地模块,通常会暴露一个公开的API
,供其他开发者使用
- 由
核心模块是Node源码在编译过程中编译进了二进制执行文件。在Node启动时这些模块就被加载进内存中,所以核心模块引入时省去了文件定位和编译执行两个步骤,并且在路径分析中优先判断,因此核心模块的加载速度是最快的。文件模块则是在运行时动态加载,速度比核心模块慢
模块载入
- 模块的载入是有先后顺序的,这里简单介绍一下
- 载入内置模块(
A Core Module
) - 载入文件模块(
A File Module
),可以忽略.js, .json, .node
后缀,如果不存在对应的文件,就将这个路径作为文件夹加载(步骤3) - 载入文件目录模块(
A Folder Module
)此时的策略是:- 先找寻该目录下的
package.json
文件,然后parse
出来,读取main
,index
字段中指定的文件(main
的优先级高) - 如果都没有指定,那么会默认寻找
index.js
文件 - 如果
index.js
文件也不存在,则报错
- 先找寻该目录下的
- 载入
node_modules
里的模块- 如果 传入的模块名称不是 路径,也不是核心模块,那么 会从 当前目录的
node_modules
文件夹搜索,如果当前目录下的node_modules
找不到,则会从父目录的node_modules
搜索,这样递归搜索,知道当前项目的根目录
- 如果 传入的模块名称不是 路径,也不是核心模块,那么 会从 当前目录的
- 自动缓存已载入模块,对于已加载的模块Node会缓存下来,而不必每次都重新搜索,因为寻找模块是一件很费时的事情,所以引入了缓存机制
模块编译
- 每个模块文件模块都是一个对象,他的定义如下:
js
function Module(id = '', parent) {
this.id = id;
this.path = path.dirname(id);
this.exports = {};
moduleParentCache.set(this, parent);
updateChildren(parent, this, false);
this.filename = null;
this.loaded = false;
this.children = [];
}
- 对于不同的拓展名,编译的方法也是不同的
.js
通过fs
模块 同步 读取文件后编译执行.json
通过fs
模块 同步 读取文件后,用JSON.parse()
解析返回结果.node
这是由C/C++
编写的拓展文件,通过dlopen()
方法加载编译生成的文件- 对于其他的文件,一律当作
.js
处理
- 每一个编译成功的模块都会将其文件路径作为
key
缓存再Module_cache
对象上 - 主要是通过
Module._extensions[xxx]
的方法来实现 编译,后续我们会自己实现一个简易版
js 模块编译
- 编译过程中,
Node
会对 读取的js
文件包装一层,方便注入一些require, __dirname
等全局变量,以及执行
js
(function (exports, require, module, __filename, __dirname) {
......
})
- 包装后的代码,会通过
vm
原生模块的runInThisContext
方法编译,这个方法接受一个字符串,将其转化为函数,并且指定明确的上下文,不污染全局 - 此方法用于创建一个独立的沙箱运行空间,
cotent
内的代码可以访问外部的global
对象,但是不能访问其他变量
实现一个简易版 require
- 从我们之前了解到的 模块机制中 不难发现,其实
require 和 module.exports
干的事情并不复杂,假设有一个全局的对象 ,初始值为{}
,当我们require
的文件,就将这个文件拿出来执行,如果这个文件里存在module.exports
的语句,则当运行到这行代码的时候,将module.exports
的值加入这个对象,key
为对应的 文件名,最终,这对象会变成这样:
js
{
'a.js': 'hello',
'b.js': function hello() {},
'c.js': 2,
'd.js': { d: 2 }
}
- 当再次
require
这个文件时,如果这个对象里面有对应的值,就直接返回,如果没有就重复前面的步骤,执行目标文件,然后将它的module.exports
加入这个全局对象,并返回给调用者。 - 这个全局对象其实就是上面说的的缓存机制(
Module._cache
)。 - 所以
require
和module.exports
并没有什么黑魔法,就只是运行并获取目标文件的值,然后加入缓存,用的时候拿出来用就行。 - 再看看这个对象,因为
d.js
是一个引用类型,所以在任何地方获取了这个引用都可以更改其中的值,如果不希望自己模块的值被更改,需要在写模块时进行处理,比如使用Object.freeze()
,Object.defineProperty()
之类的方法冻结住
实现
- 思路
- 通过传入的
id
找到对应的文件 - 执行找到的文件,同时要注入
module
和require
这些方法和属性,以便模块文件使用 - 返回模块的
module.exports
- 通过传入的
- 实现细节如下:
js
const path = require('path')
const fs = require('fs')
const vm = require('vm')
function MyModule(id = '') {
this.id = id
this.filename = path.resolve(id)
this.exports = {}
this.filename = null
this.loaded = false
}
// require 实际上是 调用 _load 方法
MyModule.require = function (id) {
return MyModule._load(id)
}
// 存放缓存模块
MyModule._cache = Object.create(null)
// 加载 模块
// 返回 module.exports
MyModule._load = function (id) {
// 根据 id 解析出 filename
const filename = MyModule._resolvePath(id)
// 根据 filename 判断是否有 缓存,有的话,直接返回 缓存的 exports 即可
const cachedModule = MyModule._cache[filename]
if (cachedModule !== undefined) {
return cachedModule.exports
}
// 没有缓存 则构造一个模块 并且调用 load 方法加载
const module = new MyModule(filename)
// load 之前就将这个模块缓存下来,这样如果有循环引用就会拿到这个缓存,但是这个缓存里面的 exports 可能还没有或者不完整
MyModule._cache[filename] = module
module.load(filename)
return module.exports
}
MyModule._resolvePath = function (id) {
const filename = path.resolve(id)
const extname = path.extname(id)
// 如果 没有后缀,则尝试找寻,添加后缀,找到则返回
if (!extname) {
const exts = Object.keys(MyModule._extensions)
for (const ext of exts) {
const curPath = `${filename}${ext}`
if (fs.existsSync(curPath)) {
return curPath
}
}
}
return filename
}
MyModule.load = function (filename) {
// 拿到 后缀,根据后缀,调用相应的 加载函数 来处理
const extname = path.extname(filename)
MyModule._extensions[extname](this, filename)
this.loaded = true
}
// js 文件的 加载函数
MyModule._extensions['.js'] = function (module, filename) {
// 思路很简单,读取文件内容,随后编译,执行该文件
const content = fs.readFileSync(filename, 'utf-8')
module._compile(content, filename)
}
// 编译 文件
// 首先,我们在执行 js 的时候,是可以拿到 一些全局变量,例如 require exports module __filename __dirname
// 所以,在执行前,我们要注入这些变量
// 实现也很简单,通过 IIFE 的形式注入即可
// 所以,这里我们需要将 js 代码包裹一层 IIFE,后续 执行的时候,可以注入这些变量
MyModule._compile = function (content, filename) {
// 将 读取的代码 包裹起来
const wrappedFn = MyModule._wrap(content)
// vm 是 nodejs 的虚拟机沙盒模块,runInThisContext 方法可以接受一个字符串并将它转化为一个函数
// 返回值就是转化后的函数,所以 compiledFn 是一个函数
const compiledFn = vm.runInThisContext(wrappedFn, {
filename,
lineOffset: 0,
displayErrors: true,
})
const dirname = path.resolve(filename)
// 执行 这段代码,注入全局变量
// 代码中 会有类似 module.exports.xxx = xxx,这就为 this.exports 挂载上了 导出的属性
compiledFn.call(
// call 的第一个参数就是 this,这里传入了 this.exports, 也就是 module.exports
// 这也是为什么在 js 文件里面 this 是对 module.exports 的一个引用
this.exports,
this.exports,
this.require,
this, // module 就是 this,指向当前模块
filename,
dirname
)
}
// 需要包裹的部分
// 注意拼接的开头和结尾多了一个()包裹,这样我们后面可以拿到这个匿名函数
// 拿到后,在后面再加一个 () 就可以传参数执行了。
MyModule.wrapper = [
'(function(exports, require, module, __filename, __dirname){',
'\n})',
]
// 包裹函数
// 将需要执行的 js 代码拼接到这个方法中间
MyModule._wrap = function (content) {
return MyModule.wrapper[0] + content + MyModule.wrapper[1]
}
相关文章