Redian新闻
>
前端技术探秘 - Nodejs的CommonJS规范实现原理

前端技术探秘 - Nodejs的CommonJS规范实现原理

公众号新闻

作者:京东云开发者-京东物流 乔盼盼

链接:https://my.oschina.net/u/4090830/blog/10150940

了解 Node.js

Node.js 是一个基于 ChromeV8 引擎的 JavaScript 运行环境,使用了一个事件驱动、非阻塞式 I/O 模型,让 JavaScript 运行在服务端的开发平台,它让 JavaScript 成为与 PHP、Python、Perl、Ruby 等服务端语言平起平坐的脚本语言。Node 中增添了很多内置的模块,提供各种各样的功能,同时也提供许多第三方模块。

模块的问题

为什么要有模块

复杂的前端项目需要做分层处理,按照功能、业务、组件拆分成模块, 模块化的项目至少有以下优点:

  1. 便于单元测试

  2. 便于同事间协作

  3. 抽离公共方法,开发快捷

  4. 按需加载,性能优秀

  5. 高内聚低耦合

  6. 防止变量冲突

  7. 方便代码项目维护


几种模块化规范

  • 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 中模块类型

  1. 内置模块,属于核心模块,无需安装,在项目中不需要相对路径引用, Node 自身提供。

  2. 文件模块,程序员自己书写的 js 文件模块。

  3. 第三方模块, 需要安装, 安装之后不用加路径。

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 模块加载器

首先导入依赖的模块 pathfs,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)

1. 配置 debug,本质是配置.vscode/launch.json 文件,而这个文件的本质是能提供多个启动命令入口选择。
一些常见参数如下:
  • program 控制启动文件的路径(即入口文件)

  • name 下拉菜单中显示的名称(该命令对应的入口名称)

  • request 分为 launch(启动)和 attach(附加)(进程已经启动)

  • skipFiles 指定单步调试跳过的代码

  • runtimeExecutable 设置运行时可执行文件,默认是 node,可以设置成 nodemon,ts-node,npm 等

修改 launch.json,skipFiles 指定单步调试跳过的代码
  1. 将 test.js 文件中的 require 方法所在行前面打断点

  2. 执行调试,进入源码相关入口方法


梳理代码步骤

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 方法的整个流程代码已经调试完毕,通过对源代码的调试,可以帮助我们学习其实现思路,代码风格及规范,有助于帮助我们实现工具库,提升我们的代码思路,同时我们知道相关原理,也对我们解决日常开发工作中遇到的问题提供帮助。


END



俄罗斯鸿蒙时代要来了吗?



这里有最新开源资讯、软件更新、技术干货等内容
点这里 ↓↓↓ 记得 关注✔ 标星⭐ 哦~

微信扫码关注该文公众号作者

戳这里提交新闻线索和高质量文章给我们。
相关阅读
石正丽的更危险毒株致死率90%背后隐藏什么NJSD Generative AI 2023 最终议程发布刚刚,美国顶尖AI基金会宣布拥抱华为,中国这些尖端技术真的让白宫很担忧几乎一统美国充电江山 现代与KIA加入特斯拉NACS规格TUM、LMU食堂纷纷罢工!泼天的富贵这次轮到Döner店了?[9月26日]科学历史上的今天——金·赫尔尼(Jean Amédée Hoerni)她呼吁限制中国获取半导体和尖端技术,中方回应澳洲特斯拉召回Model 3 和Model YNat Immunol |利用改良的CAR-T细胞提高癌症免疫疗法的疗效特斯拉又降价了!Model 3、Model Y在美售价再降!Costco员工福利这么好?10个你不知道的Costco员工政策,看完你想去应聘吗?修复 Arch Linux 中的 “Bash: man command not found” 错误 | Linux 中国真相或许比想象的更。。。。快消外企社招 | Lagardère拉格代尔,世界三大旅行零售商之一,百年外企,平均月薪17.2k,0经验可投,留学生有优势住在西班牙的柴静,还是原来的柴静----长周末Get Away, 时光中的孟菲斯股价一度暴涨340%,Bionomics的PTSD药物试验结果积极,有望成为20年来首款获批新疗法nǚ hóng?nǚ gōngHiring | Communications Manager of AG's political committee分布式锁实现原理与最佳实践全文丨国家出台指导意见规范实施政府和社会资本合作新机制发改委 财政部《关于规范实施政府和社会资本合作新机制的指导意见》极致性能优化:前端SSR渲染利器Qwik.js鸿发超市「2000 万美元」买下82街前Walmart超市!开设第4家Hông Phát分店!红色日记 不迷航 5.16-31零一万物Yi-VL多模态大模型开源,MMMU、CMMMU两大权威榜单领先【12月7日】始终走在基因组技术前端-Oxford Nanopore核心实验室线上研讨会面试官:Nacos 为什么这么强!讲讲实现原理?我懵了。。“根本不需要TypeScript,JS+JSDoc够了”,大佬说我想多了Nat Commun | 我国科学家开发出微型化的Cas13,有望简化基因编辑前端monorepo大仓权限设计的思考与实现频繁"上新"!REITs规模突破千亿在即,这一特征逐渐凸显Node.js 21发布,升级V8引擎,带来稳定的WebSteams模块和Test Runnernodejs 实现MQTT协议的服务器端和客户端的双向交互Next.js支持在前端代码中写SQL,开倒车还是遥遥领先?
logo
联系我们隐私协议©2024 redian.news
Redian新闻
Redian.news刊载任何文章,不代表同意其说法或描述,仅为提供更多信息,也不构成任何建议。文章信息的合法性及真实性由其作者负责,与Redian.news及其运营公司无关。欢迎投稿,如发现稿件侵权,或作者不愿在本网发表文章,请版权拥有者通知本网处理。