NodeJS模块机制及其应用

715 阅读6分钟

前言

早期的JavaScript由于缺乏模块系统。要编写JS脚本,必须依赖HTML对其进行管理,严重制约了JavaScript的发展。而CommonJS规范的提出,赋予了JavaScript开发大型应用程序的基础能力。其中NodeJS借鉴CommonJS的Modules规范实现了一套简单易用的模块系统,为JavaScript在服务端的开发开辟了道路。

CommonJS模块规范

CommonJS的模块规范定义三个了部分:

  • 模块引用:模块所在的上下文提供require方法,能够接受模块标识为参数引入一个模块的API到当前模块的上下文中。

  • 模块定义:在模块中,存在一个module对象以代表模块本身,同时存在exports作用模块属性的引用。

  • 模块标识:模块标识即为require方法的参数,它要求必须为小驼峰命名的字符串,相对路径或绝对路径。

Node模块的实现

  • 模块引用:在Node模块的上下文中,存在require方法,能够对模块进行引入,如const fs = require("fs");

  • 模块定义:在Node中,以单个文件作为模块的基础单位,即一个文件为一个模块,所有挂载到exports对象上的方法属性即为导出。

// person.js
exports.name = "vincent";
exports.say = function() {
    console.log("hello world");
};

// driver.js
const person = require("person");
exports.say = function() {
    person.say();
    console.log(`I am ${person.name}`);
};
  • 模块标识:Node将为模块分为两类,一类是由Node提供的内建模块,也称为核心模块;另一类是用户编写的模块,称为用户或第三方模块。而Node中的模块标识符主要分为以下几类。

    1. 绝对路径形式:/path/my/module
    2. 相对路径形式:../path/my/module 或者 ./path/my/module
    3. 模块名形式: http、fs、koa

以上便是Node对CommonJS模块规范的实现概览。但实际上Node对模块规范进行了一定的取舍,在requireexports module过程中加入了自身的特色,下面让我们来深入了解一下:

模块require过程

  1. 查询缓存:Node模块的加载策略和大多数加载器一样,遵循缓存优先,对于同一模块的二次加载优先查找缓存。这一点和浏览器缓存静态资源的策略类似,不同之处在于Node模块缓存的是编译且执行后的模块对象。除此之外,Node进程在启动会加载部分核心模块到内存中,省略了3,4两个步骤,同时内建模块在路径分析中优先判断,所以加载核心模块的速度是最快的。
  2. 路径分析:对模块标识符进行分析,判断模块引入类型,路径形式的模块在require时,会讲标识符转化为真实路径,并以此为索引进行缓存;模块名形式的模块,若为核心模块按照步骤1加载,反之对于第三方模块,Node会根据模块路径字段中的路径去查找。
    从上图中可以看出模块路径查找规则可以概括为,沿着当前文件路径目录逐级向上查找node_modules目录。这与JavaScript作用域链的查找方式类似,层级越深,查找速度越慢。 下面是require实现的核心逻辑(省略了部分代码)
Module.prototype.require = function(id) {
    return Module._load(id, this, /* isMain */ false);
};

Module._load = function(request, parent, isMain) {
  let relResolveCacheIdentifier;
  if (parent) {
    // 存在父级模块时,拼接路径作为临时缓存索引(查询真实路径)
    relResolveCacheIdentifier = `${parent.path}\x00${request}`;
    const filename = relativeResolveCache[relResolveCacheIdentifier];
    // 在缓存中查询模块
    if (filename !== undefined) {
      const cachedModule = Module._cache[filename];
      if (cachedModule !== undefined) {
        // 将模块push到父级模块的children数组中
        updateChildren(parent, cachedModule, true);
        return cachedModule.exports;
      }
      // 删除临时索引
      delete relativeResolveCache[relResolveCacheIdentifier];
    }
  }
  // 查询模块真实路径,策略同步骤二
  const filename = Module._resolveFilename(request, parent, isMain);

  const cachedModule = Module._cache[filename];
  // 缓存存在时,返回module.exports
  if (cachedModule !== undefined) {
    updateChildren(parent, cachedModule, true);
    return cachedModule.exports;
  }
  
  // 缓存不存在时,优先查找核心模块
  const mod = loadNativeModule(filename, request, experimentalModules);
  // 如果可以被开发者直接reuqire, 那么直接返回module.exports
  if (mod && mod.canBeRequiredByUsers) return mod.exports;

  // 生成模块实例并缓存
  const module = new Module(filename, parent);
  Module._cache[filename] = module;
  if (parent !== undefined) {
    relativeResolveCache[relResolveCacheIdentifier] = filename;
  }

  // 是否加载成功,默认失败
  let threw = true;
  try {
    // 加载模块,根据文件后缀名使用对应方法
    // .js -> fs.readFileSync -> compile (下一个章节会说明)
    // .json -> fs.readFileSync -> JSON.parse
    // .node -> fs.readFileSync -> dlopen (C/C++模块)
    // 其他类型省略
    module.load(filename);
    threw = false;
  } finally {
    // 加载失败,删除缓存及其索引
    if (threw) {
      delete Module._cache[filename];
      if (parent !== undefined) {
        delete relativeResolveCache[relResolveCacheIdentifier];
      }
    }
  }
  return module.exports;
};
  1. 文件定位:步骤二讲解了模块路径的分析策略,下面通过源码更直接的了解文件定位原理。
Module._findPath = function(request, paths, isMain) {
  // 绝对路径
  const absoluteRequest = path.isAbsolute(request);
  if (absoluteRequest) {
    paths = [''];
  } else if (!paths || paths.length === 0) {
    return false;
  }
    
  // 尝试通过路径缓存索引获取
  const cacheKey = request + '\x00' +
                (paths.length === 1 ? paths[0] : paths.join('\x00'));
  const entry = Module._pathCache[cacheKey];
  if (entry) return entry;

  // 判断路径是否以":/"或/结尾
  var exts;
  var trailingSlash = request.length > 0 &&
    request.charCodeAt(request.length - 1) === CHAR_FORWARD_SLASH;
  if (!trailingSlash) {
    trailingSlash = /(?:^|\/)\.?\.$/.test(request);
  }

  // For each path
  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;
    var basePath = resolveExports(curPath, request, absoluteRequest);
    var filename;
    // 查询文件类型
    var rc = stat(basePath);
    if (!trailingSlash) {
      // 文件是否存在
      if (rc === 0) {  // File.
            // 尝试根据模块类型获取真实路径,代码省略
            filename = findPath();
      }
        
      // 尝试给文件添加后缀名
      if (!filename) {
        if (exts === undefined)
          exts = Object.keys(Module._extensions);
        filename = tryExtensions(basePath, exts, isMain);
      }
    }
    
    // 当前文件路径为文件目录且后缀名不存在,尝试获取filename/index[.extension]
    if (!filename && rc === 1) {  // Directory.
      if (exts === undefined)
        exts = Object.keys(Module._extensions);
      filename = tryPackage(basePath, exts, isMain, request);
    }
    
    // 缓存路径并返回
    if (filename) {
      Module._pathCache[cacheKey] = filename;
      return filename;
    }
  }
  // 没有找到文件,返回false
  return false;
};
  1. 编译执行:根据以上步骤确定模块的真实路径,文件后缀名后,Node会根据通过找到模块,然后根据不同的文件后缀名进行不同方式的编译。编译的类型大致分为:JavaScript模块,C/C++模块和JSON文件。这里只对JavaScript模块进行说明。JavaScript模块的编译过程中,Node会对JS文件进行头尾包装的操作。
// (function(exports, require, module, __filename, __dirname) {\n
     JS文件代码...
// \n})

这样每个模块文件直接都进行了作用域隔离。包装过后的代码通过vm原生模块的runInThisContext()返回一个具体的function对象。最后将当前模块对象的module自身引用,require方法,exports属性及一些等全局属性作为参数传入function中执行。这就是这些变量没有定义却在每个模块文件中存在的原因。

模块exports过程

刚开始接触Node时,对于module.exportsexports的关系会存在一些疑惑。它们都可以挂载属性方法,作为当前模块的导出。但它们分别表示什么?又有什么区别呢?观察下面的代码:

// a.js
exports.name = "vincent";
module.exports.name = "ziwen.fu";
exports.age = 24;

// b.js
const a = require("a");
console.log(a); // { "name": "ziwen.fu", age: 24 };

从前面的模块源码解析中,我们可以得知Node模块最终导出的上module.exports的值,而从上面的代码我们可以确定,module.exports的初始值为{},而exports是作为module.exports的引用,挂载到exports上的属性方法,最终会由module.exports导出。继续观察下面的代码:

// a.js
exports = "from exports";

module.exports = "from module.exports";

// b.js
const a = require("a");
console.log(a);         // from module.exports

从代码运行结果可以看出,直接对module.exportsexports进行赋值,最终模块导出的是module.exports的值。从上一小结可知,exports在当前模块上下文中是作为形参传入,直接改变形参的引用,并不能改变作用域外的值。测试代码如下:

const myModule = function(myExports) {
    myExports = "24";
    console.log(myExports);
};

const myExports = "8";
myModule(myExports);        // 24
console.log(myExports);     // 8

Node模块的循环依赖问题

下面这段来自Node官网的示例

// a.js
console.log('a starting');
exports.done = false;
const b = require('./b.js');
console.log('in a, b.done = %j', b.done);
exports.done = true;
console.log('a done');

// b.js
console.log('b starting');
exports.done = false;
const a = require('./a.js');
console.log('in b, a.done = %j', a.done);
exports.done = true;
console.log('b done');

// main.js
console.log('main starting');
const a = require('./a.js');
const b = require('./b.js');
console.log('in main, a.done = %j, b.done = %j', a.done, b.done);

// console.log
main starting
a starting
b starting
in b, a.done = false
b done
in a, b.done = true
a done
in main, a.done = true, b.done = true

官方的解释是,当main.js加载a.js时,a.js尝试加b.js。此时b.js又尝试去加载a.js。为了避免死循环,a.js导出了一个未完成的副本,使b.js完成加载,随后b.js导出给a.js完成整个过程。目前ES6 Module 已经提供了解决方案,Node的 ES6模块也已经进入测验阶段,这里就不做过多介绍了。

Node模块的实战应用

上面介绍了Node模块的基础机制,大多数情况下我们可能都会使用依赖前置的方式去require模块,即提前引入当前模块所需的模块,并它置于代码顶部。但有时候,存在部分模块,程序不需要立即使用它们,这时候动态引入是一个更好的选择。下面是Node Web服务框架egg.js中加载器(Loader)实现的相关代码,它提供了一个不一样的思路:

// egg-core/lib/loader/utils
loadFile(filepath) {
    // filepath来自于require.resolve(path)的定位
    try {
      // 非JavaScript模块,同步读取文件
      // Module._extension为Node模块支持后缀名数组
      const extname = path.extname(filepath);
      if (extname && !Module._extensions[extname]) {
        return fs.readFileSync(filepath);
      }
      // JavaScript模块直接require
      const obj = require(filepath);
      if (!obj) return obj;
      // ES6模块返回处理
      if (obj.__esModule) return 'default' in obj ? obj.default : obj;
      return obj;
    } catch (err) {
        // ...
    }
  }
  
  // egg-core/lib/loader/context_loader
  // 代理上下文中对象app.context
  // property对应项目文件名
  Object.defineProperty(app.context, property, {
      get() {
        // 查询缓存
        if (!this[CLASSLOADER]) {
          this[CLASSLOADER] = new Map();
        }
        const classLoader = this[CLASSLOADER];
        // 获取模块对象实例并缓存
        let instance = classLoader.get(property);
        if (!instance) {
          instance = getInstance(target, this);
          classLoader.set(property, instance);
        }
        return instance;
      },
    });

egg-loader的思路是通过代理app.context上的property,动态的去require模块并加以包装之后挂载到上下文对象上。其中property是来源于require.resolve定位的模块文件名,借助缓存机制,使得程序在运行过程中能够按需引入模块,同时也减少了开发者引入模块以及维护模块名及路径的成本。

目前Node的模块机制中,require模块是基于readFileSync实现的同步API,对于大文件的引入存在诸多不便。而正在试验过程中的ES Module支持异步动态引入,同时也解决了循环依赖的问题,未来可能将广泛应用到Node模块机制中。

参考