require 内部逻辑和最新版源码解读

736 阅读6分钟

Node.js 遵循 CommonJS 规范,核心是通过 require 来加载其他依赖的模块。 本文首先介绍 require 的内部逻辑,然后分析node最新版v17.1.0 中require 逻辑的源码。

require 内部逻辑

本章尝试从我们日常进行 node 开发时使用 require 的场景切入,分析 require 的内部逻辑。

日常使用 require 加载模块时,有以下三种场景:

  1. 加载 node 原生模块,如 require('fs')
  2. 加载自己项目中的文件,通常以相对路径或绝对路径表示,如:require('./utils')
  3. 加载 npm 依赖包,如 require('lodash')

根据 Node 使用手册require('X') 的内部逻辑是:

  1. 如果原生模块
  • a. 返回该模块
  • b. 不再继续执行
  1. 如果 X 以相对路径或绝对路径开头:
  • a. 根据当前使用 require 的模块路径,确定 X 的绝对路径
  • b. 把 X 当成文件,依次查找 XX.jsX.jsonX.node,只要其中一个存在,就返回该文件,停止执行;
  • c. 把 X 当成目录,首先查找 X/package.json(main 字段),如果 main 存在,返回 main 指向的文件;如果 main 字段不存在,继续查找 X/index.jsX/index.jsonX/index.node,只要其中一个存在,就返回该文件,停止执行;
  1. X 不带路径
  • a. 根据当前使用 require 的模块路径,确定 X 可能的安装路径
  • b. 依次在每个目录中,将 X 当成 文件名或目录名查找文件,重复 2 中 b c的逻辑
  1. 抛出异常,"not found"

require 源码分析

本章分析 require 的逻辑,主要在文件 lib/internal/modules/cjs/loader.js

1、require 定义

require 定义在 Module 构造函数的原型链上,因此每个模块实例都能使用 require 方法

// Loads a module at the given file path. Returns that module's
// `exports` property.
Module.prototype.require = function(id) {
  validateString(id, 'id');
  if (id === '') {
    throw new ERR_INVALID_ARG_VALUE('id', id,
                                    'must be a non-empty string');
  }
  requireDepth++;
  try {
    return Module._load(id, this, /* isMain */ false);
  } finally {
    requireDepth--;
  }
};

这里调用了 Module._load 去执行加载逻辑。

2、Module._load 方法

Module._load 方法源码比较长,这里仅留下主要逻辑:

Module._load = function(request, parent, isMain) {

// 计算出绝对路径,作为模块标识符
const filename = Module._resolveFilename(request, parent, isMain);

// 1、如果有缓存,从缓存中取出并返回
const cachedModule = Module._cache[filename];
if (cachedModule !== undefined) {
    return cachedModule.exports;
  }
// 2、判断是否是原生模块,如果是,直接返回
  const mod = loadNativeModule(filename, request);
  if (mod?.canBeRequiredByUsers) return mod.exports;
// 3、生成模块实例
const module = cachedModule || new Module(filename, parent);
// 4、放入缓存
Module._cache[filename] = module;

// 5、加载模块
  let threw = true;
  try {
    module.load(filename);
    threw = false;
  } finally {
    if (threw) {
      delete Module._cache[filename];
    }
  }
// 6、输出 模块实例的 exports 属性
  return module.exports;
}

从以上代码可知,Module._load 的关键逻辑在于两个方法:

  • Module._resolveFilename():确定模块的绝对路劲,作为模块的标识符
  • module.load():加载模块

3、Module._resolveFilename 的源码分析

Module._resolveFilename = function(request, parent) {
  // 第一步:如果是 node: 开头的内置模块,直接返回原名称
  if (StringPrototypeStartsWith(request, 'node:') ||
      NativeModule.canBeRequiredByUsers(request)) {
    return request;
  }

  // 第二步:确定所有可能的路径
  let paths;
  paths = Module._resolveLookupPaths(request, parent);

  // 第三步:从最长路径开始找,找到真实存在的路径
  // Look up the filename first, since that's the cache key.
  const filename = Module._findPath(request, paths, isMain, false);
  if (filename) return filename;
  // 没有找到则抛出错误
  let message = `Cannot find module '${request}'`;
  const err = new Error(message);
  err.code = 'MODULE_NOT_FOUND';
  throw err;
};

Module._resolveFilename 里又用到了两个方法:Module._resolveLookupPathsModule._findPath,分别用来确定所有可能的路径和根据所有可能的路径找到真实的路径。

Module._resolveLookupPaths

Module._resolveLookupPaths 是返回 modulePathsparent.paths 的值,源码关键逻辑如下:

Module._resolveLookupPaths = function(request, parent) {
    let paths = modulePaths;
    if (parent?.paths?.length) {
      paths = ArrayPrototypeConcat(parent.paths, paths);
    }
    debug('looking for %j in %j', request, paths);
    return paths.length > 0 ? paths : null;
}
// paths 的值为:
[
  '/User/user/code/dev-cli/node_modules',
  '/User/user/code/node_modules',
  '/User/user/node_modules',
  '/User/node_modules',
  '/node_modules',
  "/Users/user/.node_modules",
  "/Users/user/.node_libraries",
  "/usr/local/lib/node", 
]

其中 modulePaths 保存的是固定值三个值,即 node 安装目录和主目录下的.node_modules.node_libraries,定义在 Module._initPaths方法里:

modulePaths = [
  "/Users/user/.node_modules",
  "/Users/user/.node_libraries",
  "/usr/local/lib/node", // node 安装目录
]

parent.paths 中的值是通过 Module._nodeModulePaths(process.cwd()) 得到:

// 如果 process.cwd() 的值为 `/User/user/code/dev-cli`,则parent.paths 值为:
parent.paths = [
  '/User/user/code/dev-cli/node_modules',
  '/User/user/code/node_modules',
  '/User/user/node_modules',
  '/User/node_modules',
  '/node_modules',
]

Module._findPath

Module._findPath 从所有可能的路径中,尝试找到正确的路径。

Module._findPath = function(request, paths, isMain) {
  // 如果是绝对路径,则path 赋值为 ['']
  const absoluteRequest = path.isAbsolute(request);
  if (absoluteRequest) {
    paths = [''];
  } else if (!paths || paths.length === 0) {
    return false;
  }

  // 如果路径已经在缓存中,则直接返回
  const cacheKey = request + '\x00' + ArrayPrototypeJoin(paths, '\x00');
  const entry = Module._pathCache[cacheKey];
  if (entry)
    return entry;

  // 判断是否由 / 结尾,如果不是,判断是不是 以 . .. /. /.. 结尾
  let trailingSlash = request.length > 0 &&
  StringPrototypeCharCodeAt(request, request.length - 1) ===
  CHAR_FORWARD_SLASH;
  if (!trailingSlash) {
    trailingSlash = RegExpPrototypeTest(trailingSlashRegex, request);
  }

  let exts;
  // 遍历 paths
  for (let i = 0; i < paths.length; i++) {
    // Don't search further if path doesn't exist
    const curPath = paths[i];
    if (curPath && stat(curPath) < 1) continue;

    const basePath = path.resolve(curPath, request);
    let filename;

    const rc = stat(basePath);
    if (!trailingSlash) {
      if (rc === 0) {  // File.
        // 该文件是否存在
        filename = toRealPath(basePath);
      }

      // 如果不存在,加上后缀名看是否存在
      if (!filename) {
        // Try it with each of the extensions
        if (exts === undefined)
          exts = ObjectKeys(Module._extensions);
        filename = tryExtensions(basePath, exts, isMain);
      }
    }
    // 是目录,通过 tryPackage ,看是否存在package.json中的 main,如果没有尝试查找目录下的index.(js,json,node)
    if (!filename && rc === 1) {  // Directory.
      // try it with each of the extensions at "index"
      if (exts === undefined)
        exts = ObjectKeys(Module._extensions);
      filename = tryPackage(basePath, exts, isMain, request);
    }
    // 将找到的路径存入缓存,然后返回
    if (filename) {
      Module._pathCache[cacheKey] = filename;
      return filename;
    }
  }
  // 没有找到,返回 false
  return false;
}

4、module.load

Module.prototype.load = function(filename) {
  this.filename = filename;
  this.paths = Module._nodeModulePaths(path.dirname(filename));
  // 找到注册在Module._extensions中的最长后缀,没有匹配则默认返回 .js
  const extension = findLongestRegisteredExtension(filename);
  // 根据不同的后缀,选择不同的加载方法
  Module._extensions[extension](this, filename);
  this.loaded = true;
}

三种后缀的加载方法如下:

// Native extension for .js
Module._extensions['.js'] = function(module, filename) {
  // If already analyzed the source, then it will be cached.
  const cached = cjsParseCache.get(module);
  let content;
  if (cached?.source) {
    content = cached.source;
    cached.source = undefined;
  } else {
    content = fs.readFileSync(filename, 'utf8');
  }
  module._compile(content, filename);
};
// Native extension for .json
Module._extensions['.json'] = function(module, filename) {
  const content = fs.readFileSync(filename, 'utf8');
  try {
    module.exports = JSONParse(stripBOM(content));
  } catch (err) {
    err.message = filename + ': ' + err.message;
    throw err;
  }
};
// Native extension for .node
Module._extensions['.node'] = function(module, filename) {
  // Be aware this doesn't use `content`
  return process.dlopen(module, path.toNamespacedPath(filename));
};

json 文件的加载很简单,就是通过JSON.parse将文件内容转为对象,赋值给 module.exports

node 文件则通过 process.dlopen 去加载,这里我们深入探究了。

我们仔细分析 js 文件的加载,根据代码,通过 fs.readFileSync 读取文件内容后,就交给了 module._compile 进行编译。

Module.prototype._compile = function(content, filename) {
  const compiledWrapper = wrapSafe(filename, content, this);
  const dirname = path.dirname(filename);
  let result;
  const exports = this.exports;
  const thisValue = exports;
  result = ReflectApply(compiledWrapper, thisValue,[exports, require, module, filename, dirname]);
}

module._compile 的作用其实就是将文件内容同函数包裹起来:

(function (exports, require, module, __filename, __dirname) {
  // js 模块内容
});

总结

总的来说,文件引入会经过三个步骤:

  • 路径分析
  • 文件定位
  • 编译执行

原生模块和内置模块在Node源码编译过程中,编译进了二进制执行文件,在启动Node时,部分核心模块就被直接加载进了内存中,所以这部分模块引入时会直接跳过文件定位和编译执行,并且在路径分析中优先加载。

非原生模块则需要完成整的路径分析、文件定位、编译执行。

看完 require 的源码,终于豁然开朗,为什么我们能直接在代码中使用 requireexportmodule.exports 以及__filename__dirname 这些变量了,他们并不是全局变量,而是在加载过程中注入的参数。

我看源码的过程中,逐渐发现原来我们以为很智能的功能,其实底层实现非常简单。例如,Module._nodeModulePaths(process.cwd())确定可能的路径时,就是根据当前路径进行遍历,通过每一个 / 进行分隔,然后加上 node_modules 得到的。当然了,别人的代码简单,并不代表自己实现起来也能这么简洁,通过学习别人的实现思路,去提升自己的编码能力,这也是看源码的一大收获。

在看源码的过程中,可能会像我一样遇到这样疑惑,为什么 loader.js 文件最开始就能使用 require 方法?这就要去探究 node 的初始化过程了,请关注我的下篇文章~