Skip to content

实现commonjs中的模块机制

一. 模块化规范有哪些?为什么要有这些规范?它解 决了什么问题?

1 模块化规范的发展历程

最早的时候,没有模块化规范,开发时,因为一个项目是团队协作,会有很多人一起开发,虽然是不同的功能业务模块,但是每个人起的变量名可能一样,这就会存在一个问题,命名冲突,最初遇到这样的问题,可以采用命名空间来防止冲突,但是,命名空间也不能完全防止命名冲突,简单来说就是,用一个对象或函数,创造一个作用域,在里面实现逻辑,但是函数名或变量名还是很可能重复,而且用起来也不方便。

后来,用执行函数(IIFE)的方式来创造一个独立命名空间逐渐流行起来,

自执行函数也是前端模块化的实现方案之一,在早期前端没有模块化系统的时代,自执行函数可以很好的解决命名空间的问题,并且模块依赖的其他模块都可以通过参数传递进来。cmd和amd规范也都是依赖自执行函数实现的。

js
(function fn (){
    ....
})()

但是对于前端来说,因为有请求,需要动态的加载文件,不太好实现模块化,后面有了一些方案趋势线模块化,比如:

  • CMD 规范 (serjs、依赖就近)阿里开发 已淘汰
  • AMD 规范(requirejs、依赖前置)

上面的两种方式比较典型,也比较古老,现在90%以上没人用了

现在主流的模块化有:

  • ES6Module (主要是前端)
  • commonjs规范 (Nodejs)
  • umd模块(叫统一模块化规范)umd = AMD + commonjs,因此它可以支持AMD,也可以支持commonjs规范
  • systemjs(用的不多)

因此,目前主流的还是EM6模块化规范Commonjs模块化规范

2 JavaScript弊端

  • 浏览器端JavaScript在使用的时候存在两大问题, 命名冲突和文件依赖。
  • 模块化开发需要依赖第三方库文件,具有代表性有require.js和sea.js。

3 模块化开发优势

  • 解决命名冲突和文件依赖
  • 将具有独立功能的代码分离到单独的文件中
  • 项目代码易维护

二.简易版commonjs模块机制的实现

commonjs规定,一个文件即是一个模块,在模块内部定义的变量和函数默认情况下在外部无法得到。 另一个文件要通过module.exports [导出],在当前文件内使用require [导入]关键字用来导入,才能在当前文件使用另一个文件内的数据

然而,Node.js天生就支持模块化开发,不需要引用额外的第三方库文件。

Node中的模块机制原理很简单,就是在每个模块文件外面包裹上一个函数,利用函数来实现代码间作用域的隔离。

但是饿哦们平时在写代码时候,就只是简单的使用module.exportrequire就解决了模块之间互相引入的问题,并感受不到这个包裹模块文件的函数的存在,是因为这是Node内部已经帮我们实现了,我们可以通过源码段调试来看到,

1 模块require加载原理

require加载机制依赖于Node中的fs内置模块,首先利用fs.readFileSync读取模块文件内容,读取到的内容是一个字符串。

  • fs.readFileSync 同步,读取文件返回文件内容
  • fs.existsSync 同步,如果路径存在则返回 true,否则返回 false。

javascrpt中可以通过eval或者new Function的方式来将一个字符串转换成js代码来运行。Node中使用了vm虚拟机内置模块来处理,vm原理于new Function类似

1.1 eval

js
let a = 100
let str = "console.log(a)"

// 希望让str字符串执行,用了eval
eval(str)
// 会输出 100

eval执行时候会查找上下文,会造成安全性问题,

1.2 new Function

new Function是将字符串编程了函数,可以产生一个执行环境,且不依赖于外层作用域,缺点就是必须包装一层函数,一般在模板引擎中会使用到

js
let a = 100
let str = "console.log(a)"

let fn = new Function(str) // 会返回一个函数
console.log(fn.toString());
/*
输出: 
    function anonymous(
    ) {
    console.log(a)
    }
*/

fn() // 报错:a is not defined

如果这个函数需要传递参数,可以在new Function的时候依次传入参数,最后传入的是要执行的字符串。比如这里传入参数b,要执行的字符串str。

js
const b = 3;
const str = 'let a = 1; return a + b';
const fun = new Function('b', str);
console.log(fun(b, str)); // 4

1.3 vm虚拟机模块

node里面有一个虚拟环境的概念,也就是vm,vmNodejs中的内置模块,可以创建一个隔离的上下文环境,用来运行额外的js文件,他可以保证javascript执行的独立性,不会被外部所影响。 Node官方vm(英文)中文VM

js
// 引入vm模块, 不需要安装,node 自建模块
const vm = require('vm');
let a = 100;
const str = 'console.log(a)';
vm.runInThisContext(str); // 报错:a is not defined

虽然外部定义了hello,但是str是一个独立的模块,并不在hello变量,所以会直接报错 所以node执行javascript模块时可以采用vm来实现。就可以保证模块的独立性了。

2 实现Node模块加载机制

2.1 要点

  1. require()方法接收一个path,可以是相对路径,也可以是绝对路径,最终返回路径下的模块对象,因此需要用到path模块
  2. require会通过path读取到模块的内容(同步),然后将内容包裹在一个函数内部,因此需要用到fs模块,fs.readFileSync
  3. require里面的path可以不带后缀,不带后缀,会首先找js文件,再找json文件
  4. 多次require,只有第一次会readFile,后面的会走缓存
  5. module.exports的优先级高于exports,且不能直接给exports赋值。

依赖项path fs vm三个模块

2.2 实现require加载器

首先导入依赖的三个模块path fs vm,创建req加载函数,req函数接收一个modulePath,表示要导入的文件路径。

js
const path = require('path');
const fs = require('file');
const vm = reuqire('vm');

function req(modulePath) {
    
}

req中,首先会调用Module._resolveFilename方法,将传入的modulePath解析成带后缀名的绝对路径,调用Module实例上的module.load方法加载模块,load方法会将模块内容放到module.exports里面,然后req函数返回module.exports对象;

js
function req(modulePath) {
    // 解析传入的路径成为绝对路径(且带后缀)
   let filepath  = Module._resolveFilename(modulePath) 
   // 创建模块,新建Module实例
   const module = new Module(filepath);
   // 加载当前模块
   module.load();
   // 返回exports对象
   return module.exports;
}

Module._resolveFilenameModule的静态方法,传入相对路径,会返回绝对路径,我们也可以将后缀名省略,这个后面实现

js
Module._resolveFilename = function(modulePath) {
    // 将传入的路径解析成绝对路径
    modulePath = path.resolve(__dirname,modulePath)
    // 判断路径时都存在,(是否有后缀名,有后缀可能就存在)
    let isExist = fs.existsSync(modulePath)
    // 存在就返回该路径
    if(isExist) return modulePath
}

Module的实现很简单,其实就是给模块创建一个exports对象,load方法执行的时候,会读取模块中的内容,并给内容包裹一个函数,得到一个string类型的function,最后借助vm.runInThisContext创建一个沙箱环境将函数执行,传入的参数有:exports, require, module, __filename, __dirname,将运行后的内容加入到exports中,new Module时候传入的filepath也就是id就是模块带后缀的的绝对路径。

js
function Module(id){
    this.id = id; // 要加载的模块路径
    this.exports = {};
}

现在,Module有了模块的绝对路径,可以用load方法加载模块内容了,加载模块过程是同步的

js
Module.prototype.load = function() {
    // 获取后缀名
    const ext = path.extname(this.id);
    Module._extensions[ext](this); // 根据不同后缀名加载模块
}

前面说过,Node模块是将模块文件读取到,然后外面包裹一个函数,得到一个函数的string,然后利用vm模块创建一个沙箱环境,隔绝了上下文,将函数执行。 这里给Module挂载静态属性wrapper,里面定义一下这个函数的字符串,wrapper是一个数组,数组的第一个元素就包括函数的形参部分,其中有exportsmodulerequire__dirname__filename, 都是模块中常用的全局变量。注意这里传入的require参数是定义的req。第二个参数就是函数的结束部分。两部分都是字符串,使用的时候将他们包裹在模块的字符串外部就可以了。

js
Module.wrapper = [
    "(function(exports, module, Require, __dirname, __filename) {",
    "})"
]

_extensions用于针对不同的模块扩展名使用不同的加载方式,比如JSONjs加载方式肯定是不同的。JSON使用JSON.parse来运行

js
Module._extensions = {
    '.js'(module) {
        const content = fs.readFileSync(module.id, {encoding:'utf-8'});
        const fnStr = Module.wrapper[0] + content + Module.wrapper[1];
        const fn = vm.runInThisContext(fnStr);
        let exports = module.exports

        let dirname = path.dirname(module.id)
        let filename = module.id

        fn.call(exports,exports,module,req,dirname,filename)
    },
    '.json'(module) {
        let jsonStr = fs.readFileSync(module.id,{encoding:'utf-8'})
        module.exports = JSON.parse(jsonStr)
    }
}

加载js文件的时候,fn.call(exports,exports,module,req,dirname,filename),其实就是用module.exports去调用新生成的函数,最终函数的结果就会挂载到module.exports上;

到这里,简单的require加载机制就实现了

js
const path = require('path');
const fs = require('fs');
const vm = require('vm');

function Module(id){
    this.id = id; // 要加载的模块路径
    this.exports = {};
}

Module.wrapper = [
    "(function(exports, module, require, __dirname, __filename) {",
    "})"
]

Module._extensions = {
    '.js'(module) {
        const content = fs.readFileSync(module.id, {encoding:'utf-8'});
        const fnStr = Module.wrapper[0] + content + Module.wrapper[1];
        const fn = vm.runInThisContext(fnStr);
        let exports = module.exports

        let dirname = path.dirname(module.id)
        let filename = module.id

        fn.call(exports,exports,module,req,dirname,filename)
    },
    '.json'(module) {
        let jsonStr = fs.readFileSync(module.id,{encoding:'utf-8'})
        module.exports = JSON.parse(jsonStr)
    }
}

Module.prototype.load = function() {
    // 判断后缀名,根据后缀名加载模块
    const ext = path.extname(this.id);
    Module._extensions[ext](this); // 根据不同后缀名加载模块
}

Module._resolveFilename = function(modulePath) {
    // 将传入的路径解析成绝对路径
    modulePath = path.resolve(__dirname,modulePath)
    // 判断路径时都存在,(是否有后缀名,有后缀可能就存在)
    let isExist = fs.existsSync(modulePath)
    // 存在就返回该路径
    if(isExist) return modulePath
}

function req(modulePath) {
    // 1. 解析传入的路径成为绝对路径(且带后缀)
   let filepath  = Module._resolveFilename(modulePath) 
   // 2. 根据文件路径,创建模块
   const module = new Module(filepath);
    // 加载模块
    module.load()

   // 最终会得到 exports对象,默认是空对象{}
   return module.exports;
}


const a = req('./a.js')
console.log("----",a);

2.3 省略模块后缀名

上一步实现的模块加载机制,我们必须要传入完整的模块文件名,才能实现加载,但是真正的require是可以省略后缀的

省略后缀时候,首先会根据支持的后缀名依次给传入的模块名补充后缀,顺序是.js-->.json,这也是为什么在有同名的js和json文件情况下会优先加载js文件的原因,当补充后缀后,发现文件存在,就会去加载对应的文件。

上一步里面Module._resolveFilename方法是根据传入的相对路径解析得到绝对路径的,那么可以在这个方法里面添加省略后缀的逻辑;

js
Module._resolveFilename = function(modulePath) {
    // 将传入的路径解析成绝对路径
    modulePath = path.resolve(__dirname,modulePath)
    // 判断路径时都存在,(是否有后缀名,有后缀可能就存在)
    let isExist = fs.existsSync(modulePath)
    // 存在就返回该路径
    if(isExist) return modulePath;

    // 不存在就补充后缀名再解析
    let extArr = Object.keys(Module._extensions);
    for (let index = 0; index < extArr.length; index++) {
        modulePath = modulePath + extArr[index];
        if(fs.existsSync(modulePath)) return modulePath;
    }
    throw new Error('module not exists');
}

2.4 给模块添加缓存

原版的require机制里面,若是写了多个require同一个模块,则只有第一次才会去load模块,后面的都会用缓存里面的东西。从而提高加载的性能。

添加缓存也比较简单,就是文件加载的时候将文件放入缓存在,再去加载模块时先看缓存中是否存在,如果存在直接使用,如果不存在再去重新嘉爱,加载之后再放入缓存。

js
function req(modulePath) {
    // 1. 解析传入的路径成为绝对路径(且带后缀)
   let filepath  = Module._resolveFilename(modulePath) 
   let cachModule = Module._cache[filepath]
   if(cachModule) return cachModule.exports // 若加载的是同一个模块,则走缓存

   // 2. 根据文件路径,创建模块
   const module = new Module(filepath);

   Module._cache[filepath] = module; // 根据文件名添加缓存

   // 加载模块
   module.load()

   // 最终会得到 exports对象,默认是空对象{}
   return module.exports;
}

2.5 步骤总结

  1. 创建一个加载函数req,内部的Module._resolveFilename会根据传入的模块路径判断模块是否存在,还会补齐后缀名,补齐的顺序是 .js --> .json;
  2. 传入解析得到的模块路径创建一个模块对象(new Module(modulePath))。模块对象内部会创建一个exports对象;
  3. 调用加载模块的方法`Module.prototype.load,根据后缀名加载文件,并解析,用策略模式针对不同的后缀名匹配不同的加载方法,解耦代码;
  4. 加载完后,将加载的内容在一个隔绝上下文的环境运行,并将运行后的内容给module.exports,最终返回的就是module.exports
  5. 在加完成后。会根据模块的绝对路径,将module存入缓存,再次加载时候会取缓存内的module,不会重新加载文件。

2.6 最终完整代码

js
const path = require('path');
const fs = require('fs');
const vm = require('vm');

function Module(id){
    this.id = id; // 要加载的模块路径
    this.exports = {};
}

Module.wrapper = [
    "(function(exports, module, require, __dirname, __filename) {",
    "})"
]

Module._extensions = {
    '.js'(module) {
        const content = fs.readFileSync(module.id, {encoding:'utf-8'});
        const fnStr = Module.wrapper[0] + content + Module.wrapper[1];
        const fn = vm.runInThisContext(fnStr);
        let exports = module.exports

        let dirname = path.dirname(module.id)
        let filename = module.id

        fn.call(exports,exports,module,req,dirname,filename)
    },
    '.json'(module) {
        let jsonStr = fs.readFileSync(module.id,{encoding:'utf-8'})
        module.exports = JSON.parse(jsonStr)
    }
}

Module.prototype.load = function() {
    // 判断后缀名,根据后缀名加载模块
    const ext = path.extname(this.id);
    Module._extensions[ext](this); // 根据不同后缀名加载模块
}

Module._resolveFilename = function(modulePath) {
    // 将传入的路径解析成绝对路径
    modulePath = path.resolve(__dirname,modulePath)
    // 判断路径时都存在,(是否有后缀名,有后缀可能就存在)
    let isExist = fs.existsSync(modulePath)
    // 存在就返回该路径
    if(isExist) return modulePath;

    // 不存在就补充后缀名再解析
    let extArr = Object.keys(Module._extensions);
    for (let index = 0; index < extArr.length; index++) {
        modulePath = modulePath + extArr[index];
        if(fs.existsSync(modulePath)) return modulePath;
    }
    throw new Error('module not exists');
}
Module._cache = {};

function req(modulePath) {
    // 1. 解析传入的路径成为绝对路径(且带后缀)
   let filepath  = Module._resolveFilename(modulePath) 
   let cachModule = Module._cache[filepath]
   if(cachModule) return cachModule.exports // 若加载的是同一个模块,则走缓存

   // 2. 根据文件路径,创建模块
   const module = new Module(filepath);

   Module._cache[filepath] = module; // 根据文件名添加缓存

   // 加载模块
   module.load()

   // 最终会得到 exports对象,默认是空对象{}
   return module.exports;
}

三. exports 与 module.exports

在node中,exportsmodule.exports 都可以用于模块的导出。但是两种方式有区别,需要注意:

  1. exportsmodule.exports的别名(地址引用关系),导出对象最终以module.exports为准 1.1 所以exports = {}是错误的写法 1.2 所以exports.fn = function () {}module.exports.fn = function (){}两者等价
  2. module.exports{}的方式整体导出时会覆盖exports的属性和方法
  3. 如果一个模块没有导出任何成员, 导入的时候是空对象
  4. 可以用this代替exports,例如:this.a=xxx;this.b=xxx

在require模块加载机制里面,module.exports 与 exports的关系,在函数内是这样的

js
// 伪代码
function (module){
  let exports = module.exports;
  let this = exports;

  // 最终导出的是module.exports
  return module.exports;
}

实际上,等价于this = exports = module.exports;模块内exportsmodule.exportsthis都是指向同一块引用地址,默认是空对象{},是在Module里面声明的。 因此在写exports = xxx的时候,会改变模块内this的指向,是不允许这样写的 若是写了exports.a = xxx,相当于给他们共同维护的空间添加了一个a属性,值为xxx,最终导出的是module.exports,也是{a:xxx}

同时写了exportsmodule.exportsmodule.exports会覆盖exports,因为最终返回的是module。exports,因此,它优先级最高。即使exports.a={b:100},那么只是exports的指向被改变了,不会影响module.exports