40行代码实现CommonJS规范

300 阅读3分钟

CommonJS 规范是把 Node.js 代码拆分成一个个文件,每个文件就是一个模块,有自己的作用域,在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见。

那这是如何做到的呢?官方文档 里面能找到一些线索:

在执行模块代码之前,Node.js 会使用一个如下的函数封装器将其封装:

(function(exports, require, module, __filename, __dirname) {
  // 模块的代码实际上在这里
})

通过这样做,Node.js 实现了以下几点:

  • 保持了顶层的变量(用 var/const/let 定义)作用在模块范围内,而不是全局对象。
  • 提供了一些看似全局的但实际上是模块特定的变量,例如 exports、__dirname 等

也就是说 CommonJS 规范是把每个单独的文件读取出来之后,包裹成一个 function 来执行的,从而实现代码的隔离。例如,我们把一个非常简单的打印两数之和的代码强拆成三个文件:

  1. entry.js 入口文件

    const {add} = require('./math')
    add(1,2)
    
  2. math.js 用于两数求和

    const print = require('./print')
    function add(a, b) {
      print(a + b)
    }
    module.exports = { add }
    
  3. print.js 用于打印

    function print(value) {
      console.log(value)
    }
    module.exports = print
    

当运行 node entry.js 的时候控制台会输出 3,因为 CommonJS 帮我们实现了模块化。接下来我们就抛弃 CommonJS,自己纯手工打造一个能够实现 js 文件模块化的代码。

首先我们新建一个 commonjs.js 的文件,在这个文件中实现自己的 Module 类:

const fs = require('fs')
const path = require('path')
function Module(id) {
  this.id = id // 传入的路径
  this.exports = {} // 保存导出的对象
  this.load(Module._resolveFilename(id)) // 查找文件并进行加载
}

然后实现 _resolveFilename 静态方法,可以把传入的 id 解析成绝对路径,并检查文件是否存在:

Module._resolveFilename = function (id) {
  const filePath = path.resolve(__dirname, id) // 解析成绝对路径
  if (fs.existsSync(filePath)) return filePath // 存在就直接返回
  const keys = Object.keys(Module._extensions) // 不存在就添加 .js 和 .json 后缀再次查找即可
  for (let i = 0; i < keys.length; i++) {
    const newFilePath = filePath + keys[i]
    if (fs.existsSync(newFilePath)) return newFilePath
  }
  throw new Error(`Cannot find module '${id}'`) // 都找不到则报错
}

还要实现两个静态属性,一个是 _cache 用于缓存已经加载过的模块,一个是 _extensions 用于对 jsonjs 后缀的文件分类解析:

Module._cache = {} // 缓存
Module._extensions = { // 按照后缀分类处理
  '.js'(module, filename) { // js文件包裹成函数来执行
    const scripts = fs.readFileSync(filename, 'utf8')
    const f = new Function('exports', 'require', 'module', '__filename', '__dirname',scripts)
    f.call(module.exports, module.exports, module.require, module, filename, path.dirname(filename))
  },
  '.json'(module, filename) { // json文件直接parse成对象返回即可
    module.exports = JSON.parse(fs.readFileSync(filename, 'utf8'))
  },
}

到这里,核心代码基本都写完了,再封装一个 load 实例方法来调用:

Module.prototype.load = function (filename) { // 加载文件
  const extname = path.extname(filename)
  Module._extensions[extname](this, filename)
}

最后还缺少一个我们自己的 require 函数,不妨叫它 _require,可以从代码或缓存中取到模块加载的结果:

Module.prototype.require = _require
function _require(id) {
  const filename = Module._resolveFilename(id)
  if (Module._cache[filename]) return Module._cache[filename].exports
  const module = new Module(filename)
  Module._cache[filename] = module
  return module.exports
}

上面的代码加起来整整 40 行,不多不少,这 40 行代码基本上实现了简易版的 CommonJS 规范了,我们不妨用自己的 _require 加载入口文件试试:

// 在 commonjs.js 最后一行加载入口文件,然后运行:node commonjs 即可
_require('./entry.js')

可以看到同样会输出 3。