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

2023年 12月 7日 32.8k 0

了解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)

    1.配置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 转载请注明来源

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论