详解node.js的module模块

754 阅读4分钟

module 概述

Node.js 支持 CommonJS 模块化规范和 esmodule 规范,本文主要是想介绍一下基于CommonJS规范的基础上实现的module模块。

module 模块系统中,每个文件都被视为独立的模块,具有自己的作用域。在一个文件里面定义的变量、函数、类都是私有的。

Node.js 模块分类及加载机制

  • 内置模块
    由 Node.js 官方提供的,例如 fspathhttp

  • 自定义模块
    自己开发的文件模块

  • 第三方模块 引用第三方开发好的npm

加载机制

  1. 尝试从缓存中获取模块
  2. 判断是否是内置模块
  3. 从node_module中查找模块
  4. 使用path.resolve将路径转变为绝对路径
  5. 使用path.extname判断是否有文件后缀名,没有则尝试拼接 .js, .json

举个🌰:

/usr/local/src/demo/a.js中去require("./utils")

对应的路径查找可能会有以下几种可能

  • /usr/local/src/demo/utils.js
  • /usr/local/src/demo/utils.json
  • /usr/local/src/demo/utils.node
  • /usr/local/src/demo/utils/index.js
  • /usr/local/src/demo/utils/index.json
  • /usr/local/src/demo/utils/index.node

模块源码解析

首先每个模块是一个 module 对象,我们先看Module这个类的属性,代码和注释如下:

function Module(id) {
    this.id = id; // require的路径
    this.path = path.dirname(id); // 获取传入参数对应的文件夹路径 
    this.exports = {}; // 导出的东西放这里,初始化为空对象 
    this.filename = null; // 模块对应的文件名 
    this.loaded = false; // loaded用来标识当前模块是否已经加载
}

紧接着我们看module对象最关键的方法,加载其他模块的方法require,对应源码上实际是调用Module类的静态方法Module._load。我们对照着代码和注释继续往下看:

Module.prototype.require = function(path) {
  return Module._load(path, this, false);
};
// Module类上挂载__cache属性保存module对象的缓存,key为文件路径,value为module对象
Module._cache = Object.create(null);

// 关键方法,加载模块
Module._load = function(request, parent, isMain) {
  // 获取文件的绝对路径
  var filename = Module._resolveFilename(request, parent, isMain);

  // 优先读取缓存,判断是否存在,存在直接取缓存中的模块
  var cachedModule = Module._cache[filename];
  if (cachedModule) {
    return cachedModule.exports;
  }

  // 获取内置模块
  if (NativeModule.nonInternalExists(filename)) {
    return NativeModule.require(filename);
  }

  // 初始化module对象
  var module = new Module(filename);

  // 将module对象保存在缓存
  Module._cache[filename] = module;

  // 下一个关键方法,加载解析具体的文件
  tryModuleLoad(module, filename);

  // 返回模块的exports对象给require方法
  return module.exports;
};
// 寻找模块的绝对路径,
// 如果没有后缀,会尝试添加 index.js、index.json、index.node、.js、.json、.node
Module._resolveFilename = function(request, parent, isMain) {
  // ...
  var filename = Module._findPath(request, paths, isMain);
  if (!filename) {
    var err = new Error("Cannot find module '" + request + "'");
    err.code = 'MODULE_NOT_FOUND';
    throw err;
  }
  return filename;
};

tryModuleLoad 是开始尝试加载解析具体的模块了。不同类型的模块通过不同的方法去加载解析,这些方法和模块的后缀名的映射保存在 Module._extension静态方法中。Node.js原生有jsjsonnode模块的加载方法,我们来深入了解下:

function tryModuleLoad(module, filename) {
  var threw = true;
  try {
    module.load(filename);
    threw = false;
  } finally {
    if (threw) {
      delete Module._cache[filename];
    }
  }
}
// 关键代码,module对象会根据文件后缀名来使用不同的加载解析方法来加载具体的文件
Module.prototype.load = function(filename) {
  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;
};
// 对js文件的解析,首先读取文件内容
Module._extensions['.js'] = function(module, filename) {
  var content = fs.readFileSync(filename, 'utf8');
  module._compile(internalModule.stripBOM(content), filename);
};
// 包裹在一个匿名函数中,这样模块里才能执行module.exports、exports、 require方法,
// 这里能看出来使用 module.exports 和 直接使用exports 其实是完全一样的
// 调用 __dirname 、 __filename 来获取当前模块的目录路径和文件路径
Module.wrap = function(script) {
  return Module.wrapper[0] + script + NativeModule.wrapper[1];
};
Module.wrapper = [
  '(function (exports, require, module, __filename, __dirname) { ',
  '\n});'
];

// 解析js脚本字符串为js对象
Module.prototype.compile = function() {
  var source = NativeModule.getSource(this.id);
  source = NativeModule.wrap(source);

  this.loading = true;

  try {
    // 使用 vm.runInThisContext 来执行js脚本字符串,转成js对象
    const fn = runInThisContext(source, {
      filename: this.filename,
      lineOffset: 0,
      displayErrors: true
    });
    // 传入所有的依赖参数
    fn(this.exports, Module.require, this, this.filename);

    // 将加载状态设置为true, 表明已加载
    this.loaded = true;
  } finally {
    this.loading = false;
  }
};



// json文件直接读取后,通过JSON.parse来转成js对象,赋值给exports
Module._extensions['.json'] = function(module, filename) {
  var content = fs.readFileSync(filename, 'utf8');
  try {
    module.exports = JSON.parse(internalModule.stripBOM(content));
  } catch (err) {
    err.message = filename + ': ' + err.message;
    throw err;
  }
};

扩展

熟悉了Node.js的 module 的基本原理,我们其实可以利用扩展require.__extension特性来加载一些其他后缀的文件,或者改写一些方法。

下面列举了几个利用module特性的应用场景。

  1. typescript 的 runtime 解析

通常我们在开发typescript类型的时候,会使用 tsc 编译成对应的 .js 文件后去执行。开发阶段可以使用tsc --watch来监听.ts编译称.js文件后再执行,但是这样源码的路径就变了,需要做一些兼容,比较麻烦。

社区诞生了类似ts-node这样的工具方法,通过ts-node app.ts来直接在模块加载的时候,引用typescript的api做编译后执行,会方便很多。而其原理就是实现require._extensions[".ts"]方法来做到的。

  1. 服务端渲染项目,对jsxcss-module的处理

随着服务端同构项目的流行,需要在node环境下通过ReactDOM/serverrenderToString来将jsx模板转成字符串。为了方便jsx文件的复用,通常jsx中会引用到css等文件,这个时候我们直接使用renderToString方法会报错,就可以使用css-module-require-hook来处理css module文件。而这个css-module-require-hook也是通过require._extensions[".css"]来实现的。