node 模块加载机制浅析

390 阅读10分钟

本片文章分析源码地址:

module源码

1. module机制

每个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类都是私有的,对其他文件不可见。

global对象是全局对象(通过global可以实现多文件共享)。

  • 所有代码都运行在模块作用域,不会污染全局作用域。
  • 模块可以多次加载,第一次加载会进行缓存,之后再次加载会从缓存读取(缓存可以删除)。
  • 模块加载的顺序,按照其在代码中出现的顺序。
1.1 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 = [];
}

const module = new Module(filename, parent);

module对象代表当前模块,成员属性:

Module {
  id: '.',
  exports: {},
  parent: null,
  filename: '/Users/tanghc/Desktop/demo.js',
  loaded: false,
  children: [],
  paths:
   [ '/Users/tanghc/Desktop/node/node_modules',
  '/Users/tanghc/Desktop/node_modules',
  '/Users/tanghc/node_modules',
  '/Users/node_modules',
  '/node_modules'
  ]
}
  • id:模块的标识符,通常是带有绝对路径的模块文件名。
  • filename: 模块文件名,带有绝对路径
  • loaded:表示当前模块是否已经完成加载
  • parent:表示调用当前该模块的模块(node xxx.js时为null)
  • children:表示该模块用到的其他模块
  • exports:对外输出的值
1.2 如何删除缓存模块:
// 删除指定模块的缓存
delete require.cache[moduleName];

// 删除所有模块的缓存
Object.keys(require.cache).forEach(function(key) {
  delete require.cache[key];
})

2.require

2.1模块加载流程

require -> _load() -> tryModuleLoad() -> Module.prototype.load -> Module._extensions[extension](this, filename) -> _compile

2.1.1 require函数在node内部是如何定义的?
// 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');
  }
  ...
  try {
    // require函数最终调用了_load函数
    return Module._load(id, this, /* isMain */ false);
  } finally {
    ...
  }
};
2.1.2 Module._load加载文件
// request:require传入的模块路径
// parent:node调用时为null,require时指向父实例
// isMain:node调用时为true,require加载时为false
Module._load = function(request, parent, isMain) {
  if (parent) {
    // 看是否存在缓存路径
    // 存在则直接读取,而不需要在执行一次路径解析的逻辑
    // \x00 -> null,相当于空字符串
    relResolveCacheIdentifier = `${parent.path}\x00${request}`;
    const filename = relativeResolveCache[relResolveCacheIdentifier];
    if (filename !== undefined) {
      const cachedModule = Module._cache[filename];
      if (cachedModule !== undefined) {
        updateChildren(parent, cachedModule, true);
        return cachedModule.exports;
      }
      delete relativeResolveCache[relResolveCacheIdentifier];
    }
  }
    
  // 获取文件的绝对路径
  // C:/a/b/c.js
  const filename = Module._resolveFilename(request, parent, isMain);
  
  // 先从_cache缓存读取模块
  const cachedModule = Module._cache[filename];
  if (cachedModule !== undefined) {
    updateChildren(parent, cachedModule, true);
    return cachedModule.exports;
  }
  
  // 未命中缓存后从native模块查找
  // 如果是navtive模块则加载
  const mod = NativeModule.map.get(filename);
  if (mod && mod.canBeRequiredByUsers) {
    debug('load native module %s', request);
    return mod.compileForPublicLoader(experimentalModules);
  }
  
  // 初始化一个新的模块
  const module = new Module(filename, parent);
  // 当前加载模块为主模块
  if (isMain) {
    process.mainModule = module;
    // 主模块路径为'.'
    module.id = '.';
  }
  
  // 缓存新构建的模块
  Module._cache[filename] = module;
  if (parent !== undefined) {
    // 缓存加载路径
    // 二次加载时,先根据相对路径去加载缓存(加快同目录下的模块的加载速度),未命中则调用_resolveFilename计算路径再加载。
    relativeResolveCache[relResolveCacheIdentifier] = filename;
  }
  // 加载模块
  module.load(filename);
  
  // 返回module.exports导出的内容
  return module.exports;
};

根据对源码对分析,可以得出_load的方法的总结:

  1. load方法会根据传入的路径来查找路径模块。
  2. 先尝试根据相对路径查找同文件夹下的缓存模块
  3. 再对路径进行解析,查找缓存模块
  4. 构建新模块,并将模块进行缓存
  5. 返回模块导出对内容。(注意返回的是module.exports
2.1.3 _resolveFilename获取文件的大致路径列表

_load方法内部使用Module._resolveFilename来查找模块的真实路径,我们来看一下具体内容:

Module._resolveFilename = function(request, parent, isMain, options) {
  // 原生模块直接返回加载路径(通常是模块名)
  if (NativeModule.canBeRequiredByUsers(request)) {
    return request;
  }
  if (typeof options === 'object' && options !== null &&
    ...
  } else {
    // 获取模块的大致路径
    paths = Module._resolveLookupPaths(request, parent, true);
  }
  // Look up the filename first, since that's the cache key.
  const filename = Module._findPath(request, paths, isMain);
  ...
  return filename
}
// 查找文件可能出现的所有位置(模块的大致路径)
Module._resolveLookupPaths = function(request, parent) {
  // require('moduleA')
  // modulePaths是根据NODE_PATH得到的路径
  // 默认从node_modules中加载依赖
  if (request.charAt(0) !== '.' ||
      (request.length > 1 &&
      request.charAt(1) !== '.' &&
      request.charAt(1) !== '/' &&
      (!isWindows || request.charAt(1) !== '\\'))) {
    let paths = modulePaths;
    if (parent != null && parent.paths && parent.paths.length) {
      /**
       * parent.paths:
       * /Users/tanghc/Desktop/node/node_modules
       * /Users/tanghc/Desktop/node_modules
       * ...
       * /node_modules
       * 
       * modulePaths:
       * '/Users/tanghc/.node_modules',
       * '/Users/tanghc/.node_libraries',
       * '/usr/local/lib/node'
       */
      paths = parent.paths.concat(paths);
    }
    return paths.length > 0 ? paths : null;
  }
  // 通过node指令方式调用
  // 返回当前程序的执行路径
  if (!parent || !parent.id || !parent.filename) {
    const mainPaths = ['.'].concat(Module._nodeModulePaths('.'), modulePaths);
    return mainPaths;
  }
  // 相对或者绝对路径引用的模块
  // 返回其父文件夹路径
  const parentDir = [path.dirname(parent.filename)];
  return parentDir;
}

_resolveLookupPaths:可能的路径以当前文件夹,nodejs系统文件夹和node_module中的文件夹为候选,以上述顺序找到任意一个,就直接返回。注意的是,自定义模块大致路径只有一条,而第三方模块,比如node_modules下的模块,会返回一个路径数组。如代码注释。

2.1.4 _findPath补全路径,获取绝对(或真实)路径
// _resolveLookupPaths获取到大致路径后,_findPath负责解析到具体文件并补全后缀。
// 我们来看一下具体到补全策略
Module._findPath = function(request, paths, isMain) {
  ...
  
  var exts;
  // 是否以/结尾
  var trailingSlash = request.length > 0 &&
    request.charCodeAt(request.length - 1) === CHAR_FORWARD_SLASH;
  if (!trailingSlash) {
    // 是否以/.或者.结尾
    trailingSlash = /(?:^|\/)\.?\.$/.test(request);
  }

  // 循环大致路径的list
  // 直到找到真实文件
  for (var 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;
    // 将大致路径与require路径拼接得到可能路径
    var basePath = path.resolve(curPath, request);
    var filename;
    
    // 首先判断文件类型
    // 分为非文件类型和文件类型
    var rc = stat(basePath);
    // 不以/或者/.或者.结尾
    // 比如require('main') 可能为main.js或者main/
    // 可能是文件,也可能是文件夹
    if (!trailingSlash) {
      // 真实文件
      if (rc === 0) {  // File.
        ...
        // 文件返回真实路径
        filename = toRealPath(basePath);
      }
      // 拼接后缀
      if (!filename) {
        // Try it with each of the extensions
        // .js,.json,.node,.mjs
        if (exts === undefined)
          exts = Object.keys(Module._extensions);
        filename = tryExtensions(basePath, exts, isMain);
      }
    }
    // 文件夹
    if (!filename && rc === 1) {  // Directory.
      // try it with each of the extensions at "index"
      if (exts === undefined)
        exts = Object.keys(Module._extensions);
      // 1.如果没有package.json或者未定义main字段,则找是否有index文件
      // 2.获取main字段定义的路径,无后缀补全后缀
      // 3.若补全后缀仍不存在,则加上index再补全后缀
      filename = tryPackage(basePath, exts, isMain, request);
    }
    // 缓存路径
    if (filename) {
      Module._pathCache[cacheKey] = filename;
      return filename;
    }
  }
  return false;
};

_findPath方法来看,它需要针对文件夹和非文件夹区分不同的补全策略

  • 文件类型:直接返回文件的路径
  • 文件夹类型:
    1. 文件夹下是否有package.json文件,并获取文件中的main字段定义的路径。
    2. 如果main字段对应的路径是一个文件且存在,那么就返回这个路径。
    3. main字段对应的路径对应没有带后缀,那么尝试使用.js,.json,.node,.ms后缀去加载对应文件
    4. 如果以上2个条件都不满足,那么尝试对应路径下的index.js,index.json,index.node文件。
    5. 如果未定义main字段,那么就对文件路径后添加分别添加.js,.json,.node,.ms后缀去加载对应的文件。
    6. 找到则缓存路径,未找到抛出错误。

_findPath路径补全的优先级:

  1. 具体的文件
  2. 若文件无后缀,则补全后缀(顺序:.js,.json,.node,.mjs)
  3. 通过package.json main补全的路径
  4. 查找index文件并加上后缀的路径
2.1.5 module.load

通过以上步骤,node已经找到的具体的模块,接下便进入最后的阶段:即读取模块内容,编译模块再执行具体的内部逻辑。

Module.prototype.load = function(filename) {
  ...
  this.filename = filename;
  ...
  // 获取后缀,即上面列出的4个
  // 如果不是给定后缀,则按.js后缀处理(包括未给定后缀的文件)
  const extension = findLongestRegisteredExtension(filename);
  // 开始load该文件
  Module._extensions[extension](this, filename);
  this.loaded = true;
};

node在内部对文件对后缀处理上,默认会按.js后缀文件处理。这也就是说,当加载的文件无后缀或者后缀不在.js,.json,.node,.ms之内时,也会按.js文件去处理。

2.1.6 加载编译文件

我们主要来看一下node内部是如何加载解析js文件的:

Module._extensions['.js'] = function(module, filename) {
  const content = fs.readFileSync(filename, 'utf8');
  module._compile(content, filename);
};

首先将文件内容按utf8格式读取到内存。

Module.prototype._compile = function(content, filename) {
  // const compiledWrapper = wrapSafe(filename, content);
  // 将文件内容包装在一个自执行的函数内
  const wrapper = Module.wrap(content);
  compiledWrapper = vm.runInThisContext(wrapper, {
    filename,
    lineOffset: 0,
    displayErrors: true,
    importModuleDynamically: experimentalModules ? async (specifier) => {
        const loader = await asyncESM.loaderPromise;
        return loader.import(specifier, normalizeReferrerURL(filename));
      } : undefined,
   });

  ...
  // 加载的文件所在的目录名
  const dirname = path.dirname(filename);
  // require方法
  const require = makeRequireFunction(this);
  // 
  var result;
  // module.exports
  const exports = this.exports;
  const thisValue = exports;
  const module = this;
  if (requireDepth === 0) statCache = new Map();
  ...
  // thisValue: module.exports的引用。thisValue = module.exports。模块内部this默认指向module.exports
  // exports: module.exports的引用
  // require: require方法
  // module:Module实例
  // filename:文件的绝对路径
  // dirname:文件所在文件夹的绝对路径
  result = compiledWrapper.call(thisValue, exports, require, module,
                                  filename, dirname);
  ...
  if (requireDepth === 0) statCache = null;
  return result;
};

在编译执行文件内容时,node会先把文件内容包装在一个函数内,对应Module.wrap方法,包装结果:

function (exports, require, module, __filename, __dirname) {
    // 文件内容content
}

所以,我们在文件内部可以直接拿到函数传入的参数,而无需声明。包装完毕后,调用vm.runInThisContext为代码创建一个独立沙箱运行空间,包装的代码可以访问外部的global对象,但不能访问其他沙箱内运行的变量。返回包装后的函数。

最好调用包装好的函数,并注入对应的参数。总体过程:

  • 通过wrapSafe方法将内容包装在在一个独立的沙箱运行环境内,返回包装的函数。
  • 调用包装函数并注入对应参数。

这里在看一下注入的相关参数:

  • thisValue:包装函数运行的上下文,即模块内部this默认指向module.exports
  • exportsmodule.exports的引用。
  • requirerequire方法。
  • module:模块Module实例。
  • filename(__filename):文件的绝对路径。
  • dirname(__dirname):文件所在文件夹的绝对路径。

可以看到module.exports与exports共享一块内容。

到这里,node已经完成来指定模块的加载与执行,但光这些还不够,我们还需要拿到指定模块对外暴露的内容(即存放在module.exportsexports)中的内容。我们再回到2.1.2 Module._load函数结尾,该函数的返回值即模块对外的暴露值:

return module.exports;

最终暴露的是module.exports中挂载的属性。

Module._compile方法是同步执行的,所以Module._load要等它执行完成,才会向用户返回module.exports的值。

2.1.7 require辅助方法

_compile函数结尾,发现通过makeRequireFunction返回来一个require函数,而不是直接使用this.require方法,这是为什么呢?来看一下方法的具体内容:

// mod就是传入的Module构造方法的原型对象
function makeRequireFunction(mod) {
  const Module = mod.constructor;
  
  // 声明一个新的require方法
  function require(path) {
    return mod.require(path);
  }
  // resolve方法同_resolveFilename方法,获取加载模块的真实路径。
  // 比如require.resolve('./main') main文件夹下存在index.js模块
  // 返回/Users/用户名/Desktop/node/main/index.js
  function resolve(request, options) {
    validateString(request, 'request');
    return Module._resolveFilename(request, mod, false, options);
  }

  require.resolve = resolve;
  
  // 获取模块的所有大致路径
  function paths(request) {
    validateString(request, 'request');
    return Module._resolveLookupPaths(request, mod);
  }

  resolve.paths = paths;

  require.main = process.mainModule;

  // Enable support to add extra extension types.
  require.extensions = Module._extensions;

  require.cache = Module._cache;

  return require;
}

makeRequireFunction方法对require函数进行来一次包装,增加了一些辅助函数和属性:

  • require.resolve():将模块名解析到一个绝对路径
  • require.main:指向主模块(入口模块)
  • require.cache:缓存模块
  • require.extensions:根据文件后缀加载不同函数,{ '.js': [Function], '.json': [Function], '.node': [Function] }

从模块加载机制看一些常见问题

node如何处理循环依赖

当main.js加载a.js时,a.js又加载b.js,b.js又尝试去加载a.js。为了防止无限的循环,会返回一个a.js的exports对象的未完成副本给b.js模块,然后b.js完成加载,并将exports对象提供给a.js模块。

// module.js
module.exports.a = 1
require('./subModule')
module.exports.b = 2

// subModule.js
const parent = require('./module')
console.log(parent.a)
console.log(parent.b)

module.exports = {
  getB() { console.log(parent.b) }
}

// main.js
const a = require('./module')
const b = require('./subModule')

b.getB()

// output
// 1
// undefined
// 2

值传递与引用传递

如果对外输出的是一个值,那么输出的值是内部输入值的一个拷贝,即内部的变化不会影响对外输出的值。引用传递除外:

// count.js
let obj = {
  num: 1
}

function incCounter() {
  obj.num += 1
}

module.exports = {
  obj,
  incCounter
}
// main.js
const { obj, incCounter } = require("./count")

console.log('%o', obj);

incCounter()

console.log('%o', obj);

// { num: 1 }
// { num: 2 }

EsModuleCommonJs

export default

default是ES6引入的与export配套使用的关键字,用来给匿名对象、匿名函数设置默认的名字用的

export出来的值必须要有一个命名,否则从语法层次便会报错

文章内容如有错误,欢迎指证!

转载请注明出处!非常感谢!