Fork me on GitHub

前端模块概述

前端的模块化方案经历了相当久的发展,如今已经具有了相当的复杂度,甚至到了有些混乱的程度。各方的不同形式,不同实现的模块化方案在开发中都时不时出现,给很多前端开发人员带来了很多不愉快的心智负担。

今天这篇博客,就来谈一谈前端的模块化方案的发展历程。

模块,即用于实现特定功能的相互独立的一组方法。 通过模块来组织代码,构建工程,已经是现代软件工程中的基本常识和工序。

在如今的计算机技术语境下,如果一门编程语言没有模块系统,简直是不可想象的事情。但很不幸,JavaScript在相当长的时间里,都并没有自己的模块系统。

这有两个原因,一是JS初衷只是被设计用作制作简单的网页脚本,并没有被期待拥有如此大的文件规模,所以自然不需要模块系统。二是网页中的JS文件是需要通过网络请求进行加载的,不能像其他编程语言那样简单的同步的去访问其他文件,所以如果有模块系统,必须保证其具有更高的伸缩和适应性。因此一直到ES6之后,JS才正式推出模块化方案。

发展历程

函数式

在早期的JS中,创建模块,都是采用最直接的方法–将函数划分到多个文件然后分别引入。
如下所示:

Javascript
1
2
3
4
5
6
7
8
9
10
11
12
// a.js
function a1 () {
....
}
fucntion a2 () {
....
}

// b.js
function b() {
....
}

但这种方式存在以下缺点:

  1. 污染全局变量,模块中声明的函数和变量都是全局的。
  2. 当然,这样也无法防止与其它模块的变量名冲突。
  3. 也无法直观分析模块的依赖关系

对象式

因此,进一步的解决方法是将模块作为一个对象导出,模块的函数和状态作为对象的成员。
如下:

Javascript
1
2
3
4
5
6
7
8
9
10
11
var A = {
function a1() {
//...
}

function a2() {
//....
}

countNumbe: 0
}

这种方式也存在缺点:

  1. 因为JS里对象是没有私有属性的,因此这种方式暴露了所有的模块成员
  2. 因为暴露了所有成员,导致内部状态会被修改,会导致模块使用时难以维护。

闭包

再进一步的方法,是将对象创建放在一个立即执行函数 IIFE中并返回。

Javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var A = (function(){
// 模块的私有属性
    var countNumber = 0;

    var a1 = function(){
      //...
    };

    var a2 = function(){
      //...
    };

    return {
      a1 : a1,
      a2 : a2
    };

  })();

通过这种闭包的方式,可以将模块的私有属性封装起来,相当于模块具有了自己的内部状态,可以提高模块的独立性,避免模块内部状态在外部被修改。
同时,如果模块还需要依赖其他模块或者全局变量,可以将它们作为参数传入函数中,实现模块的继承和依赖。

Javascript
1
2
3
var A = (function (B, C) {
    //...
})(B, C);

这种方式,终于有一点正式的模块系统的样子了。
这种构建模块的方式,既然使用了闭包,那么闭包常见的问题,自然也是无法避免的了:

  1. 闭包将函数变量保存在内存里,内存消耗会相当大。
  2. 闭包使你可以在外部通过暴露的方法修改父函数的值,仍然存在维护困难的问题。

前端在Es6模块标准推出之前,存在两种社区的模块规范AMDCMD,在基础上都是通过上面这种闭包的形式来实现的,但在具体实现方式和模块加载时机上有一些不同。

AMD规范

AMD规范 (Asynchronous Module Definition),中文异步模块定义
这种规范采用的是异步加载模式。应用于客户端环境并且兼容服务端模块。

AMD规范 使用define方法定义模块。
第一个参数表示模块名,可选,默认使用脚本文件名。
第二个参数表示此模块依赖的模块数组。
第三个参数为依赖的模块加载完毕后执行的回调函数,加载的模块会以参数形式传入该函数。

Javascript
1
2
3
4
5
6
7
8
// moduleA

define(['package/lib'], function(lib) {
function say() {
lib.log("hello world")
}
return { say: say }
})

AMD规范 也使用require方法来加载模块,它接受两个参数。
第一个参数是要加载的模块(若模块无依赖,可省略)。
第二个参数是加载成功的回调函数。

Javascript
1
2
3
4
// test.js
require(['./moduleA'], functon(moduleA){
moduleA.say()
})

目前主要有两个JS库实现了 AMD规范, 分别是require.jscurl.js

CMD规范

CMD 规范(Common Module Definition),中文通用模块定义,是国内发展出来的。

AMD类似,不过在模块定义方式和模块加载,运行,解析时机上有所不同。

实现了CMD 规范JavaScript类库是 Sea.js, CMD规范也是它主推的。

Javascript
1
2
3
4
5
6
7
8
9
10
// 定义模块
// moduleA.js
define(fucntion(require, exports, module)) {
var $ = require('jquery.js')
$('p').text('loaded')
}


// 加载模块
seajs.use(['moduleA.js'], function(m){ ...})

AMD 和 CMD 的区别

最明显的区别是对依赖的处理时机不同

  1. AMD 推崇依赖前置,在定义模块时在文件顶部就就声明依赖的模块

  2. CMD 推崇就近依赖,在用到模块时再去require

当然,这两种规范都兼容对方的写法。

最大的区别是模块的执行时机不同。

AMD依赖前置,提前执行

因为它将模块的依赖全放在文件顶部,它在加载一个模块之后就可以立即知道模块的依赖,并加载和执行所有依赖模块

也因此依赖模块的加载执行不一定按照书写顺序,而是根据哪个模块先下载完毕。但回调函数一定是在所有依赖模块都加载执行完毕之后才运行的,这也使得AMD的用户体验较好,没有延迟。

CMD依赖就近,按需执行

它的依赖是在文件中的,因此需要把模块变为字符串解析一遍,才能知道模块的依赖,然后下载所有依赖的模块。
但需要注意,模块在下载完毕之后,并不执行 ,而是在所有模块下载完毕之后就执行回调函数,在遇到对应依赖模块的require语句时才执行模块。因此 CMD 的性能较好,因为只在用户需要的时候才执行,不会存在加载和执行无用的模块。

从上面可以看出,社区的模块方案中,模块被导出成一个对象或者函数,因此,模块依赖的确定,引入,执行,都是在运行时确定的,这种方式,好处是比较自由,可以动态的加载和使用模块。但是坏处就是,无法通过代码的静态分析,来确定模块的依赖,输入和输出的变量,因此也自然的无法在模块系统上添加静态类型检查,静态优化等等。

但值得开心,ES6 Module就是通过静态分析,在JS的编译时实现了模块方案。

ES6 Module

ES6 模块 设计的思想是静态化,在语言层面上实现了在编译时即确定依赖关系(其它的规范都只能在运行时确定),具体的,ES6 Module指定的不是模块导出的对象或者函数,而是指定模块导出的代码,这也使得它可以实现编译时加载和静态优化。虽然没有那么灵活,但模块和运行时无关这一特性带来的优势完全可以无视这个代价。

ES6 module 自动使用严格模式。

最需要注意的是严格模式顶层的this执行undefined

ES6 使用 export导出接口,使用import导入。两者都必须处于模块顶层,否则会报错。

export

Javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 输出变量
export var name = "huazhou"
export var otherName = "lili"

// 另一种方式输出变量
var name = "huazhou"
var otherName = "lili"
export {name, otherName}

// 输出函数
export function func(x, y){
return x*y
}

// 另一种方式输出函数
function func(x, y){
return x*y
}
export { func }

// 别名
export { func as v1 }

// 输出类

export class Person {
name: "xiaoxiao"
}

import
import引入的变量都是只读的(对象类型的可以修改,但强烈不建议修改, 因为其它引入的地方也会被动修改)

import 具有提升效果,无论位于任何位置,都会首先执行, 并且无法使用变量和表达式动态加载(因为是编译时就执行的)

无论import模块几次,都只会执行一次。

Javascript
1
2
3
4
5
6
7
8
9
10
11
// 引入
import { name, otherName, func, Person } from 'mod'

// 别名
import { name as personName } from 'mod'

// 整体引入
import * as m from 'mod'

console.log(m.name)
m.func()

export default
默认导出。好处是引入时直接引入即可,不需要知道模块导出的具体变量或方法的名字。

Javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 使用默认导出一个函数
// 函数名无关紧要,可有可无
export default funciton myfunc() {
console.log('aaa')
}

// 另一种方式
funciton myfunc() {
console.log('aaa')
}


// 加default就可以不用加大括号
export default myfunc
Javascript
1
2
// 引入不需要大括号
import defaultFunc from 'mod'

本质上,export default就是输出一个叫做default的变量或方法,然后在引入时你可以给他起任意的名字。

export … from
从另一个模块中导入在作为本模块的导出,用于组织模块。

Javascript
1
export { some } from 'mod'

HTML加载Es6 Module

目前所有浏览器都已经支持HTML中的模块加载。

Javascript
1
2
3
4
5
6
// 默认按照defer的形式异步加载,渲染完成再执行,可以保证执行顺序与书写顺序一致
// 可以加上async, 在下载完成时就执行。
<script type="module" src="./foo.js"></script>

// 也可以在script标签中直接使用模块
<script type="module"> import { name } from "mod" ....</script>

Es6 Module的优势

ES6的模块化采用了尽可能静态化的思想,是区别与其他的社区模块化方案的最大不同,不得说这是经过深思熟虑之后非常合适JS的一种方案。这使得JS在语言层面具有了更大的可能性。

相信在不远的将来,各种全局的API, 例如Math,浏览器的location对象等等,终于可以有自己的命名空间,而不必再挂载在全局上了。

当然,这是将来的事情,在目前这个时间节点上,ES6全新的模块系统统一了服务端和客户端的各种模块方案,配合WebPack等打包工具,我们已经差不多可以抛弃AMDCMD规范了(当然是在没有历史包袱的情况下)。

静态化的模块方案,不仅使得我们拥有了更统一和语言內集成的模块,也使得一些其他一些依赖于静态模块的特性终于可以应用在JS上。典型的,是 静态类型检查摇树优化。它们都需要通过Es6这种静态分析模块的方式来实现。当然,这两个东西也都值得大写特写,在本篇就不再多提了。

OK, 本篇博客就到这里,多谢阅读。

----本文结束感谢阅读----