前端技术探秘-Nodejs的CommonJS规范实现原理 | 京东物流技术团队

了解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 scopenew Function('console.log(test1)')() // 123new 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)

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

作者:京东物流 乔盼盼

来源:京东云开发者社区 自猿其说Tech 转载请注明来源

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/215732.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

【深度学习】P1 数据缺失值预处理

数据缺失值预处理 创建数据集展示数据集缺失值处理 创建数据集 首先创建一个人工数据集,作为下文对数据缺失值预处理的案例, import osos.makedirs(os.path.join(.., data), exist_okTrue) data_file os.path.join(.., data, house_tiny.csv) with op…

关于vs code Debug调试时候出现“找不到任务C/C++: g++.exe build active file” 解决方法

vs code Debug调试时候出现“找不到任务C/C: g.exe build active file” ,出现报错,Debug失败 后来经过摸索和上网查找资料解决问题 方法如下 在Vs code的操作页面左侧有几个配置文件 红框里的是需要将要修改的文件 查看tasks.json和launch.json框选&…

类和对象(下)

目录 1.初始化列表 1.1 构造函数体内的赋值 1.2 初始化列表 1.对象整体定义和成员变量定义的区别 2.初始化列表的写法 1.3 和C11的联系 1.4 针对初始化列表的建议 2.静态成员 2.1 静态成员变量 1.概念 2.特性 2.2 静态成员函数 1.概念 2.特性 3.友元 3.1 友元函…

linux进程调度(一)-进程概述

一、什么是进程 进程是指计算机已运行的程序。程序本身只是指令、数据及其组织形式的描述。进程就是一个程序的执行实例,也就是正在执行的程序。在linux操作系统的中,进程就是一个担当分配系统资源CPU时间、内存的实体。进程控制的主要功能是对系统中的所…

Shell循环:for(二)

一、通过用户列表文件创建用户 需求:通过用户列表文件创建用户 [rootlocalhost ~]# cat user.txt qian yoa huang演示: [rootlocalhost ~]# vim foruser.sh #编写脚本 #!/bin/bash for i in cat user.txt do useradd $i if [ $? -eq 0 ] thenech…

思科模拟器操作命令

模式 思科模拟器常见的模式有 用户模式 能够操作的命令比较少 特权模式特权模式下面可以操作的比较多 全局模式 接口模式 用户模式进入特权模式: 命令enable 特权模式进行全局模式命令: configure terminal 退出命令 exit命令:返回上一层,即一步一步…

canvas高级动画001:文字瀑布流

canvas实例应用100 专栏提供canvas的基础知识,高级动画,相关应用扩展等信息。 canvas作为html的一部分,是图像图标地图可视化的一个重要的基础,学好了canvas,在其他的一些应用上将会起到非常重要的帮助。 文章目录 示例…

拿捏---JVM原理

文章目录 JVM内存划分JVM类加载为什么需要类加载?类加载的过程何时触发类加载?双亲委派模型 JVM的垃圾回收机制(GC)什么是垃圾回收?GC回收哪部分内存?回收机制怎么找出垃圾?引用计数可达性分析&…

物流实时数仓:数仓搭建(ODS)

系列文章目录 物流实时数仓:采集通道搭建 物流实时数仓:数仓搭建 文章目录 系列文章目录前言一、IDEA环境准备1.pom.xml2.目录创建 二、代码编写1.log4j.properties2.CreateEnvUtil.java3.KafkaUtil.java4.OdsApp.java 三、代码测试总结 前言 现在我们…

大模型推理加速框架vllm部署的实战方案

大家好,我是herosunly。985院校硕士毕业,现担任算法研究员一职,热衷于机器学习算法研究与应用。曾获得阿里云天池比赛第一名,CCF比赛第二名,科大讯飞比赛第三名。拥有多项发明专利。对机器学习和深度学习拥有自己独到的见解。曾经辅导过若干个非计算机专业的学生进入到算法…

堆的实现(堆的插入、堆的删除等)超级全

堆的实现(堆的插入、堆的删除等)超级全 文章目录 堆的实现(堆的插入、堆的删除等)超级全一、前期基础知识1.树结构①树的定义②树的相关概念③二叉树④满二叉树和完全二叉树a.满二叉树b.完全二叉树 ⑤二叉树的性质⑥二叉树顺序结构…

YOLOv8改进 | 2023 | LSKAttention大核注意力机制助力极限涨点

论文地址:官方论文地址 代码地址:官方代码地址 一、本文介绍 在这篇文章中,我们将讲解如何将LSKAttention大核注意力机制应用于YOLOv8,以实现显著的性能提升。首先,我们介绍LSKAttention机制的基本原理,…