跳到主要内容

Commonjs 模块化加载原理解析

Commonjs 是在 node 中引入和导出模块的一套模块化规范,使用时通过 require 引入模块,通过 module.exports/exports 导出模块内容。

在 nodejs 中,模块可分为三类

  • 内置模块:node 内置的一些核心模块,比如:path、fs
  • 用户模块:用户项目里写的 js 文件
  • 第三方模块:node_modules 下的模块

在 node 中,导入模块时,每个模块都会被包一层模块加载函数, 这个函数有多个参数分别是 exports、require、module、__filename、__dirname,所以我们在每个模块文件使用的这些变量,并不是全局的变量,只是当前 Module Context(模块上下文)提供的模块级别的变量

下面通过理解 require 函数,来深入理解一下 Commonjs 的原理,大致分为以下几个步骤

  1. 开始
  2. 解析出文件名
  3. 创建模块
  4. 加载模块
  5. 返回模块
调试 require 函数

可以使用 vscode 的断点调试,看看内部的具体原理,创建 launch.json, 改成以下内容

{
"version": "0.2.0",
"configurations": [{
"type": "node",
"request": "launch",
"name": "Launch Program",
"skipFiles": [],
"program": "${workspaceFolder}/index.js"
}]
}

require 的加载原理​

边调试边看我这块内容,不然可能看不懂

开始​

require 最终调的方法是 Module._load 的静态方法,可以看下调用栈

Alt text

require 的核心逻辑都在这个方法里,主要做了这几件事

  1. 首先会查看模块 id 是否以 node:,判断是否是内置模块,返回 module.exports
  2. 调用 Module._resolveFilename 获取目标模块正确的路径(是一个绝对路径)
  3. 查看 Module._cache 是否缓存过该模块,如果缓存过就取出模块,返回模块 cachedModule.exports;否则继续下一步
  4. 创建模块 new Module(id),并缓存模块
  5. 调用模块实例的 module.load 方法来解析、加载模块
  6. 返回模块导出信息 module.exports

解析出模块的绝对路径​

这个步骤主要在 Module._resolveFilename 的方法中实现的

首先判断是否是内置模块,满足下面任意一个条件就是 node 的内置模块了,直接返回 filename

  1. 首先会查看模块 id 是否以 node: 开始
  2. BuiltinModule.map.get(filename) 是否可以匹配到模块

不是内置模块,接着走下面的步骤

  1. 获取模块可能出现的所有的路径数组 Module._resolveLookupPaths,赋值给 paths 变量
    1. 用户模块:父级模块所在的目录;没有父级模块就返回项目的根目录
    2. 第三方模块:拿到父模块的 paths (见标题加载模块的 1 内容),拼接上 modulePaths (modulePaths 用于查找全局安装的模块)
  2. 调用 Module._findPath() ,确认模块的正确路径
    1. 首先判断模块 id 是否是一个绝对路径,是的话,就将可能的路径 paths 重置为 [""],不是就向下执行
    2. 生成模块的 cacheKey,并判断 Module._pathCache 是否缓存了模块的 path,缓存了就直接返回缓存的 path
    3. 记录模块 id 是否以 / 结束的
    4. 遍历所有可能的路径 paths,
      1. 拼接出模块可能存在的绝对路径 basePath,声明模块的正确路径 filename
      2. 调用 _stat,判断 basePath 的类型(存在的情况:0 是文件,1 是目录)
        1. basePath 是一个文件,将 filename 赋值为 basePath,否则下一步
        2. 尝试给 basePath 加上扩展名来匹配(调用 tryExtensions ),如果匹配到了,就将 filename 赋值为 basePath + ext
        3. basePath 是一个 目录,先尝试使用 pkg.main 来匹配,没有 main,就在 basePath 拼上 index(即:basePath/index[ext]),在调用 tryExtensions 来匹配,如果匹配到了,就将 filename 赋值为 basePath + index + ext
        4. 如果通过上面三个步骤匹配到了模块的正确路径,就缓存模块的正确路径 Module._pathCache[cacheKey] = filename;,返回 filename
      3. 如果没找到,就继续循环
    5. 没有找到模块的路径,就返回 false
  3. Module._findPath() 返回值为 false,就报错 Cannot find module '${request}',找到了,就继续下面的步骤

内置模块?​

调用 loadBuiltinModule(filename, moduleId),判断是否是内置模块

  1. BuiltinModule.map.get(filename) 查看是否有 filename 模块,有就是内置模块
  2. 是内置模块,就调用 mod.compileForPublicLoader() 编译加载内置模块
  3. 返回内置模块的导出内容 mod.exports

创建模块​

  1. 通过 filename 在 Module_cache 缓存中,查找是否已经缓存过该模块了
  2. 声明模块,缓存中有当前模块 filename,就赋值给 module,否则就新建一个模块 new Module(filename, parent)
  3. 缓存模块对象 Module._cache[filename] = module
  4. 调用模块的 load 方法来加载模块内容 module.load(filename);

加载模块​

这个主要是调用 module.load(filename); 的逻辑

  1. 给当前模块赋值 filename paths,paths 是 node_modules 的查找目录

Alt text

  1. 获取模块的扩展名 extension
  2. 调用模块的加载策略 Module._extensions[extension](this, filename),Module._extensions 有三个策略 .js .json .node
    1. .js 的加载策略
      1. 声明一个接受模块代码内容的变量 content
      2. 查看缓存 cjsParseCache 中是否缓存了模块的源代码,有就赋值给 content;否则使用 fs.readFileSync 读取模块源代码,然后赋值给 content
      3. 编译模块 module._compile(content, filename);
        1. 使用 vm.compileFunction 创建编译函数,函数参数是 exports, require, module, __filename, __dirname,函数体是模块的源码
        2. 使用 Reflect.apply 执行创建的编译函数,这个过程,会将模块的代码也都执行了一遍
        3. 返回执行的结果
    2. .json 的加载策略
      1. 读取 json 模块内容
      2. 调用 JSON.parse,将结果赋值给 module.exports
  3. 标识模块已加载完成 this.loaded = true

返回模块​

返回模块的导出内容 module.exports

模块加载规则​

结合上面 require 的原理,可以总结三类模块的加载规则

内置模块​

内置模块的引用方式有两种

const fs = require("fs");
const fs = require("node:fs");

内置模块优先级最高,会优先匹配,

  1. 判断 request(模块标识符)是否是内置模块
  2. 编译加载内置模块
  3. 返回内置模块的 module.exports

第三方模块​

引用方式

const fs = require("express");
  1. 拼接模块可能存在的路径,拼接规则:父级模块的 paths 和 全局模块的存放位置

Alt text

  1. 遍历路径
    1. node_modules 是否存在模块的文件 (module.{js,json,node})
    2. node_modules 是否存在模块名称的目录
      1. 先匹配 package.json 中的 main 文件
      2. 没有 main,自一次匹配模块目录下的 module/index.{js,json,node}
    3. 没有就接着遍历下一个路径
  2. 路径遍历完,还没找到,报错
  3. 找到了就编译执行模块,返回 module.exports

用户模块​

用户模块就是用户写的代码文件

引用方式

const test = require("./test");
const test = require("./test.js");
const test = require("../test");
  1. 优先匹配文件 user_module.{js,json,node}
  2. 没有就匹配文件夹: 父级目录拼接 require 传的参数 parentDir + require 参数
    1. 先匹配文件夹 package.json 中的 main 文件
    2. 没有 main,自一次匹配模块目录下的 module/index.{js,json,node}
  3. 找到了就编译执行模块,返回 module.exports;没找到文件就报错