你真的掌握commonJs了吗?来看看它底层实现

313 阅读3分钟

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

背景

说起模块化,应该都能随口说出esModule、commonJs、umd规范,再往前就是systemJs了。那为什么需要模块化呢?主要是为了解决命名冲突、以及实现高内聚低耦合。我们知道commonJs,它依赖node特性(需要进行io同步操作),可以按需依赖,但是无法进行tree-shaking。那这是为什么呢?

模块系统

1. module.exports 等价于 exports

  1. 创建模块 在node中我们可以按照如下两种方式创建模块:
    方式一:
// number.js
exports.add = (a, b) => a + b
exports.sub = (a, b) => a - b

方式二:

const add = (a, b) => a + b 
const sub = (a, b) => a - b

module.exports = {
  add,
  sub
}
  1. 引用模块 针对上面两种模块创建方式,它们的引用效果按下面是一样的:
const number = require('./number.js')
number.add(1, 1)
number.add(1, 2)

2. module.exports 不等于 exports

当存在一种场景,仅限导出的模块位一个变量或者一个函数时,它们将不一样。

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

使用exports能够仅导出一个函数?可能会这么写:

const add = (a, b) => a + b 
exports = add

实际上,这是错误的!Node中不允许重写exports。

画重点:

module.exports = exports = {}

module.exports 和exports 之间是引用关系,当给exports赋予新值的时候,它们之间的引用关系就断开了。并且,当一个模块中同时拥有 module.exports 和 exports 的时候,默认导出的是 module.exports 的值。所以当仅导出一个变量或函数,可以按如上操作。

3. require底层是什么?

require 导入一个模块,它属于同步的逻辑。它的用法是这样的:

// a.js 导出
module.exports = 'hello world!'

// b.js 导入
const hw = require('./a.js')

那么b.js导入文件a.js后,b.js实际的内容是这样的:

const hw = (function(module,exports,require,__dirname,__filename){ 
  module.exports = 'hello world!'
  return module.exports
})(module, exports, require, __dirname, __filename)

看着是不是像级了:读取了文件内容,然后包裹一层iife自执行函数?而通过打断点,查看源码,实际上也是如此。

下面看下梳理的流程代码,看下commonjs的模块化是怎么实现的。

const vm = require('vm')

// 模块对象
function Module(id){
  this.id = id;
  this.exports = {}
}

Module.prototype.require = (path) => {
  // 第2步:根据路径加载模块
  return Module._load(path)
}

// 内部可能有n种解析规则
Module._extenstions = {
  '.js'(module){
      // 读取模块文件内容
      const script = fs.readFileSync(module.id,'utf8')
      const code = `(function(exports,require,module,__filename,__dirname){${script}})`
      // 当前上下文执行代码
      const func = vm.runInThisContext(code)
      // 这里验证 exports 和 module.exports 共同引用
      let exports = module.exports
      let thisValue = exports
      let dirname = path.dirname(path.id)
      func.call(thisValue, exports, req, module, module.id, dirname)
  }
}
Module.prototype.load = (){
   // 第7步:核心的加载,根据文件不同的后缀名进行加载
   let extname = path.extname(this.id)
   Module._extenstions[extname](this)
}

// 解析模块文件是否存在,返回文件绝对路径
Module._resolveFilename = function(id){}

// 缓存
Module._cache= {}
Module._load = function(id){
  // 第3步:将用户的路径变成绝对路径
  let filename = Module._resolveFilename(id)
  // 第4步:有缓存直接将上次缓存返回,原生模块已加载有缓存,直接返回
  if(Module._cache[filename]){
     return Module._cache[filename].exports
  }
  // 第5步:自定义模块,创建模块,缓存模块
  let module = new Module(filename)
  Module._cache[filename] = module
  // 第6步:内部读取文件,用户会给exports对象赋值
  module.load()
  return module.exports
}

// 第1步:执行Module原型上的require方法
function require(path){
  return Module.require(path)
}

总结

如上便是整个commonJs的底层实现,require 的引用在底层上存在一层缓存,实际开发中,如果需要删除模块上的缓存可以操作 require.cache 对象。通过源码也知道,commonJs底层实现是借助node的io读写能力,也就无法进行tree-shaking,而且整个过程是同步的(文件操作+读缓存)。最后,也是因为cjs是借助node的io能力,因此它也是动态的,可以在任何地方进行require模块操纵。当然了,如何当前操作设计高并发/密集型,那得谨慎了,毕竟io操作费性能。