浅析当下的 Node.js CommonJS 模块系统

本文涉及的产品
全局流量管理 GTM,标准版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
云解析 DNS,旗舰版 1个月
简介: 在 ES2015 标准之前,JavaScript 语言没有原生的组织代码的方式。Node.js 用 CommonJS 模块规范填补了这个空白。我想通过这篇文章和大家分享一下当下的 CommonJS 模块系统的一些机制和细节。

在 ES2015 标准之前,JavaScript 语言没有原生的组织代码的方式。Node.js 用 CommonJS 模块规范填补了这个空白。我想通过这篇文章和大家分享一下当下的 CommonJS 模块系统的一些机制和细节。

在写这篇文章的时阅读代码 Node.js 版本是 v10.0.0

全文共由三个部分组成:

  • 什么是模块系统
  • require() 的流程,包括模块加载、编译、执行
  • 模块系统的一些细节,包括缓存机制、路径解析
  • 全文内容小结

1.什么是模块系统

模块是代码结构的基本组成部分。通过模块系统,我们可以用模块化的方式来组织应用代码。模块可以通过 module.exports 自由地隐藏内部实现、对外暴露接口 。我们只需要通过 require ,就能实现模块加载引入。

当下我们在 Node.js 中最常使用的到的模块系统 commonjs,是 JavaScript

// add.js
function add (a, b) {
  return a + b
}

module.exports = {
  add
}
复制代码

当我们需要在其他地方使用 add 方法时,比如:

// app.js
const { add } = require('./add')

console.log(add(1, 2))
// 3
复制代码

我们只需要调用 require('./add') 就能实现对模块的引入。

1.1 从一个错误场景开始看 require()

可能很多同学在开始学 Node.js 的时候,都曾经遇到这样的问题:

Cannot find module '../../add.js'  找不到模块的错误。

loader.js:573 Uncaught Error: Cannot find module '../../add.js'
    at Function.Module._resolveFilename (internal/modules/cjs/loader.js:571:15)
    at Function.Module._load (internal/modules/cjs/loader.js:497:25)
    at Module.require (internal/modules/cjs/loader.js:626:17)
    at require (internal/modules/cjs/helpers.js:20:18)
    at <anonymous>:1:1
复制代码

一般找不到模块的原因:

  • 路径不正确
  • 忘记从 npm 安装依赖包

我们可以在这异常错误堆栈看到,当我们用 require() 时,它内部调用了那些方法。

  • helpers.js: require
  • loader.js: Module.require
  • loader.js: Module._load
  • loader.js: Module._resolveFilename

我们可以通过这里的调用堆栈一步步探索 require() 的内部机制

require() 的流程

为了使之后代码细节显得不那么TLDR,先通过这个图,对整体流程有一定印象:

1

虽然在 Node.js 8 之后开始做 Node.js 支持 ES Module 的工作,CommonJs 模块系统的实现还是经过了不小的变化。但是总体流程仍然是和之前几乎一致。

  • 路径解析
  • 文件加载
  • 模块封装
  • 编译执行
  • 缓存

2.1 require 是从哪来的

我们先到 helpers.js 看看 require() 是怎么来的:

var require = makeRequireFunction(this);
var result = compiledWrapper.call(this.exports, this.exports, require, this, filename, dirname);
复制代码

通过上面 的代码看到,require 是调用 makeRequireFunction(module) 来生成的。

代码中的 this 是一个模块实例(module),至于模块实例是怎么生成的我们之后再细看。

require 和 exportsfilenamedirname 等作为参数, 执行了 完成编译之后模块代码的包装函数 compiledWrapper ,这里就是我们的模块由文件字符到 JS 代码被执行的阶段。

模块代码通过 Module.wrap 包装,之后调用 vm.runInThisContext 执行得到了模块包装函数。

Module.wrapper = [
  '(function (exports, require, module, __filename, __dirname) { ',
  '\n});'
];

Module.wrap = function(script) {
  return Module.wrapper[0] + script + Module.wrapper[1];
};
复制代码

这里就是在模块代码中常用到的 moduleexportsrequire ,__dirname__filename 的来源。

经过 Module.wrap() 包装之后的代码使用 vm.runInThisContext() 在当前上下文中编译执行。

// content 来自模块的代码
var wrapper = Module.wrap(content);

var compiledWrapper = vm.runInThisContext(wrapper, {
  filename: filename,
  lineOffset: 0,
  displayErrors: true
});
复制代码

完成上面的铺垫,回归主线,开始解析 makeRequireFunction 做了什么。

2.2 makeRequireFunction 的实现

// internal/modules/cjs/helpers.js:20

// 调用 makeRequireFunction(module)  这里的 | module | 是 Module对象
// 生成当前模块上下文的 require()
function makeRequireFunction(mod) {
  const Module = mod.constructor;
 
  // 创建一个模块相关的 require
  // 依赖深度机制实现十分巧妙
  function require(path) {
    try {
      exports.requireDepth += 1;
      return mod.require(path);
    } finally {
      exports.requireDepth -= 1;
    }
  }

  // resolve 是对 Module._resolveFilename 的封装
  function resolve(request, options) {
    if (typeof request !== 'string') {
      throw new ERR_INVALID_ARG_TYPE('request', 'string', request);
    }
    return Module._resolveFilename(request, mod, false, options);
  }

  require.resolve = resolve;
    
  // resolve.paths 是对 Module._resolveLookupPaths 的封装
  function paths(request) {
    if (typeof request !== 'string') {
      throw new ERR_INVALID_ARG_TYPE('request', 'string', request);
    }
    return Module._resolveLookupPaths(request, mod, true);
  }

  resolve.paths = paths;
    
  // process.mainModule 入口模块
  require.main = process.mainModule;

  // 启用支持以添加额外的扩展类型
  // 我们可以实现 require.extensions['.xxx']
  // 来实现对自定义文件类型模块的支持
  require.extensions = Module._extensions;
 
  // 模块的缓存
  // key: 模块路径
  // value: 模块实例
  require.cache = Module._cache;

  return require;
}
复制代码

通过上面的代码我们完整了解了 makeRequireFunction 的实现,它主要实现了:

  • 生成对应 module 上下文的 require 方法,内部调用 module 实例上 的 require 方法
  • 通过 require.resolve 添加类型校验,封装了 Module._resolveFilename
  • 通过 require.resolve.paths 封装了 Module._resolveLookupPaths
  • 在 require.main 添加入口模块 process.mainModule
  • 通过 require.extensions 暴露 Module._extensions,提供了扩展能力
  • 在 require.cache 暴露了 Module._cache,我们可以通过 require.cache操作模块缓存。

2.3 require 来自于 module.require

// Loads a module at the given file path. Returns that module's
// `exports` property.
Module.prototype.require = function(id) {
  // 参数校验 
  // 必须为 非空字符
  if (typeof id !== 'string') {
    throw new ERR_INVALID_ARG_TYPE('id', 'string', id);
  }
  if (id === '') {
    throw new ERR_INVALID_ARG_VALUE('id', id,
                                    'must be a non-empty string');
  }
  // 调用 Module._load
  return Module._load(id, this, /* isMain */ false);
};
复制代码

可以看到 module.require 参数校验完成之后,就调用 Module._load

2.4 模块的加载之前的工作 Module._load

Module._load 的主要逻辑:

  • 如果模块已经在缓存中存在,直接返回缓存中的模块
  • 如果是是原生模块,直接调用 NativeModule.require()
  • 其他情况,创建一个新的模块实例,加入缓存,调用模块实例的加载方法
Module._load = function(request, parent, isMain) {
  if (parent) {
    debug('Module._load REQUEST %s parent: %s', request, parent.id);
  }

  if (experimentalModules && isMain) {
    asyncESM.loaderPromise.then((loader) => {
      return loader.import(getURLFromFilePath(request).pathname);
    })
    .catch((e) => {
      decorateErrorStack(e);
      console.error(e);
      process.exit(1);
    });
    return;
  }

  var filename = Module._resolveFilename(request, parent, isMain);

  var cachedModule = Module._cache[filename];
  if (cachedModule) {
    updateChildren(parent, cachedModule, true);
    return cachedModule.exports;
  }

  if (NativeModule.nonInternalExists(filename)) {
    debug('load native module %s', request);
    return NativeModule.require(filename);
  }

  // Don't call updateChildren(), Module constructor already does.
  var module = new Module(filename, parent);

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

  Module._cache[filename] = module;

  tryModuleLoad(module, filename);

  return module.exports;
};
复制代码

可以看到,上面代码中的 Module._cache[filename] = module , 对还没有开始加载的模块就写入缓存可能是不安全的。

tryModuleLoad 这这里做了检测,如果模块加载失败,会清理模块的缓存。

function tryModuleLoad(module, filename) {
  var threw = true;
  try {
    module.load(filename);
    threw = false;
  } finally {
    if (threw) {
      delete Module._cache[filename];
    }
  }
}
复制代码

2.5 模块需要真实加载

module.load 的主要逻辑:

  • 写入 module.filename
  • 生成当前模块的模块路径, 并缓存在 module.paths
  • 根据 filename 文件后缀,选择不同的处理方式
  • 如果启用了 ES Module, 调用 ESMLoader 加载模块
// 设置文件名,并选择相应的文件处理
// 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));

  var extension = path.extname(filename) || '.js';
  if (!Module._extensions[extension]) extension = '.js';
  Module._extensions[extension](this, filename);
  this.loaded = true;

  if (experimentalModules) {
    // ES Module 相关逻辑,下篇文章分析
    ...
  }
};
复制代码

同步读取文件,清除文件中的 BOM 编码字符,然后调用 module._compile 编译

// Native extension for .js
Module._extensions['.js'] = function(module, filename) {
  var content = fs.readFileSync(filename, 'utf8');
  module._compile(stripBOM(content), filename);
};
复制代码

2.6 模块的编译 module._compile

module._compile 的主要工作:

  • 在当前的作用域或沙箱中运行文件内容,暴露当前模块上下文的的工具变量(require,module,exports)到模块文件中。
  • 如果有的任何异常,返回异常

以下的代码是 module._compile 的部分代码,去掉了 调试相关和文件 stat 缓存的代码。

Module.prototype._compile = function(content, filename) {
    
  // 去除 Shebang 比如:#!/bin/sh
  content = stripShebang(content);

  // create wrapper function
  // 创建封装函数
  var wrapper = Module.wrap(content);
    
  // 在当前上下文编译模块的封装函数代码
  // 传入当前模块的文件名,用于定义堆栈跟踪信息
  // 如在解析代码的时候发生错误Error,引起错误的行将会被加入堆栈跟踪信息
  var compiledWrapper = vm.runInThisContext(wrapper, {
    filename: filename,
    lineOffset: 0,
    displayErrors: true
  });

  ...
  
  var dirname = path.dirname(filename);
  var require = makeRequireFunction(this);
  var depth = requireDepth;
  
  ... 
  
  // 运行模块的封装函数
  // 并传入 `exports` , `require`, `module` `filename`, `dirname`
  var result = compiledWrapper.call(this.exports, this.exports, require, this, filename, dirname);
 
  return result;
};
复制代码

2.7 小结

模块的代码最终在 Module.prototype._compile 中完成了封装、编译、执行。最后我们在回看 Module._load ,之前我们看到的 Module.load , Module.prototype._compile 都在 tryModuleLoad() 中被调用, 当这些都执行完成之后,Module._load 返回 module.exports

Module._load = function(request, parent, isMain) {
  ...
 
  tryModuleLoad(module, filename);

  return module.exports;
};
复制代码

所以我们在模块可以使用下面的方式暴露模块 API

function add () {}

module.exports.add = add

exports.add = add

this.add = add

module.exports = {
  add
}
复制代码

3.模块系统中的一些细节

3.1 各种缓存

  • Module._pathCache
  • Module._cache
  • packageMainCache

模块请求 -> 路径的缓存

Module._findPath = function(request, paths, isMain) {
  if (path.isAbsolute(request)) {
    paths = [''];
  } else if (!paths || paths.length === 0) {
    return false;
  }

  var cacheKey = request + '\x00' +
                (paths.length === 1 ? paths[0] : paths.join('\x00'));
  var entry = Module._pathCache[cacheKey];
  if (entry)
    return entry;
    
  ...
 
}
复制代码

模块路径 -> 模块实例的缓存

Module._load = function(request, parent, isMain) {
  
  ...
  
  var filename = Module._resolveFilename(request, parent, isMain);

  var cachedModule = Module._cache[filename];
  if (cachedModule) {
    updateChildren(parent, cachedModule, true);
    return cachedModule.exports;
  }

  ...
 
}
复制代码

3.2 启动

当我们启动 Node.js 时,入口文件也是作为一个模块被加载执行。

// bootstrap main module.
Module.runMain = function() {
  // Load the main module--the command line argument.
  Module._load(process.argv[1], null, true);
  // Handle any nextTicks added in the first tick of the program
  process._tickCallback();
};
复制代码

3.3 相对路径模块、绝对路径的模块与 node_modules 中的模块

我们之前看到 require.resolve() 封装了 Module._resolveFilename。从最初调用 require()输入的字符 request 通过_resolveFilename 匹配到了模块文件路径 ,这在继续深入会发现 Module._resolveLookupPaths ,。

Module._resolveLookupPaths() 会返回一个数组,第一个元素是我们的 request ,第二个元素

console.log(Module._resolveLookupPaths('app', module))

[
  "app",
  [
    "/Users/awe/Desktop/code/test/node-module/node_modules",
    "/Users/awe/Desktop/code/test/node_modules",
    "/Users/awe/Desktop/code/node_modules",
    "/Users/awe/Desktop/node_modules",
    "/Users/awe/node_modules",
    "/Users/node_modules",
    "/node_modules",
    "/Users/awe/Desktop/code/test/node-module",
    "/Users/awe/.node_modules",
    "/Users/awe/.node_libraries",
    "/Users/awe/.nvm/versions/node/v10.0.0/lib/node"
  ]
]
复制代码

/src/node.cc#L3212

// --inspect-brk
  if (debug_options.wait_for_connect()) {
    READONLY_DONT_ENUM_PROPERTY(process,
                                "_breakFirstLine", True(env->isolate()));
  }
复制代码

回想最开头提到的找不到模块的错误是在 Module._resolveFilename ,我们可以在 node 源码中看看它的实现 lib/internal/modules/cjs/loader.js

Module._resolveFilename 的功能是根据我们在 require 输入的参数,尝试查找可以引入的模块。

Module._resolveFilename = function(request, parent, isMain, options) {
  ...
  // look up the filename first, since that's the cache key.
  var filename = Module._findPath(request, paths, isMain);
  if (!filename) {
    // 可以看到,找不到模块的报错来自于这里
    // eslint-disable-next-line no-restricted-syntax
    var err = new Error(`Cannot find module '${request}'`);
    err.code = 'MODULE_NOT_FOUND';
    throw err;
  }
  return filename;
};
复制代码

4 小结

通过这篇文章,我们从代码实现了解了 Node.js 的 CommonJS 模块系统的实现,再次回顾模块系统的流程:

  • 路径解析
  • 文件加载
  • 模块封装
  • 编译执行
  • 缓存

下一篇会分享 Node.js 还在试验阶段 ES Module 模块系统的当前实现以及一些还在讨论的新功能展望。

5.参考:

github.com/nodejs/node

blog.risingstack.com/node-js-at-…

medium.freecodecamp.org/requiring-m…



原文发布时间为:2018年07月02日
原文作者:掘金

本文来源:掘金 如需转载请联系原作者

相关文章
|
14天前
|
JavaScript Java 测试技术
基于springboot+vue.js+uniapp的母婴全程服务管理系统附带文章源码部署视频讲解等
基于springboot+vue.js+uniapp的母婴全程服务管理系统附带文章源码部署视频讲解等
18 1
基于springboot+vue.js+uniapp的母婴全程服务管理系统附带文章源码部署视频讲解等
|
14天前
|
JavaScript Java 测试技术
基于springboot+vue.js+uniapp的小区物流配送系统附带文章源码部署视频讲解等
基于springboot+vue.js+uniapp的小区物流配送系统附带文章源码部署视频讲解等
31 3
|
14天前
|
JavaScript Java 测试技术
基于springboot+vue.js+uniapp的宠物医院系统附带文章源码部署视频讲解等
基于springboot+vue.js+uniapp的宠物医院系统附带文章源码部署视频讲解等
20 2
|
14天前
|
JavaScript Java 测试技术
基于springboot+vue.js+uniapp的在线考试系统附带文章源码部署视频讲解等
基于springboot+vue.js+uniapp的在线考试系统附带文章源码部署视频讲解等
21 2
|
14天前
|
JavaScript Java 测试技术
基于springboot+vue.js+uniapp的疫情防控自动售货机系统附带文章源码部署视频讲解等
基于springboot+vue.js+uniapp的疫情防控自动售货机系统附带文章源码部署视频讲解等
19 2
|
14天前
|
JavaScript Java 测试技术
基于springboot+vue.js+uniapp的在线作业管理系统附带文章源码部署视频讲解等
基于springboot+vue.js+uniapp的在线作业管理系统附带文章源码部署视频讲解等
17 1
|
14天前
|
JavaScript Java 测试技术
基于springboot+vue.js+uniapp的太原学院在线考试系统附带文章源码部署视频讲解等
基于springboot+vue.js+uniapp的太原学院在线考试系统附带文章源码部署视频讲解等
9 1
|
5天前
|
资源调度 前端开发 开发工具
阿里云云效操作报错合集之Node-Sass模块在构建过程中,出现报错"ENOENT: no such file or directory, scandir ",该如何处理
本合集将整理呈现用户在使用过程中遇到的报错及其对应的解决办法,包括但不限于账户权限设置错误、项目配置不正确、代码提交冲突、构建任务执行失败、测试环境异常、需求流转阻塞等问题。阿里云云效是一站式企业级研发协同和DevOps平台,为企业提供从需求规划、开发、测试、发布到运维、运营的全流程端到端服务和工具支撑,致力于提升企业的研发效能和创新能力。
|
14天前
|
JavaScript Java 测试技术
基于springboot+vue.js+uniapp的公共交通查询系统附带文章源码部署视频讲解等
基于springboot+vue.js+uniapp的公共交通查询系统附带文章源码部署视频讲解等
13 0
|
14天前
|
JavaScript Java 测试技术
基于springboot+vue.js+uniapp的学习网站系统附带文章源码部署视频讲解等
基于springboot+vue.js+uniapp的学习网站系统附带文章源码部署视频讲解等
15 0