likes
comments
collection

深入浅出CommonJs

作者站长头像
站长
· 阅读数 11

背景

  • 历史上,JavaScript 一直没有模块 (module) 体系,无法将一个大程序拆分成互相依赖的小文件,再用简单的方法拼装起来。其他语言都有这项功能,比如 Rubyrequire、Python 的import,甚至就连 CSS 都有@import,而 JavaScript 通过 <script> 标签引入代码的方式显得杂乱无章,语言本身毫无组织和约束能力,这对开发大型的、复杂的项目形成了巨大障碍。
  • CommonJs 的提出,主要弥补了当前 Javascript 对于模块化,没有统一标准的缺陷。nodejs 借鉴了 CommonJs 的 Module ,实现了良好的模块化管理。

CommonJs 的基本使用

  • 创建一个 名为 foo.js 的文件,并编写以下代码:
// 模块导出
function foo(x, y) {
  return x + y;
}

module.exports = {
  foo,
};

/**
 * 你也可以通过一下的方法导出,二者选其中一个
 */

// exports.foo = foo;


  • 创建一个名为 main.js 的文件,并编写以下代码,并使用 node 运行:
// 模块引入
const { foo } = require("./a.js");

console.log(require("./a.js")); // { foo: [Function: foo] }

console.log(foo(6, 6)); // 12
  • 好啦,CommonJs 的基本使用我们已经有一个初步了解了,如果你还不是很懂怎么使用,你可以通过 JavaScript高级程序设计第四版 进行学习,当然了,没有的小伙伴可以通过私信向我获取。

深入理解 CommonJs 的导出

  • 每个 模块 对应就是 Module 实例。
// 此类继承的是 WeakMap
const moduleParentCache = new SafeWeakMap();

function Module(id = '', parent) {
  this.id = id; // 模块的识别符,通常是带有绝对路径的模块文件名
  this.path = path.dirname(id); // 文件当前的路径

  /**
   * 相当于给构造函数 Module 上添加了一个 exports 为空对象
   * 等同于这样的写法 Module.exports = {};
   */
  setOwnProperty(this, 'exports', {});

  // 返回一个弱引用对象,表示调用该模块的模块
  moduleParentCache.set(this, parent);
  updateChildren(parent, this, false);

  this.filename = null; // 模块的文件名,带有绝对路径
  this.loaded = false; // 是否已经被加载过,用作缓存
  this.children = []; // 返回一个数组,表示该模块要用到的其他模块
}
  • 通过打印 module,有以下的输出,也证实了上边所讲的:

深入浅出CommonJs

  • 这里定义了一个数组 wrapper,里面存了一个 匿名函数,但是这个 匿名函数 被拆分成了头和尾两段,而这就是为下面进行头尾封装的辅助数组。

深入浅出CommonJs

  • Node.jsJavaScript 的代码快进行了首为包装,我们所编写的代码将作为包装函数的执行上下文,使用的 requireexportsmodule 本质上是通过形参的方式传递到包装函数中的,而这就是Node.js 中定义的包装函数:

深入浅出CommonJs

  • 借助上面例子的 foo 函数,在编译的过程中,将 JavaScript 的代码以形参的形式传入 wrap 函数,对 foo 函数进行进行头尾包装,它包装的之后的样子如下:
(function (exports, require, module, __filename, __dirname) {
  function foo(x, y) {
    return x + y;
  }

  module.exports = {
    foo,
  };
});
  • 这样每个模块文件之间都进行了作用域隔离。包装之后的代码会通过 vm 原生模块的 runInThisContext() 方法执行(类似 eval,只是具有明确上下文,不污染全局),返回一个具体的 function对象。最后,将当前模块对象的 exports 属性, require() 方法, module(模块对象自身),以及在文件定位中得到完整文件路径和文件目录作为参数传递给这个 function() 执行。
  • 通过这样的方法,Node.js实现了以下目的:
  1. 将顶级变量,例如:varconst、或者let等顶级变量限定为模块而不是全局对象;
  2. 它有助于提供一些实际特定于模块的全局变量,例如:__firenamedirname,模块开发者可以使用模块和对象从模块导出值;

module.exports 和 exports 的关系

  • 前面讲到, module.exports === exportstrue ,通过打印也确实如此。

深入浅出CommonJs

  • 代码认真一看,在构造函数 Module 中 定义了一个 exports 的对象,也就是

深入浅出CommonJs

  • _compile 原型方法上定义了一个 exports 用来保存 Module.exports ,所以这也就是为什么 module.exports === exports 的原因了,实际上是它们共享同一块内存空间。

深入浅出CommonJs

注意: 虽然他们共享的是同一块内存空间,但是最终被导出的是 module.exports 而不是 exports。值得注意的是 CommonJs 导出的是对象的引用,通过 require 之后 可以对其进行修改。

// a.js 文件下
const object = {
  moment: "Melody",
};

setTimeout(() => {
  object.moment = "Mayday";
}, 2000);

module.exports = {
  object,
};


// main.js 文件下 通过node ./main.js运行
const bar = require("./a");

console.log("main.js", bar.object.moment); // main.js main.js Melody

setTimeout(() => {
  console.log("2秒之后输出 ", bar.object.moment); // 2秒之后输出  2秒之后输出  Mayday
}, 2000);

一些错误的做法

// a.js 文件下
const object = {
  moment: "Melody",
};

exports = {
  object,
};

// main.js 文件下 通过node ./main.js运行
const bar = require("./a");
console.log(bar); //{}
  • 这样的写法,实际上是给 expors 另外创建了一个对象,但是这时候 exports 已经和 module.exportts 脱离关系了,它并没有对 module.exportts 所在的内存执行实际的操作,此时依然为空对象,所以最终输出的是空对象。再看一下代码:
// a.js 文件下
const object = {
  moment: "Melody",
};

exports.object = object;

// main.js 文件下 通过node ./main.js运行
const bar = require("./a");

console.log(bar); // { object: { moment: 'Melody' } }
  • 这段代码实际上就是给 module.exports 添加了一个对象 object,所以最终能看到想要的输出。
  • CommonJS 模块输出的是一个值的拷贝,修改修改引入的值不会改变原来的模块:

// main.js
var { moment } = require("./foo.js");

console.log(moment);

setTimeout(() => {
  moment = 777;
}, 1000);

setTimeout(() => {
  console.log("输出的是缓存的值", moment);
}, 4000);

// foo.js
var moment = "moment";

setTimeout(() => {
  console.log("输出的是原本的值,不会因为子模块修改而改变", moment);
}, 2000);

setTimeout(() => {
  moment = "不会修改 main 导入的值";
}, 3000);

exports.moment = moment;

  • 运行 main.js,毫无悬念,模块foomain.js 导入之后,修改foo的值不会,不会引起 main.js 导入的改变,而修改导入的值,不会引起 foo.js 的改变,因为 CommonJs 使用的是值的拷贝。

深入浅出CommonJs

require 查找细节

require的语法规则为 require(X) ,其中 X 为一个模块名称或者路径。

  • 如果 X 为一个核心模块,直接返回核心模块,并停止查找。
  1. Node.js 中的核心模块通常可分为httpfsurlpathEvents;
  2. 这些内置模块的优势在于它们是由 C/C++ 编写,性能上优于脚本语言;
  3. 在进行文件编译的时候,它们就被编译进二进制文件中。一旦NOde开始执行,它们就直接加载到内存中,无须再次做标识符定位、文件定位、编译等过程,直接就可以执行。

深入浅出CommonJs

  • 如果 X"./""../""/" 开头,如果 X 为一个文件夹路径名,则按照以下顺序查找:
  1. 查找 index.js 文件;
  2. 查找 index.json 文件
  3. 查找 index.node 文件
  • 如果 X 为一个文件,且没有后缀名,则按照以下顺序查找:
  1. 查找 X.js 文件;
  2. 查找 X.json 文件
  3. 查找 X.node 文件
  • 如果 X 不是路径也不是核心模块,则通过 node_module 所在的路径一层一层往下找,如果没有找到则报错提示没有找到:

深入浅出CommonJs

深入浅出CommonJs

  • 有了路径之后,下面就是 Modul.findPath() 的源码,用来确定哪个是正确的路径,其中以下代码有省略的,具体代码可以查看 github:
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' + ArrayPrototypeJoin(paths, '\x00');
  const entry = Module._pathCache[cacheKey];
  if (entry) return entry;

  let exts;
  // 是否有后缀的目录斜杠
  const trailingSlash = '...'; //省略了很多代码
  // 是否相对路径
  const isRelative = '...'; // 省略了很多代码
  let insidePath = true;
  if (isRelative) {
    const normalizedRequest = path.normalize(request);
    if (StringPrototypeStartsWith(normalizedRequest, '..')) {
      insidePath = false;
    }
  }

  // 遍历所有路径
  for (let i = 0; i < paths.length; i++) {
    const curPath = paths[i];
    if (insidePath && curPath && _stat(curPath) < 1) continue;

    if (!absoluteRequest) {
      const exportsResolved = resolveExports(curPath, request);
      if (exportsResolved) return exportsResolved;
    }

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

    const rc = _stat(basePath);
    if (!trailingSlash) {
      if (rc === 0) {
        // File.
        if (!isMain) {
          if (preserveSymlinks) {
            filename = path.resolve(basePath);
          } else {
            filename = toRealPath(basePath);
          }
        } else if (preserveSymlinksMain) {
          filename = path.resolve(basePath);
        } else {
          filename = toRealPath(basePath);
        }
      }

      if (!filename) {
        if (exts === undefined) exts = ObjectKeys(Module._extensions);
        // 该模块文件加上后缀名,是否存在
        filename = tryExtensions(basePath, exts, isMain);
      }
    }

    if (!filename && rc === 1) {
      if (exts === undefined) exts = ObjectKeys(Module._extensions);
      // 目录中是否存在 package.json   
      filename = tryPackage(basePath, exts, isMain, request);
    }

    if (filename) {
      // 将找到的文件路径存入返回缓存,然后返回
      Module._pathCache[cacheKey] = filename;
      return filename;
    }
  }
  // 如果没有找打返回 false
  return false;
};
  • 前面讲述了核心模块的原理,也解释了核心模块的引入速度为何是最快的,,下图就展示了 os 原生模块的引入流程,可以看到,为了符合 CommonJs 模块规范,从 JavaScriptC/C++ 的过程是相当复杂的,他要经历 C/C++ 层面的内建模块定义、JavaScript 核心模块的定义和引入以及(JavaScript)文件模块层面的引入:

深入浅出CommonJs

require 加载原理

  • 有了模块的路径了,就可以加载模块了。
  • 但是有一个问题,require 是怎么来的,为什么平白无故能用呢,实际上都干了什么?
  • 查看源码上发现.构造函数 Module 上有一个原型方法 require:
Module.prototype.require = function (id) {
  // 进行简单的 id 变量的判断,需要传入的 id 是一个 string 类型。
  validateString(id, 'id');
  if (id === '') {
    throw new ERR_INVALID_ARG_VALUE('id', id, 'must be a non-empty string');
  }
  // 默认为0,表示还没有使用过这个模块,每使用一次便自增一次

  requireDepth++;
  try {
    // 用于检查是否有缓存,有则从缓存里查找
    return Module._load(id, this, /* isMain */ false);
  } finally {
    // 每次结束后递减一个,用于判断递归的层次
    requireDepth--;
  }
};
  • 看完了 require 的了,我们再看看构造函数的静态方法 _load:
Module._load = function (request, parent, isMain) {
  let relResolveCacheIdentifier;
  if (parent) {
    debug('Module._load REQUEST %s parent: %s', request, parent.id);
    relResolveCacheIdentifier = `${parent.path}\x00${request}`;
    // 以文件的绝对地址当成缓存 key
    const filename = relativeResolveCache[relResolveCacheIdentifier];
    reportModuleToWatchMode(filename);
    if (filename !== undefined) {
      // 先通过 key 从缓存中获取模块
      const cachedModule = Module._cache[filename];
      if (cachedModule !== undefined) {
        updateChildren(parent, cachedModule, true);
        if (!cachedModule.loaded)
          // 如果要加载的模块缓存已经存在,但是并没有完全加载好,这是解决循环引用的关键
          return getExportsForCircularRequire(cachedModule);

        // 已经加载好的模块,直接从缓存中读取返回
        return cachedModule.exports;
      }
      // 判断缓存是否存在父模块中,存在则删除
      delete relativeResolveCache[relResolveCacheIdentifier];
    }
  }

  // 判断是否为 node: 前缀的,也就是判断是否为原生模块
  if (StringPrototypeStartsWith(request, 'node:')) {
    // Slice 'node:' prefix
    const id = StringPrototypeSlice(request, 5);

    const module = loadBuiltinModule(id, request);
    if (!module?.canBeRequiredByUsers) {
      throw new ERR_UNKNOWN_BUILTIN_MODULE(request);
    }

    return module.exports;
  }
  • 这个函数检查模块是否已经在缓存中——如果是,它返回exports对象;
  • 如果是 node: 前缀的内置模块,则调用 loadBuiltinModule() 返回结果;
  • 否则,创建一个新的模块,并保存到缓存中。
  • 请看下面的一些例子:

深入浅出CommonJs

  • 通过查看输出,你会发现,c.js文件只被执行了一次,那么为什么呢?这是因为每个模块都有一个 module 对象,其中有一个属性 loaded,如果被加载了,则为 true,也就是前面所说的被缓存过了,再次使用,不会再次执行,直接从缓存里面取,如果为 false,则执行一次并且添加到缓存中。
  • 这就是 Node.js 通过缓存的方法解决无限循环引用的问题, 也是系统优化的重要手段,通过以空间换时间,使得每次加载模块变得非常高效。

优点也可能存在缺点,在实际的业务开发中,我们从堆的角度观察 node 启动模块后,缓存了大量的模块,包括第三方的模块,有的可能只加载使用一次。那么是否有必要增加一种模块的卸载机制(垃圾回收),可以降低对 V8 内存的占用,从而提升整体代码的运行效率。

深入浅出CommonJs

commonJs 的编译

  • 通过构造函数 Module 的原型方法 _compile 在正确的作用域或沙盒中运行模块的内容,并对外公开 requiremoduleexports等辅助变量。
  • _compile 需要传入文件路径和被内置模块 vm 包装后的代码进行编译。
  • 再次查看源码,抽取部分代码,这就是获取到模块文件后所做处理的核心代码:
// 将 module.exports 对象 赋值给 expoerts 这个作用前面有讲到过
  const exports = this.exports;

  // 又将 exports 对象 赋值给 thisValue
  const thisValue = exports;
  const module = this;
  if (requireDepth === 0) statCache = new SafeMap();
  if (inspectorWrapper) {
    // inspectorWrapper 不知道是具体干啥的,就不硬解释了,
    // 应该是把他合并成一个对象 然后最终对外暴露吧 没错,就是这样
    result = inspectorWrapper(
      compiledWrapper,
      thisValue,
      exports,
      require,
      module,
      filename,
      dirname
    );
  } else {
    result = ReflectApply(compiledWrapper, thisValue, [
      exports,
      require,
      module,
      filename,
      dirname,
    ]);
  }
  hasLoadedAnyUserCJSModule = true;
  if (requireDepth === 0) statCache = null;
  return result;
  • require 的流程图正如下图所示: 深入浅出CommonJs

总结

  • CommonJs 加载模块是同步加载的,这就意味着只有等到对应的模块加载完毕,当前模块中的内容才能被运行,如果用于浏览器器中可能会引起后续js代码被阻塞,而服务器加载的js文件都是本地的,加载速度非常快。
  • CommonJs 是以对象的方式导出的,最终导出的是 module.expoers
  • CommonJS 是可以动态加载的,对每一个加载都存在缓存,可以有效的解决循环引用问题。
  • CommonJs 使用的是缓存,require 引入后修改值不会改变原模块。

参考

推荐文章

PS

  • 以上内容都是我瞎编的,如有疑问,欢迎评论出指出。
  • 下一篇文章将会讲解 ES Module,敬请期待。