前端的模块化方案经历了相当久的发展,如今已经具有了相当的复杂度,甚至到了有些混乱的程度。各方的不同形式,不同实现的模块化方案在开发中都时不时出现,给很多前端开发人员带来了很多不愉快的心智负担。
今天这篇博客,就来谈一谈前端的模块化方案的发展历程。
模块,即用于实现特定功能的相互独立的一组方法。 通过模块来组织代码,构建工程,已经是现代软件工程中的基本常识和工序。
在如今的计算机技术语境下,如果一门编程语言没有模块系统,简直是不可想象的事情。但很不幸,JavaScript
在相当长的时间里,都并没有自己的模块系统。
这有两个原因,一是JS
初衷只是被设计用作制作简单的网页脚本,并没有被期待拥有如此大的文件规模,所以自然不需要模块系统。二是网页中的JS
文件是需要通过网络请求进行加载的,不能像其他编程语言那样简单的同步的去访问其他文件,所以如果有模块系统,必须保证其具有更高的伸缩和适应性。因此一直到ES6
之后,JS
才正式推出模块化方案。
发展历程
函数式
在早期的JS
中,创建模块,都是采用最直接的方法–将函数划分到多个文件然后分别引入。
如下所示:
1 | // a.js |
但这种方式存在以下缺点:
- 污染全局变量,模块中声明的函数和变量都是全局的。
- 当然,这样也无法防止与其它模块的变量名冲突。
- 也无法直观分析模块的依赖关系
对象式
因此,进一步的解决方法是将模块作为一个对象导出,模块的函数和状态作为对象的成员。
如下:
1 | var A = { |
这种方式也存在缺点:
- 因为
JS
里对象是没有私有属性的,因此这种方式暴露了所有的模块成员 - 因为暴露了所有成员,导致内部状态会被修改,会导致模块使用时难以维护。
闭包
再进一步的方法,是将对象创建放在一个立即执行函数 IIFE中并返回。
1 | var A = (function(){ |
通过这种闭包的方式,可以将模块的私有属性封装起来,相当于模块具有了自己的内部状态,可以提高模块的独立性,避免模块内部状态在外部被修改。
同时,如果模块还需要依赖其他模块或者全局变量,可以将它们作为参数传入函数中,实现模块的继承和依赖。
1 | var A = (function (B, C) { |
这种方式,终于有一点正式的模块系统的样子了。
这种构建模块的方式,既然使用了闭包,那么闭包常见的问题,自然也是无法避免的了:
- 闭包将函数变量保存在内存里,内存消耗会相当大。
- 闭包使你可以在外部通过暴露的方法修改父函数的值,仍然存在维护困难的问题。
前端在Es6
模块标准推出之前,存在两种社区的模块规范AMD
和CMD
,在基础上都是通过上面这种闭包的形式来实现的,但在具体实现方式和模块加载时机上有一些不同。
AMD规范
AMD规范 (Asynchronous Module Definition),中文异步模块定义。
这种规范采用的是异步加载模式。应用于客户端环境并且兼容服务端模块。
AMD规范 使用define
方法定义模块。
第一个参数表示模块名,可选,默认使用脚本文件名。
第二个参数表示此模块依赖的模块数组。
第三个参数为依赖的模块加载完毕后执行的回调函数,加载的模块会以参数形式传入该函数。
1 | // moduleA |
AMD规范 也使用require
方法来加载模块,它接受两个参数。
第一个参数是要加载的模块(若模块无依赖,可省略)。
第二个参数是加载成功的回调函数。
1 | // test.js |
目前主要有两个JS
库实现了 AMD规范, 分别是require.js
和curl.js
。
CMD规范
CMD 规范(Common Module Definition),中文通用模块定义,是国内发展出来的。
与AMD
类似,不过在模块定义方式和模块加载,运行,解析时机上有所不同。
实现了CMD 规范 的JavaScript
类库是 Sea.js
, CMD
规范也是它主推的。
1 | // 定义模块 |
AMD 和 CMD 的区别
最明显的区别是对依赖的处理时机不同
AMD 推崇依赖前置,在定义模块时在文件顶部就就声明依赖的模块
CMD 推崇就近依赖,在用到模块时再去
require
当然,这两种规范都兼容对方的写法。
最大的区别是模块的执行时机不同。
AMD 是依赖前置,提前执行。
因为它将模块的依赖全放在文件顶部,它在加载一个模块之后就可以立即知道模块的依赖,并加载和执行所有依赖模块。
也因此依赖模块的加载执行不一定按照书写顺序,而是根据哪个模块先下载完毕。但回调函数一定是在所有依赖模块都加载执行完毕之后才运行的,这也使得AMD的用户体验较好,没有延迟。
CMD 是依赖就近,按需执行。
它的依赖是在文件中的,因此需要把模块变为字符串解析一遍,才能知道模块的依赖,然后下载所有依赖的模块。
但需要注意,模块在下载完毕之后,并不执行 ,而是在所有模块下载完毕之后就执行回调函数,在遇到对应依赖模块的require
语句时才执行模块。因此 CMD 的性能较好,因为只在用户需要的时候才执行,不会存在加载和执行无用的模块。
从上面可以看出,社区的模块方案中,模块被导出成一个对象或者函数,因此,模块依赖的确定,引入,执行,都是在运行时确定的,这种方式,好处是比较自由,可以动态的加载和使用模块。但是坏处就是,无法通过代码的静态分析,来确定模块的依赖,输入和输出的变量,因此也自然的无法在模块系统上添加静态类型检查,静态优化等等。
但值得开心,ES6 Module
就是通过静态分析,在JS
的编译时实现了模块方案。
ES6 Module
ES6 模块 设计的思想是静态化,在语言层面上实现了在编译时即确定依赖关系(其它的规范都只能在运行时确定),具体的,ES6 Module
指定的不是模块导出的对象或者函数,而是指定模块导出的代码,这也使得它可以实现编译时加载和静态优化。虽然没有那么灵活,但模块和运行时无关这一特性带来的优势完全可以无视这个代价。
ES6 module 自动使用严格模式。
最需要注意的是严格模式顶层的this
执行undefined
。
ES6 使用 export
导出接口,使用import
导入。两者都必须处于模块顶层,否则会报错。
export
1 | // 输出变量 |
importimport
引入的变量都是只读的(对象类型的可以修改,但强烈不建议修改, 因为其它引入的地方也会被动修改)
import
具有提升效果,无论位于任何位置,都会首先执行, 并且无法使用变量和表达式动态加载(因为是编译时就执行的)
无论import
模块几次,都只会执行一次。
1 | // 引入 |
export default
默认导出。好处是引入时直接引入即可,不需要知道模块导出的具体变量或方法的名字。
1 | // 使用默认导出一个函数 |
1 | // 引入不需要大括号 |
本质上,
export default
就是输出一个叫做default
的变量或方法,然后在引入时你可以给他起任意的名字。
export … from
从另一个模块中导入在作为本模块的导出,用于组织模块。
1 | export { some } from 'mod' |
HTML
加载Es6 Module
目前所有浏览器都已经支持HTML
中的模块加载。
1 | // 默认按照defer的形式异步加载,渲染完成再执行,可以保证执行顺序与书写顺序一致 |
Es6 Module
的优势
ES6
的模块化采用了尽可能静态化的思想,是区别与其他的社区模块化方案的最大不同,不得说这是经过深思熟虑之后非常合适JS
的一种方案。这使得JS
在语言层面具有了更大的可能性。
相信在不远的将来,各种全局的API
, 例如Math
,浏览器的location
对象等等,终于可以有自己的命名空间,而不必再挂载在全局上了。
当然,这是将来的事情,在目前这个时间节点上,ES6
全新的模块系统统一了服务端和客户端的各种模块方案,配合WebPack
等打包工具,我们已经差不多可以抛弃AMD
和CMD
规范了(当然是在没有历史包袱的情况下)。
静态化的模块方案,不仅使得我们拥有了更统一和语言內集成的模块,也使得一些其他一些依赖于静态模块的特性终于可以应用在JS
上。典型的,是 静态类型检查 和摇树优化。它们都需要通过Es6
这种静态分析模块的方式来实现。当然,这两个东西也都值得大写特写,在本篇就不再多提了。
OK, 本篇博客就到这里,多谢阅读。