文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

前端技术探秘-Node.js的CommonJS规范实现原理

2024-11-30 04:29

关注

模块的问题

为什么要有模块

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

  1. 便于单元测试
  2. 便于同事间协作
  3. 抽离公共方法, 开发快捷
  4. 按需加载, 性能优秀
  5. 高内聚低耦合
  6. 防止变量冲突
  7. 方便代码项目维护

几种模块化规范

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

首先导入依赖的模块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文件,而这个文件的本质是能提供多个启动命令入口选择。

一些常见参数如下:

修改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 转载请注明来源

来源:今日头条内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     813人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     354人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     318人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     435人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     224人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-后端开发
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯