阅读 316

JS模块化的杂七杂八

本文首先会先按照时间顺序初步介绍CommonJSAMDCMDUMDES6 Module,然后针对CommonJSES6 Module进行深入阐述

一、初步介绍

在平常开发的过程中,所有的这些代码都是由一个个的模块构成的,我们可以在npm上下载我们需要的包,然后组装在我们的项目中,就好像组装模块一样,随着mvvm框架的普及,主流的开发方式都变成了模块化。在之前,js模块化还是通过命名空间来实现的,再后来产生了一个模块化规范(CommonJs),CommonJs是诞生于node社区,但它只能在服务端运行,在浏览器端还是用不了,于是,社区便出了AMD规范,国内又出了CMD,后来又出了UMD,其实这三个规范都是为了帮助模块化开发。目前比较流行的是ES6 Module规范。

CommonJS

CommonJS规范的特点:

  • 一个文件即一个模块。文件中定义的变量、函数、类都是私有的,外界无法访问模块内的内容

  • 通过module.exports暴露模块内的常量、函数、文件、模块等

  • module.exports导出模块,输出的是值的拷贝;模块导入的也是输出值的拷贝

    • 也就是说,一旦输出这个值,这个值在模块内部的变化是监听不到的(可对比ES6)
  • 使用require引入模块

  • 同步加载

    • 模块是同步加载的,即只有加载完成,才能执行后面的操作;
    • 因为CommonJS是运行在Nodejs服务端的,Node.js是同步模块加载,因此CommonJS是用同步的方式加载模块,对服务端来说require()是本地加载,正因为所有文件都在本地,读取速度很快,同步加载不会造成什么不好的影响,但是在浏览器端,因为网络的原因,同步加载就会在一定程度限制资源的加载速度
  • 模块是运行时加载(运行时加载)

    • CommonJS规范中,require()是用来加载一个模块,那require这语句做了什么事?

      运行文件时,Node发现文件中使用require加载一个A模块,Node首先会执行整个A模块,然后在内存中生成一个对象,该对象就是A模块的一个表达载体,接着从该对象的exports属性中取出A模块输出的各个接口,供当前文件使用

      {
         id: '',  // 唯一的模块名
         exports: {  // 包含模块输出的各个接口
            ...
         },
         loaded: true,  // 模块的脚本是否执行完毕
         ...
      }
      复制代码

      若在文件中再次使用require加载A模块,Node也不会重复执行A模块,而是直接在内存中找到A模块的表达对象,取出exports属性的值。也就是说无论通过require加载多少次相同的模块,都只会在第一次加载中运行,往后都是从对象中取出exports属性的值(除非手动清除系统缓存)

然后可以看一下CommonJS规范的代码

var foo = require('./foo') // 引入模块
var foo = require('event').some // 引入模块的某个属性

function fn() {
  console.log('fn')
}
module.exports.fn = fn // 通过module.exports将fn函数暴露出去
// exports.fn = fn // 或者通过exports暴露出去
复制代码

AMD(Async Module Definition )

AMD是异步模块加载机制,作者以RequireJS实现了AMD规范,所以说起AMD规范就能想到RequireJS,它的特点:

  • 使用define定义模块: define可以传入三个参数,分别是模块名(string)、依赖模块(array)、回调函数(function

    • 第一个参数是模块名,字符串类型,可选参数。若不存在则模块标识应该默认定义为在加载器中被请求脚本的标识。如果存在,那么模块标识必须为顶层的或者一个绝对的标识

    • 第二个参数是依赖模块,数组类型,是一个当前模块依赖的,已被模块定义的模块标识的数组字面量

    • 第三个参数是一个需要进行实例化的函数或者一个对象

      // 定义无依赖的模块
      define({
        fn1: function(a){
          return a
        },
        arr1: []
      })
      
      // 定义有依赖的模块
      define(["a"], function(a){
        return {
          fn1: function(){
            return a.num
          }
        }
      })
      
      // 具名模块
      define("foo", [ "a", "b"], function(a, b){
          ...
      });
      复制代码
  • 使用require引入模块:

  • 异步加载模块:模块的加载不影响它后面语句的运行,所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行

  • 依赖前置,提前执行

    • define方法里传入的依赖模块(数组),会在一开始就下载并执行
    • RequireJS从2.0开始,也改成可以延迟执行,只是写法上和提前执行有些不同。虽然 AMD也支持CMD写法,但依赖前置是官方文档的默认模块定义写法

然后可以看一下AMD规范的代码

// src/foo.js
// 会在声明并初始化要用到的所有模块(a和b),不管后续是否用到
// 参数1 "foo" 是模块名
// 参数2 ["a", "b"] 是依赖的模块名
// 参数3 function(){} 是回调函数
define("foo", ["a", "b"], function (a, b) { // 定义foo.js模块
    const NUM = 2
    const fn = function () {
      console.log('foo')
    }
    return {
        NUM: NUM,
        fn: fn
    }
})


// app.js
require.config({ // 通过require.config()设置每个模块路径和引用名
  baseUrl: "src",
  paths: {
    "foo": "foo", // 实际路径为src/foo.js, 指定foo.js的引用名为foo
  }
})

// 加载foo.js模块
// 加载模块时要将模块名(也可以是文件路径)放在[]中作为reqiure()的第一参数,
require(["foo"],function(foo){
  console.log(foo)
})
复制代码

CMD

CMD是通用模块定义,CMDSeaJS在推广过程中生产的对模块定义的规范,SeaJS的作者就是大名鼎鼎的玉伯,它的特点:

  • 一个文件为一个模块
  • 使用define定义模块
  • 使用require引入模块
  • 依赖就近,延迟执行: 只有执行到require()时,依赖模块才执行。

AMDCMD其实很多地方相似,但是它们最大区别是执行方式的不同,在执行过程中,经过AMD编译后,所有require引入的模块都被前置了,CMD虽然也会将require的代码下载下来,但是它不会去执行,直到代码运行到那个模块的依赖才会去执行对应模块

/** AMD写法 **/
define(['./a','./b'], function (a, b) {
    //依赖一开始就写好,等于在最前面声明并初始化了要用到的所有模块
    a.test() // 即便没用到某个模块 b,但 b 还是提前执行了
})
 
/** CMD写法 **/
define(function (requie, exports, module) {
    if (false) {
        var b = require('./b') //依赖可以在需要时申明
        b.doSomething()
    }
    exports.fn = function() {
      console.log('fn')
    };
})
复制代码

👇是sea.js的使用demo

//a.js
define(function (require, export, module)) {
  // 通过require引入依赖
  var foo = require('./foo')
  const NUM = 2
  // 通过export或者通过module.exports暴露接口
  exports.num = NUM
  // module.exports.num = NUM
}


//b.js
// 加载模块
// 数组中声明需要加载的模块,可以是模块名、js文件路径
seajs.use(['./a'], function(a) {
  a.fn();
});
复制代码

UMD

webpack打包过程中就有UMD这个选项,UMD其实是一个通用解决方案,它本身不是什么新的规范,就好似一个判断条件一样。在模块定义中,它主要做了三件事

  • 先判断是否支持AMD。如果是则使用AMD,否则执行下一步
  • 然后判断是否支持CommonJs。如果是则使用CommonJs,否则执行下一步
  • 如果都不是,则定义为全局变量
(function (root, fac) {
  if (typeof define === 'function' && define.amd) { // 如果define这个方法被定义,且define.amd是否存在,说明是AMD
    define([], fac) // 以AMD的规范去定义模块
  } else if (typeof exports === 'object'){ // exports为一个对象,这个就是判断是否在node环境中,满足Commonjs规范
    module.exports = fac() // 以commonjs的规范去暴露接口
  } else {
    root.some = fac() // 暴露给浏览器的全局环境,root其实就是window
  }
})
复制代码

ESM(ES6 Module)

ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,旨在成为浏览器和服务器通用的模块解决方案。webpack3原生支持

  • 一个文件为一个模块

  • 使用export暴露模块内的常量、函数、文件、模块等,暴露的是对值的引用

    • ES6 Module暴露出去的是一种静态定义(引用)
  • export命令规定的是对外的接口,必须与模块内部的变量建立一一对应关系。

  • 使用import引入模块

    • ES6 Module模块对导出模块,变量,对象是动态引用,遇到模块加载命令import时不会去执行模块,只是生成一个指向被加载模块的引用,不存在缓存值的问题
    • 引用带来的一个特点是,不会缓存运行结果,而是动态地去被加载的模块取值,也就是模块暴露的内容发生变化时,通过import引入的值本身也会相应的变化(在后面循环加载章节会详细阐述)
  • 模块是编译时输出接口(编译时加载)

    • ES6 Module暴露出去的是一种静态定义(引用),在代码静态解析阶段就会生成

import/export 最终都是编译为 require/exports 来执行的

// foo.js 暴露接口
export const NUM = 12
function fn() {
  console.log('fn')
}
export { fn }
export default class myClass {...} // 默认暴露


// app.js 引入模块
import myClass, { fn, NUM } from 'other_module'
复制代码

目前webpack支持AMD(requireJS)ES6 Module(官方推荐)、CommonJS

require: node 和 es6 都支持的引入 export / import : 只有es6 支持的导出引入 module.exports / exports: 只有 node 支持的导出

二、理解CommonJS中的exports、module.exports

CommonJS规范中认为一个文件即一个模块

CommonJS是运行在Nodejs服务端的,Node为每个模块都创建一个module对象以代表当前模块,初始值为{},该对象的exports属性(即module.exports)是对外的接口,当通过**require加载某个文件时,其实就是加载该文件的module.exports**。下面代码通过module.exports输出变量el和函数fn1。

// foo1.js
const el = 5
const fn1 = function (val) {
  return `val: ${val}`
}
module.exports.el = el
module.exports.fn1 = fn1
复制代码
// index.js
var foo1 = require('./foo1') // 加载模块

console.log(foo1) // { a: 5, fn1: [Function: fn1] }
console.log(foo1.el) // 5
console.log(foo1.fn1(4)) // val: 4
复制代码

前面说了,require加载某个文件时,其实就是加载该文件的module.exports,那exports对象是什么?

为了方便,Node为每个模块提供一个exports对象,并让exports对象指向module.exports,如此一来,exportsmodule.exports都指向同一块内存区域,这样我们就可以直接在exports对象上添加变量、函数、类等,以表示对外输出的接口,如同添加在module.exports一样。其作用就像👇这行代码一样,

var exports = module.exports; 
复制代码

来看一下通过exports暴露接口的代码:

// foo1.js
const el = 5
const fn1 = function (val) {
  return `val: ${val}`
}
// 与module.exports上方写法相同效果
exports.el = el
exports.fn1 = fn1
复制代码

但是需要注意的是,不能直接将exports变量指向一个值(直接被覆盖取值 例如:export = xxx),因为这样等于切断了exportsmodule.exports的联系;也不能修改module.exports值,这样也会让exports切断联系

掏个🌰感受下这句话的意思

// index.js
var foo1 = require('./foo1');
console.log(foo1)

// fool1.js 
// eg1: 在第一次赋值之后,如果单独修改module.exports指向,export还是停留在上一次赋值的值,并且此时通过exports暴露任何变量、函数等是没有用的,因为export已经不指向module.exports了
var exports = module.exports = {a: 1}
module.exports = {a: 2} // 此时exports值为{a: 1}
const el = 5
exports.el = el // index.js输出的值:{a: 2}

// fool1.js  
// eg2:修改exports值,同样导致export断开与module.exports的联系
var exports = module.exports
const el = 5
exports = {} // export指向新的对象
exports.el = el // index.js输出的值:{},因为module.exports默认是{}
复制代码

从上面可以看出,require永远只会导出module.exports的内容,exports就好似一个代驾,当断开与module.exports的联系,他就没用了,它的作用就是方便用户使用module.exports这个对象

总结一下exportsmodule.exports的区别了:

  • module.exports 初始值为一个空对象 {}
  • exports是指向的 module.exports的引用
  • require()返回的值是被加载文件的module.exports

如果有人有疑问,既然是引用关系,为啥修改exports不会同时修改module.exports的值?可以看看👇代码理解下引用的意思

var a = {name: 'one'}
var b = a
console.log(a) // {name: 'one'}
console.log(b) // {name: 'one'}

b.name = 'two'
console.log(a) // {name: 'two'}
console.log(b) // {name: 'two'}

var b = {name: 'three'}
console.log(a) // {name: 'two'}
console.log(b) // {name: 'three'}
复制代码

a 是一个对象,b 是对 a 的引用,即 a 和 b 指向同一块内存,所以前两个输出一样。当对 b 作修改时,即 a 和 b 指向同一块内存地址的内容发生了改变,所以 a 也会体现出来,所以第三四个输出一样。当 b 被覆盖时,b 指向了一块新的内存,a 还是指向原来的内存,所以最后两个输出不一样

三、理解ES6 Module的export、export default

ES6 module是通过exportexport default来暴露接口,那它们的联系和区别是什么?

先说明exportexport default的区别:

  • 在一个文件或模块中,export可以有多个,export default仅有一个

  • 通过export方式导出,在导入时要加{ },export default则不需要

  • export可直接导出变量表达式,export default不可

// foo.js
// export导出
export const NUM1 = 1 // 直接导出
function fn () {
  console.log('res')
}
const NUM2 = 2
export { fn, NUM2 } // 间接导出

// export default导出
export default function fn1 () { // 直接导出
  console.log('res1')
}
// const NUM2 = 2
// export default NUM2 // 间接导出
// export defult const NUM2 = 2 // export default不可导出变量表达式
复制代码
// index.js
import { NUM1, fn, NUM2 } from './foo' // 导出了export
import fn1 from './foo'  // 导出了export default
// import fn1, { NUM1, fn, NUM2 } from './foo' // 等价于上面两行

import * as all from './foo' // 将export和export default暴露出的接口都用对象all表示

fn1() // res1
fn() // res
console.log(NUM1) // 1
console.log(NUM2) // 2

console.log(all.NUM) // 1
console.log(all.default) // res1 export default暴露出来的在all的default属性里
复制代码

另外提一下,暴露的方式还有一些拓展的写法

// 第二种方式:先定义,后暴露
const NUM = 12
function fn() {...}

export { NUM, fn } // 暴露上面定义的NUM变量和fn函数
export { NUM as NUM_NEW, fn as fn1 } // 以NUM_NEW代替NUM,被暴露出去;fn同理,import也要用NUM_NEW和fn1来引入

// 可以将其他模块的内容引入进来,然后再暴露出去,作一个中转站
export {foo, bar} from 'other_module' // 引入foo和bar接口,并将其暴露出去
export * from 'src/other_module' // 表示先引入other_module模块的所有接口,然后全部暴露出去
export {foo as foo1, bar} from 'other_module' // 也可以进行重命名,将foo以foo1的名字暴露出去
复制代码

四、CommonJS和ES6 Module应对”循环加载“

“循环加载”指的是,a脚本的执行依赖b脚本,而b脚本的执行又依赖a脚本

// a.js
var b = require('b');

// b.js
var a = require('a');
复制代码

循环加载最容易发生的情况就是递归加载,导致无限循环,但是复杂项目中模块众多,模块互相依赖的情况也时常发生,因此,主流的两个模块规范CommonJSES6 Module也给出了各自的解决方案

CommonJS的循环加载

前面讨论过CommonJS的加载原理,require第一次加载模块会生成一个对象,此后重复加载该模块都是从对象中的exports属性中取值。针对循环加载

👉CommonJS的方案是:一旦出现某个模块被“循环加载”,就只输出已经执行的部分,没有执行的部分不会输出

结合🌰进行理解,下面🌰来自于Node官方文档

//main.js
var a = require('./a.js');
var b = require('./b.js');

console.log('在main.js中,a.done = %j, b.done = %j', a.done, b.done);
复制代码
//a.js
exports.done = false;

var b = require('./b.js');
console.log('在a.js中,b.done = %j', b.done);

exports.done = true;
console.log('a.js执行完毕!')
复制代码
//b.js
exports.done = false;

var a = require('./a.js');
console.log('在b.js中,a.done = %j', a.done);

exports.done = true;
console.log('b.js执行完毕!')
复制代码
node main.js // 运行main.js

输出结果:
在b.js中,a.done = false
b.js执行完毕!
在a.js中,b.done = true
a.js执行完毕!
在main.js中,a.done = true, b.done = true
复制代码
  • 执行过程如下:
    • 首先加载a模块,a模块暴露done=false,然后在a模块中加载b模块,b模块暴露done=false,然后加载a模块,此时发生循环加载
    • 此时,系统不会去继续重新加载a模块,而是从之前加载a模块时创建的对象中取值,而其中的值也仅仅是已经执行完的部分,系统从对象的exports属性中取值,done=false,然后继续往下执行,输出在b.js中,a.done = false,然后输出b.js执行完毕!
    • b模块执行结束后,a模块就可以继续执行了,此时b模块的done=true,因此输出在a.js中,b.done = true,然后输出a.js执行完毕!
    • 此时a,b都执行结束,且都在内存创建对应的对象,在main.js中加载a、b模块,则直接从对象中获取exports属性

ES6 Module的循环加载

前面介绍了ES6 Module两个特点:

1、使用export暴露接口,暴露的是对值的引用

2、使用import动态引入模块,遇到import时不会去执行模块,只是生成一个指向被加载模块的引用,等到真的需要用到模块时,再到模块里面去取值。

举个🌰,例子来源于阮一峰

// a.js
export var foo = 'bar';
setTimeout(() => foo = 'baz', 500);

// b.js
import {foo} from './m1.js';
console.log(foo);
setTimeout(() => console.log(foo), 500);

// 执行b.js,输出为:
bar
baz
复制代码

从结果可以看出,a.js的变量foo,在刚加载时等于bar,过了500毫秒,又变为等于baz,b.js一开始获取到foo的值时bar,500毫秒后获取的值时baz,这就说明ES6 Module 不会去缓存运行结果,而是动态地去取被加载模块暴露的值,因为import生成的引用,其实就是一个地址引用,指向那块数据的内存

👉ES6 Module不会关心import是否发生”循环加载“,因为它仅仅生成一个引用,需要开发者自己保证,在文件中真正使用被加载模块时,引用是可以取到值

也就是说,在文件中使用import加载A模块时,仅仅是生成一个引用(类似指针),该引用指向的是A模块暴露接口的地址,因此,开发者必须保证在真正使用A模块时,是可以取到A模块的值,否则就会报错

// a.js
import {resB} from './b'
console.log('a')
export const resA = 2

// b.js
import {resA} from './a'
console.log('b')
export const resB = 3
console.log(resA)

// 输出结果
b
undefined
a
复制代码

上述代码,a.js加载b.js,b.js又加载a.js,构成了循环加载。按照CommonJS规范,上面的代码是没法执行的。a先加载b,然后b又加载a,这时a还没有任何执行结果,所以输出结果为null,即对于b.js来说,变量resA的值等于null,后面的foo()就会报错。

让我们一行行来看,ES6 循环加载是怎么处理的。首先,执行a.js以后,引擎发现它加载了b.js,因此会优先执行b.js,然后再执行a.js。接着,执行b.js的时候,已知它从a.js输入了resA接口,这时不会去执行a.js,而是认为这个接口已经存在了,继续往下执行,执行到console.log(resA)时,发现这个接口没有定义,然后输出undefined。接着继续执行a.js,输出a

补充知识点:在代码运行前,函数声明和变量定义通常会被解释器移动到其所在作用域的最顶部。如果把函数写成函数表达式,就不具有提升作用了

那如何解决这个问题?可以利用函数提升来解决,在a.js中通过函数包裹NUM常量,然后将该值进行返回,由于函数会被提升,所以当b.js执行resA()的时候,a.js的resA已经被声明了,此时执行console.log(resA())就能得到正常的结果

// a.js
import {resB} from './b'
console.log('a')
function resA() {
  const NUM = 2
  return NUM
}
export { resA }

// b.js
import {resA} from './a'
console.log('b')
export const Foo2 = 3
console.log(resA())

// 输出结果
b
2
a
复制代码

五、CommonJS与ES6 Module的差异

  • CommonJS暴露出的是一个值的拷贝,一旦暴露接口,这个接口在模块内部的变化是监听不到的;ES6 Module暴露的是内容的引用,模块内部被暴露的接口改变会影响引用的改变
  • 若遇到重复加载的情况,CommonJS会直接从第一次加载时生成对象的exports属性中取值;ES6 Module则会通过引用找到模块暴露接口的内存位置,并从中取值
  • 若出现循环加载情况,CommonJS只输出已经执行的部分,还未执行的部分不会输出;ES6 Module需要开发者自己保证,真正取值的时候能够取到值
  • CommonJS是加载时执行,若出现循环加载情况,则从已执行的内容中取值;ES6 Module是动态引用,加载模块时不执行代码,只是生成一个指向被加载模块的引用
  • CommonJS模块是运行时加载,ES6 Module模块是编译时输出接口
  • CommonJS是加载整个模块,ES6 Module可以按需加载部分接口

希望看完本篇文章能对你有所帮助,

文中如有错误,欢迎在评论区指正,如果这篇文章帮助到了你,欢迎点赞和关注。

参考资料 📖