node模块机制&实现丐版require

模块加载&编译机制&require

路径分析

  • NodeJS 中的模块可以通过 文件路径 或者 名字 获取到模块,模块的引用会映射到一个 JS 的文件
  • 主要分为两种类型的模块:
    • Node 提供的模块,称为核心模块,内置模块公开了一些常用的 API 给开发者,并且它们在 Node 进程开始的时候就预加载了
    • 由用户编写的模块,称为 文件模块 ,通常是 npm 包或者是本地模块,通常会暴露一个公开的 API ,供其他开发者使用

核心模块是Node源码在编译过程中编译进了二进制执行文件。在Node启动时这些模块就被加载进内存中,所以核心模块引入时省去了文件定位和编译执行两个步骤,并且在路径分析中优先判断,因此核心模块的加载速度是最快的。文件模块则是在运行时动态加载,速度比核心模块慢

模块载入

  • 模块的载入是有先后顺序的,这里简单介绍一下
  1. 载入内置模块(A Core Module
  2. 载入文件模块(A File Module),可以忽略 .js, .json, .node后缀,如果不存在对应的文件,就将这个路径作为文件夹加载(步骤3)
  3. 载入文件目录模块(A Folder Module)此时的策略是:
    • 先找寻该目录下的 package.json文件,然后 parse 出来,读取 mainindex字段中指定的文件(main 的优先级高)
    • 如果都没有指定,那么会默认寻找 index.js 文件
    • 如果 index.js 文件也不存在,则报错
  4. 载入 node_modules 里的模块
    • 如果 传入的模块名称不是 路径,也不是核心模块,那么 会从 当前目录的 node_modules 文件夹搜索,如果当前目录下的 node_modules 找不到,则会从父目录的 node_modules 搜索,这样递归搜索,知道当前项目的根目录
  5. 自动缓存已载入模块,对于已加载的模块Node会缓存下来,而不必每次都重新搜索,因为寻找模块是一件很费时的事情,所以引入了缓存机制

模块编译

  • 每个模块文件模块都是一个对象,他的定义如下:
js 复制代码
function Module(id = '', parent) {
  this.id = id;
  this.path = path.dirname(id);
  this.exports = {};
  moduleParentCache.set(this, parent);
  updateChildren(parent, this, false);
  this.filename = null;
  this.loaded = false;
  this.children = [];
}
  • 对于不同的拓展名,编译的方法也是不同的
    • .js 通过 fs 模块 同步 读取文件后编译执行
    • .json 通过 fs 模块 同步 读取文件后,用 JSON.parse() 解析返回结果
    • .node 这是由 C/C++ 编写的拓展文件,通过 dlopen() 方法加载编译生成的文件
    • 对于其他的文件,一律当作 .js 处理
  • 每一个编译成功的模块都会将其文件路径作为 key 缓存再 Module_cache 对象上
  • 主要是通过 Module._extensions[xxx] 的方法来实现 编译,后续我们会自己实现一个简易版

js 模块编译

  • 编译过程中,Node 会对 读取的 js 文件包装一层,方便注入一些 require, __dirname 等全局变量,以及执行
js 复制代码
(function (exports, require, module, __filename, __dirname) {
	......
})
  • 包装后的代码,会通过 vm 原生模块的 runInThisContext 方法编译,这个方法接受一个字符串,将其转化为函数,并且指定明确的上下文,不污染全局
  • 此方法用于创建一个独立的沙箱运行空间,cotent 内的代码可以访问外部的 global 对象,但是不能访问其他变量

实现一个简易版 require

  • 从我们之前了解到的 模块机制中 不难发现,其实 require 和 module.exports 干的事情并不复杂,假设有一个全局的对象 ,初始值为 {},当我们 require 的文件,就将这个文件拿出来执行,如果这个文件里存在 module.exports 的语句,则当运行到这行代码的时候,将module.exports的值加入这个对象,key为对应的 文件名,最终,这对象会变成这样:
js 复制代码
{
	'a.js': 'hello',
	'b.js': function hello() {},
	'c.js': 2,
	'd.js': { d: 2 }
}
  • 当再次require 这个文件时,如果这个对象里面有对应的值,就直接返回,如果没有就重复前面的步骤,执行目标文件,然后将它的module.exports加入这个全局对象,并返回给调用者。
  • 这个全局对象其实就是上面说的的缓存机制(Module._cache)。
  • 所以requiremodule.exports并没有什么黑魔法,就只是运行并获取目标文件的值,然后加入缓存,用的时候拿出来用就行。
  • 再看看这个对象,因为d.js是一个引用类型,所以在任何地方获取了这个引用都可以更改其中的值,如果不希望自己模块的值被更改,需要在写模块时进行处理,比如使用Object.freeze()Object.defineProperty()之类的方法冻结住

实现

  • 思路
    1. 通过传入的 id 找到对应的文件
    2. 执行找到的文件,同时要注入modulerequire这些方法和属性,以便模块文件使用
    3. 返回模块的module.exports
  • 实现细节如下:
js 复制代码
const path = require('path')
const fs = require('fs')
const vm = require('vm')

function MyModule(id = '') {
  this.id = id
  this.filename = path.resolve(id)
  this.exports = {}
  this.filename = null
  this.loaded = false
}

// require 实际上是 调用 _load 方法
MyModule.require = function (id) {
  return MyModule._load(id)
}

// 存放缓存模块
MyModule._cache = Object.create(null)

// 加载 模块
// 返回 module.exports
MyModule._load = function (id) {
  // 根据 id 解析出 filename
  const filename = MyModule._resolvePath(id)

  // 根据 filename 判断是否有 缓存,有的话,直接返回 缓存的 exports 即可
  const cachedModule = MyModule._cache[filename]
  if (cachedModule !== undefined) {
    return cachedModule.exports
  }

  // 没有缓存 则构造一个模块 并且调用 load 方法加载
  const module = new MyModule(filename)
  // load 之前就将这个模块缓存下来,这样如果有循环引用就会拿到这个缓存,但是这个缓存里面的 exports 可能还没有或者不完整
  MyModule._cache[filename] = module
  module.load(filename)

  return module.exports
}

MyModule._resolvePath = function (id) {
  const filename = path.resolve(id)
  const extname = path.extname(id)

  // 如果 没有后缀,则尝试找寻,添加后缀,找到则返回
  if (!extname) {
    const exts = Object.keys(MyModule._extensions)
    for (const ext of exts) {
      const curPath = `${filename}${ext}`
      if (fs.existsSync(curPath)) {
        return curPath
      }
    }
  }
  return filename
}

MyModule.load = function (filename) {
  // 拿到 后缀,根据后缀,调用相应的 加载函数 来处理
  const extname = path.extname(filename)
  MyModule._extensions[extname](this, filename)
  this.loaded = true
}

// js 文件的 加载函数
MyModule._extensions['.js'] = function (module, filename) {
  // 思路很简单,读取文件内容,随后编译,执行该文件
  const content = fs.readFileSync(filename, 'utf-8')
  module._compile(content, filename)
}

// 编译 文件
// 首先,我们在执行 js 的时候,是可以拿到 一些全局变量,例如 require exports module __filename __dirname
// 所以,在执行前,我们要注入这些变量
// 实现也很简单,通过 IIFE 的形式注入即可
// 所以,这里我们需要将 js 代码包裹一层 IIFE,后续 执行的时候,可以注入这些变量
MyModule._compile = function (content, filename) {
  // 将 读取的代码 包裹起来
  const wrappedFn = MyModule._wrap(content)

  // vm 是 nodejs 的虚拟机沙盒模块,runInThisContext 方法可以接受一个字符串并将它转化为一个函数
  // 返回值就是转化后的函数,所以 compiledFn 是一个函数
  const compiledFn = vm.runInThisContext(wrappedFn, {
    filename,
    lineOffset: 0,
    displayErrors: true,
  })
  const dirname = path.resolve(filename)

  // 执行 这段代码,注入全局变量
  // 代码中 会有类似 module.exports.xxx = xxx,这就为 this.exports 挂载上了 导出的属性
  compiledFn.call(
    // call 的第一个参数就是 this,这里传入了 this.exports, 也就是 module.exports
    // 这也是为什么在 js 文件里面 this 是对 module.exports 的一个引用
    this.exports,
    this.exports,
    this.require,
    this, // module 就是 this,指向当前模块
    filename,
    dirname
  )
}

// 需要包裹的部分
// 注意拼接的开头和结尾多了一个()包裹,这样我们后面可以拿到这个匿名函数
// 拿到后,在后面再加一个 () 就可以传参数执行了。
MyModule.wrapper = [
  '(function(exports, require, module, __filename, __dirname){',
  '\n})',
]
// 包裹函数
// 将需要执行的 js 代码拼接到这个方法中间
MyModule._wrap = function (content) {
  return MyModule.wrapper[0] + content + MyModule.wrapper[1]
}

相关文章

nodejs文档

深入Node.js的模块加载机制,手写require函数

深入了解Nodejs模块机制

相关推荐
_.Switch7 分钟前
Python Web 架构设计与性能优化
开发语言·前端·数据库·后端·python·架构·log4j
libai10 分钟前
STM32 USB HOST CDC 驱动CH340
java·前端·stm32
南斯拉夫的铁托38 分钟前
(PySpark)RDD实验实战——取最大数出现的次数
java·javascript·spark
Java搬砖组长41 分钟前
html外部链接css怎么引用
前端
GoppViper1 小时前
uniapp js修改数组某个下标以外的所有值
开发语言·前端·javascript·前端框架·uni-app·前端开发
丶白泽1 小时前
重修设计模式-结构型-适配器模式
前端·设计模式·适配器模式
程序员小羊!1 小时前
UI自动化测试(python)Web端4.0
前端·python·ui
破z晓1 小时前
OpenLayers 开源的Web GIS引擎 - 地图初始化
前端·开源
好看资源平台1 小时前
JavaScript 数据可视化:前端开发的核心工具
开发语言·javascript·信息可视化
维生素C++1 小时前
【可变模板参数】
linux·服务器·c语言·前端·数据结构·c++·算法