前端技术探秘 - Nodejs 的 CommonJS 规范实现原理 | 京东物流技术团队
前端技术探秘 - Nodejs 的 CommonJS 规范实现原理 | 京东物流技术团队
了解 Node.js
Node.js 是一个基于 ChromeV8 引擎的 JavaScript 运行环境,使用了一个事件驱动、非阻塞式 I/O 模型,让 JavaScript 运行在服务端的开发平台,它让 JavaScript 成为与 PHP、Python、Perl、Ruby 等服务端语言平起平坐的脚本语言。Node 中增添了很多内置的模块,提供各种各样的功能,同时也提供许多第三方模块。
模块的问题
为什么要有模块
复杂的前端项目需要做分层处理,按照功能、业务、组件拆分成模块, 模块化的项目至少有以下优点:
- 便于单元测试
- 便于同事间协作
- 抽离公共方法,开发快捷
- 按需加载,性能优秀
- 高内聚低耦合
- 防止变量冲突
- 方便代码项目维护
几种模块化规范
- CMD (SeaJS 实现了 CMD)
- AMD (RequireJS 实现了 AMD)
- UMD (同时支持 AMD 和 CMD)
- IIFE (自执行函数)
- CommonJS (Node 采用了 CommonJS)
- ES Module 规范 (JS 官方的模块化方案)
Node 中的模块
Node 中采用了 CommonJS 规范
实现原理:
Node 中会读取文件,拿到内容实现模块化, Require 方法 同步引用
tips:Node 中任何 js 文件都是一个模块,每一个文件都是模块
Node 中模块类型
- 内置模块,属于核心模块,无需安装,在项目中不需要相对路径引用, Node 自身提供。
- 文件模块,程序员自己书写的 js 文件模块。
- 第三方模块, 需要安装, 安装之后不用加路径。
Node 中内置模块
fs filesystem
操作文件都需要用到这个模块
const path = require('path'); // 处理路径
const fs = require('fs'); // file system
// // 同步读取
let content = fs.readFileSync(path.resolve(__dirname, 'test.js'), 'utf8');
console.log(content);
let exists = fs.existsSync(path.resolve(__dirname, 'test1.js'));
console.log(exists);
path 路径处理
const path = require('path'); // 处理路径
// join / resolve 用的时候可以混用
console.log(path.join('a', 'b', 'c', '..', '/'))
// 根据已经有的路径来解析绝对路径, 可以用他来解析配置文件
console.log(path.resolve('a', 'b', '/')); // resolve 不支持/ 会解析成根路径
console.log(path.join(__dirname, 'a'))
console.log(path.extname('1.js'))
console.log(path.dirname(__dirname)); // 解析父目录
vm 运行代码
字符串如何能变成 JS 执行呢?
1.eval
eval 中的代码执行时的作用域为当前作用域。它可以访问到函数中的局部变量。
let test = 'global scope'
global.test1 = '123'
function b(){
test = 'fn scope'
eval('console.log(test)'); //local scope
new Function('console.log(test1)')() // 123
new Function('console.log(test)')() //global scope
}
b()
2.new Function
new Function () 创建函数时,不是引用当前的词法环境,而是引用全局环境,Function 中的表达式使用的变量要么是传入的参数要么是全局的值
Function 可以获取全局变量,所以它还是可能会有变量污染的情况出现
function getFn() {
let value = "test"
let fn = new Function('console.log(value)')
return fn
}
getFn()()
global.a = 100 // 挂在到全局对象global上
new Function("console.log(a)")() // 100
3.vm
前面两种方式,我们一直强调一个概念,那就是变量的污染
VM 的特点就是不受环境的影响,也可以说他就是一个沙箱环境
在 Node 中全局变量是在多个模块下共享的,所以尽量不要在 global 中定义属性
所以,vm.runInThisContext 可以访问到 global 上的全局变量,但是访问不到自定义的变量。而 vm.runInNewContext 访问不到 global,也访问不到自定义变量,他存在于一个全新的执行上下文
const vm = require('vm')
global.a = 1
// vm.runInThisContext("console.log(a)")
vm.runInThisContext("a = 100") // 沙箱,独立的环境
console.log(a) // 1
vm.runInNewContext('console.log(a)')
console.log(a) // a is not defined
Node 模块化的实现
node 中是自带模块化机制的,每个文件就是一个单独的模块,并且它遵循的是 CommonJS 规范,也就是使用 require 的方式导入模块,通过 module.export 的方式导出模块。
node 模块的运行机制也很简单,其实就是在每一个模块外层包裹了一层函数,有了函数的包裹就可以实现代码间的作用域隔离。
我们先在一个 js 文件中直接打印 arguments,得到的结果如下图所示,我们先记住这些参数。
console.log(arguments) // exports, require, module, __filename, __dirname
Node 中通过 modules.export 导出,require 引入。其中 require 依赖 node 中的 fs 模块来加载模块文件,通过 fs.readFile 读取到的是一个字符串。
在 javascrpt 中可以通过 eval 或者 new Function 的方式来将一个字符串转换成 js 代码来运行。但是前面提到过,他们都有一个致命的问题,就是变量的污染。
实现 require 模块加载器
首先导入依赖的模块 path,fs,vm, 并且创建一个 Require 函数,这个函数接收一个 modulePath 参数,表示要导入的文件路径
const path = require('path');
const fs = require('fs');
const vm = require('vm');
// 定义导入类,参数为模块路径
function Require(modulePath) {
...
}
在 Require 中获取到模块的绝对路径,使用 fs 加载模块,这里读取模块内容使用 new Module 来抽象,使用 tryModuleLoad 来加载模块内容,Module 和 tryModuleLoad 稍后实现,Require 的返回值应该是模块的内容,也就是 module.exports。
// 定义导入类,参数为模块路径
function Require(modulePath) {
// 获取当前要加载的绝对路径
let absPathname = path.resolve(__dirname, modulePath);
// 创建模块,新建Module实例
const module = new Module(absPathname);
// 加载当前模块
tryModuleLoad(module);
// 返回exports对象
return module.exports;
}
Module 的实现就是给模块创建一个 exports 对象,tryModuleLoad 执行的时候将内容加入到 exports 中,id 就是模块的绝对路径。
// 定义模块, 添加文件id标识和exports属性
function Module(id) {
this.id = id;
// 读取到的文件内容会放在exports中
this.exports = {};
}
node 模块是运行在一个函数中,这里给 Module 挂载静态属性 wrapper,里面定义一下这个函数的字符串,wrapper 是一个数组,数组的第一个元素就是函数的参数部分,其中有 exports,module,Require,__dirname,__filename, 都是模块中常用的全局变量.
第二个参数就是函数的结束部分。两部分都是字符串,使用的时候将他们包裹在模块的字符串外部就可以了。
// 定义包裹模块内容的函数
Module.wrapper = [
"(function(exports, module, Require, __dirname, __filename) {",
"})"
]
_extensions 用于针对不同的模块扩展名使用不同的加载方式,比如 JSON 和 javascript 加载方式肯定是不同的。JSON 使用 JSON.parse 来运行。
javascript 使用 vm.runInThisContext 来运行,可以看到 fs.readFileSync 传入的是 module.id 也就是 Module 定义时候 id 存储的是模块的绝对路径,读取到的 content 是一个字符串,使用 Module.wrapper 来包裹一下就相当于在这个模块外部又包裹了一个函数,也就实现了私有作用域。
使用 call 来执行 fn 函数,第一个参数改变运行的 this 传入 module.exports,后面的参数就是函数外面包裹参数 exports, module, Require, __dirname, __filename。/
// 定义扩展名,不同的扩展名,加载方式不同,实现js和json
Module._extensions = {
'.js'(module) {
const content = fs.readFileSync(module.id, 'utf8');
const fnStr = Module.wrapper[0] + content + Module.wrapper[1];
const fn = vm.runInThisContext(fnStr);
fn.call(module.exports, module.exports, module, Require,__filename,__dirname);
},
'.json'(module) {
const json = fs.readFileSync(module.id, 'utf8');
module.exports = JSON.parse(json); // 把文件的结果放在exports属性上
}
}
tryModuleLoad 函数接收的是模块对象,通过 path.extname 来获取模块的后缀名,然后使用 Module._extensions 来加载模块。
// 定义模块加载方法
function tryModuleLoad(module) {
// 获取扩展名
const extension = path.extname(module.id);
// 通过后缀加载当前模块
Module._extensions[extension](module); // 策略模式???
}
到此 Require 加载机制基本就写完了。Require 加载模块的时候传入模块名称,在 Require 方法中使用 path.resolve (__dirname, modulePath) 获取到文件的绝对路径。然后通过 new Module 实例化的方式创建 module 对象,将模块的绝对路径存储在 module 的 id 属性中,在 module 中创建 exports 属性为一个 json 对象。
使用 tryModuleLoad 方法去加载模块,tryModuleLoad 中使用 path.extname 获取到文件的扩展名,然后根据扩展名来执行对应的模块加载机制。
最终将加载到的模块挂载 module.exports 中。tryModuleLoad 执行完毕之后 module.exports 已经存在了,直接返回就可以了。
接下来,我们给模块添加缓存。就是文件加载的时候将文件放入缓存中,再去加载模块时先看缓存中是否存在,如果存在直接使用,如果不存在再去重新加载,加载之后再放入缓存。
// 定义导入类,参数为模块路径
function Require(modulePath) {
// 获取当前要加载的绝对路径
let absPathname = path.resolve(__dirname, modulePath);
// 从缓存中读取,如果存在,直接返回结果
if (Module._cache[absPathname]) {
return Module._cache[absPathname].exports;
}
// 创建模块,新建Module实例
const module = new Module(absPathname);
// 添加缓存
Module._cache[absPathname] = module;
// 加载当前模块
tryModuleLoad(module);
// 返回exports对象
return module.exports;
}
增加功能:省略模块后缀名。
自动给模块添加后缀名,实现省略后缀名加载模块,其实也就是如果文件没有后缀名的时候遍历一下所有的后缀名看一下文件是否存在。
// 定义导入类,参数为模块路径
function Require(modulePath) {
// 获取当前要加载的绝对路径
let absPathname = path.resolve(__dirname, modulePath);
// 获取所有后缀名
const extNames = Object.keys(Module._extensions);
let index = 0;
// 存储原始文件路径
const oldPath = absPathname;
function findExt(absPathname) {
if (index === extNames.length) {
return throw new Error('文件不存在');
}
try {
fs.accessSync(absPathname);
return absPathname;
} catch(e) {
const ext = extNames[index++];
findExt(oldPath + ext);
}
}
// 递归追加后缀名,判断文件是否存在
absPathname = findExt(absPathname);
// 从缓存中读取,如果存在,直接返回结果
if (Module._cache[absPathname]) {
return Module._cache[absPathname].exports;
}
// 创建模块,新建Module实例
const module = new Module(absPathname);
// 添加缓存
Module._cache[absPathname] = module;
// 加载当前模块
tryModuleLoad(module);
// 返回exports对象
return module.exports;
}
源代码调试
我们可以通过 VSCode 调试 Node.js
步骤
创建文件 a.js
module.exports = 'abc'
1. 文件 test.js
let r = require('./a')
console.log(r)
2. 配置 debug,本质是配置.vscode/launch.json 文件,而这个文件的本质是能提供多个启动命令入口选择。
一些常见参数如下:
- program 控制启动文件的路径(即入口文件)
- name 下拉菜单中显示的名称(该命令对应的入口名称)
- request 分为 launch(启动)和 attach(附加)(进程已经启动)
- skipFiles 指定单步调试跳过的代码
- runtimeExecutable 设置运行时可执行文件,默认是 node,可以设置成 nodemon,ts-node,npm 等
修改 launch.json,skipFiles 指定单步调试跳过的代码
- 将 test.js 文件中的 require 方法所在行前面打断点
- 执行调试,进入源码相关入口方法
梳理代码步骤
1. 首先进入到进入到 require 方法:Module.prototype.require
2. 调试到 Module._load 方法中,该方法返回 module.exports,Module._resolveFilename 方法返回处理之后的文件地址,将文件改为绝对地址,同时如果文件没有后缀就加上文件后缀。
3. 这里定义了 Module 类。id 为文件名。此类中定义了 exports 属性
4. 接着调试到 module.load 方法,该方法中使用了策略模式,Module._extensions [extension](this, filename) 根据传入的文件后缀名不同调用不同的方法
5. 进入到该方法中,看到了核心代码,读取传入的文件地址参数,拿到该文件中的字符串内容,执行 module._compile
6. 此方法中执行 wrapSafe 方法。将字符串前后添加函数前后缀,并用 Node 中的 vm 模块中的 runInthisContext 方法执行字符串,便直接执行到了传入文件中的 console.log 代码行内容。
至此,整个 Node 中实现 require 方法的整个流程代码已经调试完毕,通过对源代码的调试,可以帮助我们学习其实现思路,代码风格及规范,有助于帮助我们实现工具库,提升我们的代码思路,同时我们知道相关原理,也对我们解决日常开发工作中遇到的问题提供帮助。
作者:京东物流 乔盼盼
来源:京东云开发者社区 自猿其说 Tech 转载请注明来源