模块加载源码分析

配置 vscode 调试

用 vscode 打开文件夹,在里面创建两个文件:

  • m.js 作为被加载的模块文件
  • require-load.js 作为加载模块的文件
const obj = require('./m')
module.exports = {
  foo: 123
}

打个断点:

node_modules 修改不生效 node_modules .staging_visual studio code

创建 vscode 调试配置文件:

点击左边的【运行和调试】,点击【创建 launch.json 文件】,选择【Node.js】

node_modules 修改不生效 node_modules .staging_vscode_02

修改配置文件:

{
  // 使用 IntelliSense 了解相关属性。
  // 悬停以查看现有属性的描述。
  // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Launch Program",
      // 忽略调试的文件
      "skipFiles": [
        // "<node_internals>/**" 是 node 内部模块
        // 本例要分析源码,所以不能忽略,将其注释
        // 注意:不能直接注释 skipFiles,否则 vscode 仍会采用默认值
        //   "<node_internals>/**"
      ],
      // 启动程序后需要打开的文件,修改成正确的地址即可
      "program": "${workspaceFolder}\\require-load.js"
    }
  ]
}

点击绿色的按钮开始调试,代码运行到断点,顶部有调试进度工具,使用同 Chrome:

node_modules 修改不生效 node_modules .staging_缓存_03

点击单步调试箭头(F11)进入 require 源码,它调用了另一个 require 方法:

require = function require(path) {
      return mod.require(path);
    };

Module.prototype.require

继续 F11 查看这个方法:

// Loads a module at the given file path. Returns that module's
// `exports` property.
Module.prototype.require = function(id) {
  // `id` 就是模块路径(当前是`./m`)
  // 先是对其进行了一些校验
  validateString(id, 'id');
  if (id === '') {
    throw new ERR_INVALID_ARG_VALUE('id', id,
                                    'must be a non-empty string');
  }
  requireDepth++;
  try {
    // 最终调用 `Module._load` 方法,这个方法就是 `reuqire` 的主要逻辑
    // 第一个参数:模块地址
    // 第二个参数:当前 Module 的实例
    // 第三个参数:是否主模块
    return Module._load(id, this, /* isMain */ false);
  } finally {
    requireDepth--;
  }
};

Module._load

Module._load = function(request, parent, isMain) {
  let relResolveCacheIdentifier;
  if (parent) {
    debug('Module._load REQUEST %s parent: %s', request, parent.id);
    // Fast path for (lazy loaded) modules in the same directory. The indirect
    // caching is required to allow cache invalidation without changing the old
    // cache key names.
    relResolveCacheIdentifier = `${parent.path}\x00${request}`;
    // 使用 parent 拼接缓存的 key
    const filename = relativeResolveCache[relResolveCacheIdentifier];
    if (filename !== undefined) {
      // 从缓存中查找 - 缓存优先
      const cachedModule = Module._cache[filename];
      if (cachedModule !== undefined) {
        updateChildren(parent, cachedModule, true);
        // 如果找到,直接返回它的 exports
        return cachedModule.exports;
      }
      delete relativeResolveCache[relResolveCacheIdentifier];
    }
  }

  // 到这里表示根据 parent 查找缓存失败
  
  // _resolveFilename 方法用于将模块地址转换成绝对路径
  const filename = Module._resolveFilename(request, parent, isMain);

  // 使用绝对路径作为 key 去查找缓存 - 缓存优先
  const cachedModule = Module._cache[filename];
  if (cachedModule !== undefined) {
    updateChildren(parent, cachedModule, true);
    const parseCachedModule = cjsParseCache.get(cachedModule);
    if (parseCachedModule && !parseCachedModule.loaded)
      parseCachedModule.loaded = true;
    else
      // 如果找到,直接返回它的 exports
      return cachedModule.exports;
  }

  // 如果是内置的核心模块,则加载 - 缓存优先后是内置模块优先
  const mod = loadNativeModule(filename, request);
  // 如果加载成功,直接返回它的 exports
  if (mod && mod.canBeRequiredByUsers) return mod.exports;

  // Don't call updateChildren(), Module constructor already does.
  // 获取缓存对象,如果没有则创建一个空的对象,用于加载模块
  // Module 实例化的对象就是每个模块下的 `module` 属性,可以查看一下它的源码了解 `module` 对象的属性
  const module = cachedModule || new Module(filename, parent);

  if (isMain) {
    process.mainModule = module;
    module.id = '.';
  }

  // 缓存当前的 module
  Module._cache[filename] = module;
  if (parent !== undefined) {
    relativeResolveCache[relResolveCacheIdentifier] = filename;
  }

  let threw = true;
  try {
    // Intercept exceptions that occur during the first tick and rekey them
    // on error instance rather than module instance (which will immediately be
    // garbage collected).
    if (enableSourceMaps) {
      try {
        module.load(filename);
      } catch (err) {
        rekeySourceMap(Module._cache[filename], err);
        throw err; /* node-do-not-add-exception-line */
      }
    } else {
      // 调用通过 module 原型定义的 load 方法加载模块(可以先跳到下面查看源码,再返回这里)
      // 实际上就是获取模块中的 exports(填充到 module 本身上)
      module.load(filename);
    }
    threw = false;
  } finally {
    // ...
  }

  // 最终返回/导出 exports (当前就是 {foo: 123} )
  // 到这里基本上 require('./m') 就完成了
  return module.exports;
};

Module 构造函数

这个构造函数创建的实例就是每个模块下的 module 属性。

function Module(id = '', parent) {
  this.id = id;
  this.path = path.dirname(id);
  this.exports = {};
  this.parent = parent;
  updateChildren(parent, this, false);
  this.filename = null;
  this.loaded = false;
  this.children = [];
}

Module.prototype.load

// Given a file name, pass it to the proper extension handler.
Module.prototype.load = function(filename) {
  debug('load %j for module %j', filename, this.id);

  assert(!this.loaded);
  // 在实例上添加设置一些属性
  // 绝对路径
  this.filename = filename;
  // 查找路径
  this.paths = Module._nodeModulePaths(path.dirname(filename));

  // 获取文件扩展名(当前是 `.js`)
  const extension = findLongestRegisteredExtension(filename);
  // allow .mjs to be overridden
  if (filename.endsWith('.mjs') && !Module._extensions['.mjs']) {
    throw new ERR_REQUIRE_ESM(filename);
  }
  // 调用相应的文件处理函数进行解析(可以优先跳到下面查看源码,再返回这里)
  // _extensions 包含三个方法 { .js: f, .json(): f, .node: f }
  Module._extensions[extension](this, filename);
  // 经过解析(当前就是 js 模块内容被执行并填充到模块实例中)
  // 变更加载状态
  this.loaded = true;

  // ...
};

Module._extensions['.js']

// Native extension for .js
Module._extensions['.js'] = function(module, filename) {
  if (filename.endsWith('.js')) {
    const pkg = readPackageScope(filename);
    // Function require shouldn't be used in ES modules.
    if (pkg && pkg.data && pkg.data.type === 'module') {
      const parentPath = module.parent && module.parent.filename;
      const packageJsonPath = path.resolve(pkg.path, 'package.json');
      throw new ERR_REQUIRE_ESM(filename, parentPath, packageJsonPath);
    }
  }
  // If already analyzed the source, then it will be cached.
  // 从懒加载的缓存中查找资源
  const cached = cjsParseCache.get(module);
  // content 用于存储模块文件(当前是 m.js)中读取的内容(文本字符串,不是可运行的代码)
  let content;
  if (cached && cached.source) {
    content = cached.source;
    cached.source = undefined;
  } else {
    // 同步读取文件
    content = fs.readFileSync(filename, 'utf8');
  }
  // 运行文件内容,提取相关变量
  module._compile(content, filename);
};

module._compile

源码

该方法最终通过将文件的文本字符串包装成一个函数。由于嵌套太深,本例不去查找具体实现,可以直接跳到一些方法调用之后查看调用的结果。

// Run the file contents in the correct scope or sandbox. Expose
// the correct helper variables (require, module, exports) to
// the file.
// Returns exception, if any.
Module.prototype._compile = function(content, filename) {
  let moduleURL;
  let redirects;
  if (manifest) {
    moduleURL = pathToFileURL(filename);
    redirects = manifest.getRedirector(moduleURL);
    manifest.assertIntegrity(moduleURL, content);
  }

  maybeCacheSourceMap(filename, content, this);
  // 将模块的内容包装成一个接收 `'exports , require , module , __filename , __dirname'` 参数的函数
  // 因为有了这层包装,所以在 NodeJS 任意模块中都可以直接使用的这个5个参数所表示的变量
  // 之后将被调用并传入相应的实参
  const compiledWrapper = wrapSafe(filename, content, this);

  var inspectorWrapper = null;
  // ...
  
  // 准备 compiledWrapper 方法的参数
  const dirname = path.dirname(filename);
  const require = makeRequireFunction(this, redirects);
  let result;
  const exports = this.exports;
  const thisValue = exports;
  const module = this;
  if (requireDepth === 0) statCache = new Map();
  if (inspectorWrapper) {
    result = inspectorWrapper(compiledWrapper, thisValue, exports,
                              require, module, filename, dirname);
  } else {
    // 调用包装的函数并传递参数
    // 内部利用 Node 的 vm 模块实现安全的执行,它是类似于虚拟机的沙箱环境
    // 这里将 this 指向了 thisValue 实际上就是模块实例的 exports
    // 这也就是为什么在 Nodejs 模块中使用 this 时它指向的是一个空对象,而不是 global 的原因
    result = compiledWrapper.call(thisValue, exports, require, module,
                                  filename, dirname);
  }
  hasLoadedAnyUserCJSModule = true;
  if (requireDepth === 0) statCache = null;
  return result;
};

包装的方法

这里将模块内容包装成了一个方法,接收 5 个参数,之后再被调用并传递这 5 个参数。

所以这 5 个参数就是所有模块中都可以直接使用的变量。

node_modules 修改不生效 node_modules .staging_缓存_04

包装的原理大致是在模块内容前后拼接函数声明字符串,就像:

var content = `\nmodule.exports = {\r\n foo: 123\r\n}\r\n\n`
var packFunction = 'function(exports,require,module,__filename,__dirname){' + content + '}'

然后在 vm 创建的沙箱环境中将这个字符串作为 JS 运行,获取这个函数。

效果类似:

var content = `\nmodule.exports = {\r\n foo: 123\r\n}\r\n\n`
var compiledWrapper = new Function('exports , require , module , __filename , __dirname', content)

关于 this 指向

在调用包装的方法并传递参数的时候绑定了 thisthis.exports,它的初始值就是一个空对象,所以在模块中定义 exports 内容之前,this 都指向一个空对象。

// 准备 compiledWrapper 方法的参数
const dirname = path.dirname(filename);
const require = makeRequireFunction(this, redirects);
let result;
const exports = this.exports;
const thisValue = exports;
const module = this;

// 这里将 this 指向了 thisValue 实际上就是模块实例的 exports
// 这也就是为什么在 Nodejs 模块中使用 this 时它指向的是一个空对象,而不是 global 的原因
result = compiledWrapper.call(thisValue, exports, require, module,
                              filename, dirname);

模块示例:

console.log(this) // {}

// 修改 exports 对象的属性,并没有改变 exports 的引用
exports.foo = 123

console.log(this) // { foo: 123}

但是如果直接用 module.exports = {} 修改 exports,因为修改的是它的引用,所以 this 指向的对象仍是原来的 exports 地址:

console.log(this) // {}

// module.exports 的引用地址变更,已经不再指向原来的 {}
module.exports = {
  foo: 123
}

// this 仍指向原来的 {}
console.log(this) // {}