深入理解CommonJS

1,240 阅读2分钟

深入理解CommonJS

参考

IIFE

在JS没有模块化之前,我们只能通过块级作用域对数据进行封装,最常见的方式就是使用:立即执行函数(IIFE)

const myModule = (() => {
  const privateFoo = () => {};
  const privateBar = [];
  const exported = {
    publicFoo: () => {},
    publicBar: () => {},
  };
  return exported;
})(); // once the parenthesis here are parsed, the function will be invoked

console.log(myModule);
console.log(myModule.privateFoo, myModule.privateBar);

{ publicFoo: [Function: publicFoo],
  publicBar: [Function: publicBar] }
undefined undefined

CommonJS modules

  • 通过require函数进行导入
  • 通过export或者module.exports导出
  • 同步加载

模块加载

在讲导入和导出之前,我们先来讨论一下被导入和导出的代码是怎么加载的。

function loadModule(filename, module, require) {
  const wrappedSrc = `(function (module, exports, require) { 
      ${fs.readFileSync(filename, "utf8")}
      })(module, module.exports, require)`;
  eval(wrappedSrc);
}

这里只是为了概述加载的流程,很多边界及安全问题都不予考虑,如:

这里我们只是简单的使用eval来我们的JS代码,实际上这种方式会有很多安全问题,所以真实代码中应该使用vm来实现。

  1. 这里和上面我们说的IFFE唯一的区别就是,我们给执行函数提供了以下三个参数
    • module
    • exports:我们看传参会发现,它实际上等于module.exports
    • require

实际上,原代码中还有额外两个参数:__filename __dirname,这就是我们在写代码的时候为什么可以使用到这两个变量的原因

  1. 读取文件使用的是fs.readFileSync,是使用同步读取的。

!!!同步加载!!!这是最大的特点,后面会着重讲。

require方法

从上面的代码中,我们原本的JS文件就会被包裹在一个函数中执行,在文档中这个函数称为 The module wrapper。那么文件在执行的时候,就可以拥有该函数传递进来的参数。

// 1.js
console.log(1)

假设这个文件被加载了,最后执行的代码就会是以下代码

(function (module, exports, require) {
  //通过文件读取 加载进来的 1.js文件 中的代码
  console.log(1)
})(module, module.exports, require);

所以,如果我们是一个被加载的文件,我们就可以使用以下两个关键词

  • require:通过该函数加载本地文件从而导入新的模块内容
  • exportsmodule.exports:导出当前模块中的值

我们先来写一个常用的模块代码

// load another dependency
const dependency = require('./anotherModule')
// a private function
function log() {
  console.log(`Well done ${dependency.username}`)
}
// the API to be exported for public use
module.exports.run = () => {
  log()
}

就像我们在IIFE中借助块级作用域进行封装,通过the module wrapper,模块中的值除非挂载在module.exports上,否则模块里的任一值将是私有的。

require实现

function require(moduleName) {
  console.log(`Require invoked for module: ${moduleName}`);
  const id = require.resolve(moduleName);
  if (require.cache[id]) {
    return require.cache[id].exports;
  }
  // module metadata
  const module = {
    exports: {},
    id,
  };
  // Update the cache
  require.cache[id] = module;
  // load the module
  loadModule(id, module, require);
  // return exported variables
  return module.exports;
}
require.cache = {};
require.resolve = (moduleName) => {
  /* resolve a full module id from the moduleName */
};

补全路径名 && id && resulve

function require(moduleName) {
  // ....
  const id = require.resolve(moduleName); 
  // ....
}
// ....
require.resolve = (moduleName) => {
  /* resolve a full module id from the moduleName */
};

我们拿到moduleName会对该模块的路径通过require.resolve进行补全,得到一个绝对路径。这里的路径补全有一套特定的逻辑,等到后面再讲。

缓存模块 && module&& cache

function require(moduleName) {
  // ...
  const module = {
    exports: {},
    id,
  };
  require.cache[id] = module;
  // ...
}
require.cache = {};
// ...

创建一个模块对象,对象就两个属性

  • exports:空对象
  • id:前面通过resulve解析出来的路径

module挂载到requre.cache上,后面就可以通过id作为key来进行查询。

既然有缓存的概念,那么在创建模块前,我们就可以先判断该模块是否创建,如果有就不可以跳过创建模块的过程,就有

function require(moduleName) {
  // ...
  if (require.cache[id]) {
    return require.cache[id].exports;
  }
  // 后面创建模块的过程就可以不用执行了
  const module = {
    exports: {},
    id,
  };
  // ...
}
// ...

加载模块 && loadModule

function require(moduleName) {
  const id = require.resolve(moduleName);
  const module = {
    exports: {},
    id,
  };
  // 加载模块
  loadModule(id, module, require);
}

function loadModule(filename, module, require) {
  const wrappedSrc = `(function (module, exports, require) { 
          ${fs.readFileSync(filename, "utf8")}
          })(module, module.exports, require)`;
  eval(wrappedSrc);
}

loadModule就是我们前面实现的过的了,把代码整合起来

function require(moduleName) {
  const id = require.resolve(moduleName);
  const module = {
    exports: {},
    id,
  };
  // 加载模块
  (function (filename, module, require) {
    (function (module, exports, require) {
      fs.readFileSync(filename, "utf8");
    })(module, module.exports, require);
  })(id, module, require);
}

嵌了那么多函数,实际作用只是把加载进来的文件执行了,然后把我们一开始创建的moudle对象传进去,让执行的代码修改moudle,比如我们前面写的使用代码

// load another dependency
const dependency = require('./anotherModule')
// a private function
function log() {
  console.log(`Well done ${dependency.username}`)
}
// the API to be exported for public use
module.exports.run = () => {
  log()
}

最后就会把run挂在到moudle.exports,而这个moudle就是我们前面通过以下代码创建的

const module = {
  exports: {},
  id,
};

导出数据 && module.exports

function require(moduleName) {
  loadModule(id, module, require);
  //   模块加载后,module.exports 已经被加载进来的代码挂在了需要暴露的属性了
  return module.exports;
}
require.cache = {};

除了导出外,我们在前面还说过这些模块都会缓存到require上。

moudle.exportsexports使用方式及差别

我们在上面知道,这两个变量是通过函数参数传递进来的,那我们在使用moudleexports时,就可以想象着把当前文件的代码,套入到以下代码中:

const initModule = {
  exports: {
    defatName: "wcdaren",
  },
};

// 等于 const ret = reuire('./xxx')
const ret = (function fn(module, exports) {
  // 把JS文件中的代码放在这里
  return module.exports;
})(initModule, initModule.exports);


console.log(`==============>ret`);
console.log(ret);

加载策略resolve

具体的看resolve加载策略文档

function require(moduleName) {
  // ...
  const id = require.resolve(moduleName);
  // ...
}
// ...
require.resolve = (moduleName) => {
  /* resolve a full module id from the moduleName */
};

在前面我们已经在了resolverequire的属性方法。具体的作用就是把引用传递进来的路径进行补全得到一个绝对路径的字符串。

实际项目中,我们最常使用的就是

  • 导入自己写的文件模块
  • 导入node_modules里的包模块
  • 导入node提供的核心模块

我们把加载策略只按上面三个为所有条件的话,那我们可以简单地概括下,加载策略顺序为:

  1. 判断是否为核心模块,在node本身提供的模块列表中进行查找,如果是就直接返回
  2. 判断是 moduleName是否以/或者./开头,如果是就统一转换成绝对路径进行加载后返回
  3. 如果前两步都没找到,就当做是包模块,到最近的node_moudles来找

这里只得注意的还要就是关于扩展名的问题,假设我们这么加载

const dependency = require('./anotherModule')

剔除路径,设文件名anotherModuleX,那么Node在加载的时候会进行以下判断

X如果是一个文件的话,就直接加载,不然就依次使用以下格式进行加载

  1. X.js
  2. X.json
  3. X.node
  4. X/index.js
  5. X/index.json
  6. X/index.node

如果是包模块的话,就会先在package.json文件中以main属性中的值,进行上面的扩展名操作

image-20201211133308877

实际工作中,懂到这就够了。

具体的这的自己去看文档比较好。

缓存

function require(moduleName) {
  // ...
  if (require.cache[id]) {
    return require.cache[id].exports;
  }

  const module = {
    exports: {},
    id,
  };
  // ...
  // load the module
  loadModule(id, module, require);
  // ...
  return module.exports;
}
// ...

从我们require的实现中,我们可以发现,一旦模块被加载过后,再次加载时,我们只是返回第一次执行后返回的数据。于是我们可以得出一个结果,模块只会被执行一次。

// a.js 
console.log(`==============>111`)
console.log(111)

// main.js
require("./a");
require("./a");
require("./a");
require("./a");
require("./a");

最终控制台只会打出一次

==============>111
111

循环引用

Cycles

我们举个循环引用的例子:

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 ending');

打印出来的结果:

$ node main.js
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

Title: test
Note right of main.js: console.log('main starting')
main.js->a.js: require('./a.js')
Note right of a.js: console.log('a starting');
Note right of a.js: exports.done = false;
a.js -> b.js:  require('./b.js')
Note right of b.js: console.log('b starting');
Note right of b.js: exports.done = false;
b.js -> a.js: require('./a.js')
a.js->b.js:,
Note right of b.js:exports.done = true;
Note right of b.js:console.log('b done');
b.js->a.js:,
Note right of a.js: exports.done = true;
Note right of a.js: console.log('a done');
a.js->main.js:,
main.js->b.js:require('./b.js');
b.js->main.js:,
Note right of main.js:console.log('main ending');


When main.js loads a.js, then a.js in turn loads b.js. At that point, b.js tries to load a.js. In order to prevent an infinite loop, an unfinished copy of the a.js exports object is returned to the b.js module. b.js then finishes loading, and its exports object is provided to the a.js module.

根据文档及流程图,我们知道当发生循环引用时的处理方式是,返回还未全部执行完时的代码,在第一次加载时创建的module.exports对象返回,这就会导致,被引入的模块代码是未完成的。当这个模块的加载位置进行移动时,得到的结果就存在很多不确定性。所以在实际项目中我们应该避免这种循环引用,比如

  • a依赖于b
  • b依赖于a

那这种情况就是a和b中有重复需要的代码,我们就应该把这些代码提取出来放在c中,就有

  • a依赖于c
  • b依赖于c