你真的懂模块化吗?教你CommonJS实现

9,224 阅读9分钟

你真的懂模块化吗

加紧学习,抓住中心,宁精勿杂,宁专勿多。 —— 周恩来

模块简史

  • 早期的 JavaScript 往往作为嵌入到 HTML 页面中的用于控制动画与简单的用户交互的脚本语言,我们习惯这样写。
<!--html-->
<script type="application/javascript">
    // module1 code
    // module2 code
</script>
  • 所有的嵌入到网页内的 JavaScript 对象都会使用全局的 window 对象来存放未使用 var 定义的变量。这就会导致一个问题,那就是,最后调用的函数或变量取决于我们引入的先后顺序。

  • 模块化时代。随着单页应用与富客户端的流行,不断增长的代码库也急需合理的代码分割与依赖管理的解决方案,这也就是我们在软件工程领域所熟悉的模块化(Modularity)

  • 直接声明依赖(Directly Defined Dependences)、命名空间(Namespace Pattern)、模块模式(Module Pattern)、依赖分离定义(Detached Dependency Definitions)、沙盒(Sandbox)、依赖注入(Dependency Injection)、CommonJS、AMD、UMD、标签化模块(Labeled Modules)、YModules、ES 2015 Modules。这些都是模块化时代的产物。

  • 问题来了,过度碎片化的模块同样会带来性能的损耗与包体尺寸的增大,这包括了模块加载、模块解析、因为 Webpack 等打包工具包裹模块时封装的过多IIFE 函数导致的 JavaScript 引擎优化失败等。

那么到底什么是模块化?

简而言之,模块化就是将一个大的功能拆分为多个块,每一个块都是独立的,你不需要去担心污染全局变量,命名冲突什么的。

好处

  • 封装功能
  • 封闭作用域
  • 可能解决依赖问题
  • 工作效率更高,重构方便
  • 解决命名冲突
  • ...

js有模块化吗?

  • JS没有模块系统,不支持封闭的作用域和依赖管理
  • 没有标准库,没有文件系统和IO流API
  • 也没有包管理系统

那怎么实现js的模块化?

  • CommonJS规范,node是在v8引擎上的javascript运行时,作为服务端的,不能没有模块化的功能,于是就创造CommonJS规范,现在的node用的是CommonJS2。CommonJS2和CommonJS1的区别也在下面。属于动态同步加载
// CommonJS2也可以通过这种方式导出
module.exports = {
    a: 1
}
// CommonJS1只能通过这种方式
exports.a = 1

// b.js
var module = require('./a.js')
module.a // -> log 1
  • AMD && CMD。AMD是RequireJS提出的,主要是依赖前置。CMD是SeaJS提出的,主要是就近依赖(只要用到才会导入),两者用法接近。属于异步加载
// file lib/greeting.js
define(function() {
    var helloInLang = {
        en: 'Hello world!',
        es: '¡Hola mundo!',
        ru: 'Привет мир!'
    };

    return {
        sayHello: function (lang) {
            return helloInLang[lang];
        }
    };
});

// file hello.js
define(['./lib/greeting'], function(greeting) {
    var phrase = greeting.sayHello('en');
    document.write(phrase);
});
  • UMD。因为AMD中无法使用CommonJS,所以出来了一个UMD,可在UMD中同时使用AMD和CommonJS。
(function(define) {
    define(function () {
        var helloInLang = 'hello';

        return {
            sayHello: function (lang) {
                return helloInLang[lang];
            }
        };
    });
}(
    typeof module === 'object' && module.exports && typeof define !== 'function' ?
    function (factory) { module.exports = factory(); } :
    define
));

CommonJS实现

  • 首先我们这里说的CommonJS是CommonJS2,我们需要了解到它的特性。
  • 模块引用时会找到绝对路径
  • 模块加载过会有缓存,把文件名作为key,module作为value
  • node实现模块化就是增加了一个闭包,并且自执行这个闭包(runInThisContext)
  • 模块加载时是同步操作
  • 默认会加后缀js,json,...
  • 不同模块下的变量不会相互冲突

闭包实现(其实CommonJS中每个模块都是一个闭包,所以里面的变量互不影响)

  • 我们可以在vscode中创建一个arguments.js项目
//arguments就是参数列表
console.log(arguments)
  • 此时在node环境下执行该文件,就会输出如下
{ '0': {},
  '1': 
   { [Function: require]
     resolve: { [Function: resolve] paths: [Function: paths] },
     main: 
      Module {
        id: '.',
        exports: {},
        parent: null,
        filename: '/Users/chenxufeng/Desktop/笔记/node/arguments.js',
        loaded: false,
        children: [],
        paths: [Array] },
     extensions: { '.js': [Function], '.json': [Function], '.node': [Function] },
     cache: { '/Users/chenxufeng/Desktop/笔记/node/arguments.js': [Object] } },
  '2': 
   Module {
     id: '.',
     exports: {},
     parent: null,
     filename: '/Users/chenxufeng/Desktop/笔记/node/arguments.js',
     loaded: false,
     children: [],
     paths: 
      [ '/Users/chenxufeng/Desktop/笔记/node/node_modules',
        '/Users/chenxufeng/Desktop/笔记/node_modules',
        '/Users/chenxufeng/Desktop/node_modules',
        '/Users/chenxufeng/node_modules',
        '/Users/node_modules',
        '/node_modules' ] },
  '3': '/Users/chenxufeng/Desktop/笔记/node/arguments.js',
  '4': '/Users/chenxufeng/Desktop/笔记/node' }
  • 其实每个模块外面都包了这么一层闭包,所以外面的require才能获取到module.exports的值
//exports内存中指向的就是module.exports指向的那块空间
//require一个方法
//Module模块类
//__filename该文件绝对路径
//__dirname该文件父文件夹的绝对路径
(function(exports,require,Module,__filename,__dirname){
  module.exports = exports = this = {}
  //文件中的所有代码
  

  //不能改变exports指向,因为返回的是module.exports,所以是个{}
  return module.exports
})

所以我们require的时候其实就相当于执行了这么一个闭包,然后返回的就是我们的module.exports

require是怎么样的?

  • 每个模块都会带一个require方法
  • 动态加载(v8执行到这一步才会去加载此模块)
  • 不同模块的类别,有不同的加载方式,一般有三种常用后缀
    • 后缀名为.js的JavaScript脚本文件,需要先读入内存再运行
    • 后缀名为.json的JSON文件,fs 读入内存 转化成JSON对象
    • 后缀名为.node的经过编译后的二进制C/C++扩展模块文件,可以直接使用
  • 查找第三方模块
    • 如果require函数只指定名称则视为从node_modules下面加载文件,这样的话你可以移动模块而不需要修改引用的模块路径。
    • 第三方模块的查询路径包括module.paths和全局目录。

流程图

代码实现

下面我通过步骤讲解require整个的一个实现

根据路径找是否有缓存

//require方法
function req(moduleId){
  //解析绝对路径的方法,返回一个绝对路径
  let p = Module._resolveFileName(moduleId)
  //查看是否有缓存
  if(Module._catcheModule[p]){
    //有缓存直接返回对应模块的exports
    return Module._catcheModule[p].exports
  }
  //没有缓存就生成一个
  let module = new Module(p)
  //把他放入缓存中
  Module._catcheModule[p] = module
  //加载模块
  module.exports = module.load(p)
  return module.exports
}

上面有很多方法都还没有,不急,我们慢慢实现

创建Module类,并添加_resolveFileName_catcheModule

//node原生的模块,用来读写文件(fileSystem)
let fs = require('fs')
//node原生的模块,用来解析文件路径
let path = require('path')
//Module类,就相当于我们的模块(因为node环境不支持es6的class,这里用function)
function Module(p){
  //当前模块的标识
  this.id = p
  //没个模块都有一个exports属性
  this.exports = {}
  //这个模块默认没有加载完
  this.loaded = false
  //模块加载方法(这个我们到时候再实现)
  this.load = function(filepath){
    //判断文件是json还是 node还是js
    let ext = path.extname(filepath)
    //返回一个exports
    return Module._extensions[ext](this)
  }
}

//以绝对路径为key存储一个module
Module._catcheModule = {}
// 解析绝对路径的方法,返回一个绝对路径
Module._resolveFileName = function(moduleId){
  //获取moduleId的绝对路径
  let p = path.resolve(moduleId)
  try{
    //同步地测试 path 指定的文件或目录的用户权限
    fs.accessSync(p)      
    return p
  }catch(e){
    console.log(e)
  }
}

此时会有一个问题,如果我们没有传文件后缀,就会读取不到

给Module添加一个加载策略,并且在_resolveFileName中再加点东西

//所有的加载策略
Module._extensions = {
  '.js': function(module){
    //每个文件的加载逻辑不一样,这个我们后面再写
  },
  '.json': function(module){
  },
  '.node': 'xxx',
}
Module._resolveFileName = function(moduleId){
  //对象中所有的key做成一个数组[]
  let arr = Object.keys(Module._extensions)
  for(let i=0;i<arr.length;i++){
    let file = p+arr[i]
    //因为整个模块读取是个同步过程,所以得用sync,这里判断有没有这个文件存在
    try{
      fs.accessSync(file)      
      return p
    }catch(e){
      console.log(e)
    }
  }
}

此时,我们能够找到文件的绝对路径,并把他丢给Module实例上的load方法

load方法实现

//node原生的模块,用来读写文件(fileSystem)
let fs = require('fs')
//node原生的模块,用来解析文件路径
let path = require('path')
//提供了一系列 API 用于在 V8 虚拟机环境中编译和运行代码。
let vm = require('vm')
//Module类,就相当于我们的模块(因为node环境不支持es6的class,这里用function)
function Module(p){
  //当前模块的标识
  this.id = p
  //没个模块都有一个exports属性
  this.exports = {}
  //这个模块默认没有加载完
  this.loaded = false
  //模块加载方法
  this.load = function(filepath){
    //判断文件后缀是json还是 node还是js
    let ext = path.extname(filepath)
    return Module._extensions[ext](this)
  }
}

//js文件加载的包装类
Module._wrapper = ['(function(exports,require,module,__dirname,__filename){','\n})']
//所有的加载策略
Module._extensions = {
   //这里的module参数是就是Module的实例
  '.js': function(module){
    let fn = Module._wrapper[0] + fs.readFileSync(module.id,'utf8') + Module._wrapper[1]
    //执行包装后的方法 把js文件中的导出引入module的exports中
    //模块中的this === module.exports === {} exports也只是module.exports的别名
    //runInThisContext:虚拟机会产生一个干净的作用域来跑其中的代码,类似于沙箱sandbox
    vm.runInThisContext(fn).call(module.exports,module.exports,req,module)
    return module.exports
  },
  '.json': function(module){
    //同步读取文件中的内容并把它转为JSON对象
    return JSON.parse(fs.readFileSync(module.id,'utf8'))
  },
  '.node': 'xxx',
}

此时我们的代码已经全部完成

  • 我们随便找个文件试一下,当然如果是vscode下的话,req的路径参数需要在根目录下,这是一个坑。
  • 如果是vscode,就可以下一个插件Code Runner,可在vscode右键直接运行js文件,在node环境中。
  • 我们拿之前的arguments.js来实验

  • 成功输出!!

完整代码

//node原生的模块,用来读写文件(fileSystem)
let fs = require('fs')
//node原生的模块,用来解析文件路径
let path = require('path')
//提供了一系列 API 用于在 V8 虚拟机环境中编译和运行代码。
let vm = require('vm')
//Module类,就相当于我们的模块(因为node环境不支持es6的class,这里用function)
function Module(p){
  //当前模块的标识
  this.id = p
  //没个模块都有一个exports属性
  this.exports = {}
  //这个模块默认没有加载完
  this.loaded = false
  //模块加载方法
  this.load = function(filepath){
    //判断文件是json还是 node还是js
    let ext = path.extname(filepath)
    return Module._extensions[ext](this)
  }
}
//js文件加载的包装类
Module._wrapper = ['(function(exports,require,module,__dirname,__filename){','\n})']
//所有的加载策略
Module._extensions = {
  '.js': function(module){
    let fn = Module._wrapper[0] + fs.readFileSync(module.id,'utf8') + Module._wrapper[1]
    //执行包装后的方法 把js文件中的导出引入module的exports中
    //模块中的this === module.exports === {}  exports也只是module.exports的别名
    vm.runInThisContext(fn).call(module.exports,module.exports,req,module)
    return module.exports
  },
  '.json': function(module){
    return JSON.parse(fs.readFileSync(module.id,'utf8'))
  },
  '.node': 'xxx',
}
//以绝对路径为key存储一个module
Module._catcheModule = {}
// 解析绝对路径的方法,返回一个绝对路径
Module._resolveFileName = function(moduleId){
  let p = path.resolve(moduleId)
  try{
    fs.accessSync(p)      
    return p
  }catch(e){
    console.log(e)
  }
  //对象中所有的key做成一个数组[]
  let arr = Object.keys(Module._extensions)
  for(let i=0;i<arr.length;i++){
    let file = p+arr[i]
    //因为整个模块读取是个同步过程,所以得用sync,这里判断有没有这个文件存在
    try{
      fs.accessSync(file)      
      return file
    }catch(e){
      console.log(e)
    }
  }
}
//require方法
function req(moduleId){
  let p = Module._resolveFileName(moduleId)
  if(Module._catcheModule[p]){
    //模块已存在
    return Module._catcheModule[p].exports
  }
  //没有缓存就生成一个
  let module = new Module(p)
  Module._catcheModule[p] = module
  //加载模块
  module.exports = module.load(p)
  return module.exports
}