likes
comments
collection
share

【造轮子系列】38行代码带你实现CommonJS规范

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

【造轮子系列】38行代码带你实现CommonJS规范

概览: 前端模块化长久以来一直是被诟病的,由于语言的先天因素,JS的模块化发展经历了漫长的混乱时期,主要经历了以下三个阶段:

  • “伪”模块化阶段
  • 各种社区规范兴起阶段(CommonJS、AMD、CMD)
  • 官方ESModule阶段

今天我们实现的就是第二阶段在Nodejs中实现的CommonJs规范,CommonJS对于模块的定义非常简洁,主要包括模块定义、模块引用、模块标识和模块编译。

CommonJS概览

  • 模块定义

    每一个文件对应一个模块,模块上有属性exports,用来导出模块的属性或方法,有一个id用来唯一标识模块。

// id:完整的文件名
function Module(id){
    // 用来唯一标识模块
    this.id = id; 
    // 用来导出模块的属性和方法
    this.exports = {}; 
}
  • 模块引用

在CommonJS规范中,存在require()方法,接受模块标识,以此引入模块的导出属性或方法到当前的上下文中,文件标识可以不包含文件扩展名,require在对文件进行定位的时候按.js、.json、.node的次序补足扩展名,依次尝试,具体是调用fs模块同步阻塞地判断文件是否存在,所以具体实践过程中,.json和.node文件在传递给require()的标识符中带上扩展名会提高性能:

const cal = require("math")

//math.js文件
module.exports = {
    sum(){},
   // ...
}
  • 模块的标识

模块的标识就是传递给require()方法的参数。模块的标识符在Node中主要分为以下几类:

  1. 核心模块,例如:http、fs、path等
  2. 以.或者..开始的相对路径文件模块
  3. 以/开始的绝对路径文件模块
  4. 非路径形式的文件模块,如第三方模块

CommonJS实现:

上面我们已经声明了一个模块Module,接下来我们会以此为基础来构建整个规范。首先我们定义一个myRequire方法,用来加载模块:

function myRequire(filePath) {
    // 直接调用Module的静态方法进行文件的加载
    return Module._load(filePath);
}

下面我们需要给Module添加_load的静态方法去加载文件,挂载在Module的类上的原因是在加载文件的过程中需要处理缓存,文件的寻址等模块通用的逻辑处理。

Module._cache = {};
Module._load = function(filePath) {
    // 首先通过用户传入的filePath寻址文件的绝对路径
    // 因为再CommnJS中,模块的唯一标识是文件的绝对路径
    const realPath = Module._resoleveFilename(filePath);
    // 缓存优先,如果缓存中存在即直接返回模块的exports属性
    let cacheModule = Module._cache[realPath];
    if(cacheModule) return cacheModule.exports;
    // 如果第一次加载,需要new一个模块,参数是文件的绝对路径
    let module = new Module(realPath);
    // 调用模块的load方法去编译模块
    module.load(realPath);
    return module.exports;
}

定义一个模块的缓存,首先定位文件的具体路径,然后去缓存中查找,有缓存立即返回模块的exports属性,如果没有,通过文件的唯一标识创建模块,最后通过模块的load方法去对模块进行最后的编译。

const path = require('path');

// node文件暂不讨论
Module._extensions = {
   // 对js文件处理
  ".js": handleJS,
  // 对json文件处理
  ".json": handleJSON
}

Module._resolveFilename = function (filePath) {
  // 拼接绝对路径,然后去查找,存在即返回
  let absPath = path.resolve(__dirname, filePath);
  let exists = fs.existsSync(absPath);
  if (exists) return absPath;
  // 如果不存在,依次拼接.js,.json,.node进行尝试
  let keys = Object.keys(Module._extensions);
  for (let i = 0; i < keys.length; i++) {
    let currentPath = absPath + keys[i];
    if (fs.existsSync(currentPath)) return currentPath;
  }
};

_resolveFilename方法主要是进行文件的定位,这里实现比较简单,只是去依次尝试拼接扩展名,而没有按照模块的类型进行细致的进一步处理。

Module.prototype.load = function(realPath) {
  // 获取文件扩展名,交由相对应的方法进行处理
  let extname = path.extname(realPath)
  Module._extensions[extname](this)
}

我们给模块添加了一个load的实例方法,具体实现文件的编译过程,接下来,我们具体看一下,js文件和json文件的模块处理过程:

const fs = require("fs")

function handleJSON(module) {
 // 如果是json文件,直接用fs.readFileSync进行读取,
 // 然后用JSON.parse进行转化,直接返回即可
  const json = fs.readFileSync(module.id, 'utf-8')
  module.exports = JSON.parse(json)
}

function handleJS(module) {
  const js = fs.readFileSync(module.id, 'utf-8')
  let fn = new Function('exports', 'myRequire', 'module', '__filename', '__dirname', js)
  let exports = module.exports;
  // 组装后的函数直接执行即可
  fn.call(exports, exports, myRequire, module,module.id,path.dirname(module.id))
}

因为要隔离模块与模块之间的作用域,我们需要把js的内容包装再一个function中,这样你定义的变量也就是局部变量了,并且封装的时候我们我们传入了五个参数,依次为:exports(module.exports的引用)、myRequire(加载文件的方法)、module(模块)、__filename(文件的绝对路径)、__dirname(文件的目录名) 这也就是我们为什么我们没有定义却能够直接使用上面方法和属性的原因,是这些方法和属性被当做函数的参数传给每一个模块了。整个实现的流程图如下:

【造轮子系列】38行代码带你实现CommonJS规范

至此一个简单的CommonJS规范就实现了,我们核心要解决的问题就是作用域的隔离,所以我们需要封装成函数(vm.runInThisContext,这个方法会更好一些),提供给外界的方式和属性我们需要利用module.exports和require进行模块的导出和加载,针对不同类型的文件我们需要进行不同的处理(本文处理了js和json文件),对于加载过的模块我们进行了缓存,不会进行二次加载,直接返回exports属性。