前端模块化发展史
十年之前,模块化还只是使用「闭包」简单的实现一个命名空间。使用这种解决方式可以简单粗暴的处理全局变量和依赖关系等问题。
转眼间模块化已经发展了有十余年了,不同的工具和轮子层出不穷,下面是最各大工具或框架的诞生时间:
1. 时间线
生态 诞生时间
CommonJS 2009年
Node.js 2009年
NPM 2010年
requireJS(AMD) 2010年
seaJS(CMD) 2011年
broswerify 2011年
webpack 2012年
grunt 2012年
gulp 2013年
react 2013年
vue 2014年
ES6(Module) 2015年
angular 2016年
redux 2015年
vite 2020年
snowpack 2020年
不知不觉,模块化的发展已有十年之余了。
2. 模块化的目的
前端模块化,默认聊的就是 JavaScript 模块化,从一开始定位为简单的网页脚本语言,到如今可以开发复杂交互的前端,模块化的发展自然而然,目的无非是为了代码的可组织重用性、隔离性、可维护性、版本管理、依赖管理等。
前端模块跑在浏览器端,异步的加载 JavaScript 脚本,使模块化的考虑需要比后端从直接可以快速的本地加载模块的实现需要考虑的更多。
我们来看看都有哪些作品深刻的影响了前端模块化发展:
3. CommonJS 横空出世
让我们把时间追溯到 2009 年 1 月 29 日,在这个普通的冬天里,万物开始生长, JavaScript
社区中, Mozilla
的工程师 Kevin Dangoor
发布了一篇文章 《What Server Side JavaScript needs》,并在 Google Groups 中创建了一个 ServerJS 小组,旨在构建更好的 JavaScript 生态系统,包括服务器端、浏览器端,而后更名为 CommonJS 小组,发起了 CommonJS
的提案。
再到后来横空出世的 Node.js
采用 CommonJS
模块化规范,同时还带来了 npm
(全球最大的模块仓库) 。
「nodejs 中模块的实现并非完全按照 CommonJS 的规范来的,而是进行了取舍,并增加了少许特性。」
Node.js
的发布是 2009 年末,基于 CommonJS
社区最初的主流规范实现模块化,但是之后赢得了 Server-side JavaScript
战争的 Node.js
更加看重实际开发者的声音而不是 CommonJS
社区许多腐朽化的规范,虽然大体上的使用方法未变,但之后 Node.js modules
发展其实于 CommonJS
已分道扬镳。
CommonJS
规范在服务端表现出色,使得 JavaScript
在服务器端大放异彩,与传统服务器语言(PHP、Python)等产生抗衡甚至压倒之势。程序员们便萌发出了将它移植到浏览器端的想法。
然而由于CommonJS
的模块加载是同步的。我们知道,服务器端加载的模块从内存或磁盘中加载,耗时基本可忽略。但是在浏览器端却会造成阻塞,白屏时间过长,用户体验不够友好。
因此,从CommonJS
中逐渐产生了一些分支,也就是业内熟知的AMD
、CMD
等。
讲到这里那就一定要谈一下 RequireJS 和 SeaJS:
- RequireJS 遵循的是 AMD(异步模块定义)规范;
- SeaJS 遵循的是 CMD (通用模块定义)规范。
随着 2015 年 6 月,ECMAScript 对 ES6 Modules 的正式发布,浏览器厂商和 Node.js 随之纷纷跟进实现,市面上的模块化加载库随之暗淡失色,间接给 CommonJS 社区判了死刑。在浏览器端取而代之流行的做法的是大家都使用 ES6 Modules 写法,然后使用 Babel 等的 transpiler 来应对不同浏览器版本的支持程度和在浏览器端异步特性产生的一些待解决的问题。Node.js 的模块还是大量的采用 CommonJS 模式,随着对 ES6 Modules 的支持力度的提高和可以兼容之前 CommonJS 模块,CommonJS 写法过渡到 ES6 Modules 只是时间的问题。
4. AMD 与 CommonJS 的兼容性
CommonJS规范加载模块是同步的,也就是说,只有加载完成,才能执行后面的操作。AMD规范则是非同步加载模块,允许指定回调函数。由于Node.js主要用于服务器编程,模块文件一般都已经存在于本地硬盘,所以加载起来比较快,不用考虑非同步加载的方式,所以CommonJS规范比较适用。但是,如果是浏览器环境,要从服务器端加载模块,这时就必须采用非同步模式,因此浏览器端一般采用AMD规范。
AMD规范使用define
方法定义模块,下面就是一个例子:
define(['package/lib'], function(lib){
function foo(){
lib.log('hello world!');
}
return {
foo: foo
};
});
AMD规范允许输出的模块兼容CommonJS规范,这时define
方法需要写成下面这样:
define(function (require, exports, module){
var someModule = require("someModule");
var anotherModule = require("anotherModule");
someModule.doTehAwesome();
anotherModule.doMoarAwesome();
exports.asplode = function (){
someModule.doTehAwesome();
anotherModule.doMoarAwesome();
};
});
5. AMD 和 CMD 的区别
-
AMD 是 RequireJS 在推广过程中对模块定义的规范化产出。
-
CMD 是 SeaJS 在推广过程中对模块定义的规范化产出。
上述两种规范是服务于JavaScript的模块化开发,目前两种都能实现浏览器端的模块化开发的目的,不同之处是CMD是懒执行,AMD是预执行。
区别:
- 对于依赖的模块,AMD 是提前执行,CMD 是延迟执行。不过 RequireJS 从 2.0 开始,也改成可以延迟执行(根据写法不同,处理方式不同)。CMD 推崇 as lazy as possible.
- CMD 推崇依赖就近,AMD 推崇依赖前置。
看代码:
// CMD
define(function (require, exports, module) {
var a = require('./a')
a.doSomething()
// 此处略去 100 行
var b = require('./b') // 依赖可以就近书写
b.doSomething()
// ...
})
// AMD 默认推荐的是
define(['./a', './b'], function (a, b) {
// 依赖必须一开始就写好
a.doSomething()
// 此处略去 100 行
b.doSomething()
//...
})
虽然 AMD 也支持 CMD 的写法,同时还支持将 require 作为依赖项传递,但 RequireJS 的作者默认是最喜欢上面的写法,也是官方文档里默认的模块定义写法。
● AMD 的 API 默认是一个当多个用,CMD 的 API 严格区分,推崇职责单一。
- AMD 里,require 分全局 require 和局部 require,都叫 require。
- CMD 里,没有全局 require,而是根据模块系统的完备性,提供 seajs.use 来实现模块系统的加载启动。CMD 里,每个 API 都简单纯粹。
6. RequirJS 和 SeaJS 的区别
两者的主要区别如下:
- 定位有差异。RequireJS 想成为浏览器端的模块加载器,同时也想成为 Rhino / Node 等环境的模块加载器。Sea.js 则专注于 Web 浏览器端,同时通过 Node 扩展的方式可以很方便跑在 Node 环境中。
- 遵循的规范不同。RequireJS 遵循 AMD(异步模块定义)规范,Sea.js 遵循 CMD (通用模块定义)规范。规范的不同,导致了两者 API 不同。Sea.js 更贴近 CommonJS Modules/1.1 和 Node Modules 规范。
- 推广理念有差异。RequireJS 在尝试让第三方类库修改自身来支持 RequireJS,目前只有少数社区采纳。Sea.js 不强推,采用自主封装的方式来“海纳百川”,目前已有较成熟的封装策略。
- 对开发调试的支持有差异。Sea.js 非常关注代码的开发调试,有 nocache、debug 等用于调试的插件。RequireJS 无这方面的明显支持。
- 插件机制不同。RequireJS 采取的是在源码中预留接口的形式,插件类型比较单一。Sea.js 采取的是通用事件机制,插件类型更丰富。
总之,如果说 RequireJS 是 Prototype 类库的话,则 Sea.js 致力于成为 jQuery 类库。
7. ES6 Module 原生支持
Javascript 程序本来很小——在早期,它们大多被用来执行独立的脚本任务,在你的 web 页面需要的地方提供一定交互,所以一般不需要多大的脚本。过了几年,我们现在有了运行大量 Javascript 脚本的复杂程序,还有一些被用在其他环境(例如 Node.js)。
因此,近年来,有必要开始考虑提供一种 将 JavaScript 程序拆分为可按需导入的单独模块 的机制。Node.js 已经提供这个能力很长时间了,还有很多的 Javascript 库和框架 已经开始了模块的使用(例如, CommonJS 和基于 AMD 的其他模块系统 如 RequireJS, 以及最新的 Webpack 和 Babel)。
好消息是,最新的浏览器开始原生支持模块功能了,这是本文要重点讲述的。这会是一个好事情 — 浏览器能够最优化加载模块,使它比使用库更有效率:使用库通常需要做额外的客户端处理。
ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。CommonJS 和 AMD 模块,都只能在运行时确定这些东西。比如,CommonJS 模块就是对象,输入时必须查找对象属性。
// CommonJS模块
let {
stat, exists, readfile } = require('fs');
// 等同于
let _fs = require('fs');
let stat = _fs.stat;
let exists = _fs.exists;
let readfile = _fs.readfile;
上面代码的实质是整体加载fs
模块(即加载fs
的所有方法),生成一个对象(_fs
),然后再从这个对象上面读取 3 个方法。这种加载称为“运行时加载”,因为只有运行时才能得到这个对象,导致完全没办法在编译时做“静态优化”。
ES6 模块不是对象,而是通过export
命令显式指定输出的代码,再通过import
命令输入。
// ES6模块
import {
stat, exists, readFile } from 'fs';
上面代码的实质是从fs
模块加载 3 个方法,其他方法不加载。这种加载称为“编译时加载”或者静态加载,即 ES6 可以在编译时就完成模块加载,效率要比 CommonJS 模块的加载方式高。
下面我们详细讲解各个模块化方案的具体使用方法和细节:
CommonJS
CommonJS 是以在浏览器环境之外构建 JavaScript 生态系统为目标而产生的项目,比如在服务器和桌面环境中。
Node 应用由模块组成,采用 CommonJS 模块规范。
CommonJS规范规定,每个模块内部,module
变量代表当前模块。这个变量是一个对象,它的exports
属性(即module.exports
)是对外的接口。加载某个模块,其实是加载该模块的module.exports
属性。
var x = 5;
var addX = function (value) {
return value + x;
};
module.exports.x = x;
module.exports.addX = addX;
上面代码通过module.exports
输出变量x
和函数addX
。
require
方法用于加载模块。
var example = require('./example.js');
console.log(example.x); // 5
console.log(example.addX(1)); // 6
也可以为 module.exports
属性分配对象。
在 square.js
中定义了 square
模块:
// 赋值给 exports 不会修改模块,必须使用 module.exports
module.exports = class Square {
constructor(width) {
this.width = width;
}
area() {
return this.width ** 2;
}
};
下面,bar.js
使用了导出 Square 类的 square
模块:
const Square = require('./square.js');
const mySquare = new Square(2);
console.log(`The area of mySquare is ${
mySquare.area()}`);
CommonJS模块的特点如下:
- 所有代码都运行在模块作用域,不会污染全局作用域。
- 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存。
- 模块加载的顺序,按照其在代码中出现的顺序。
1. exports 快捷导出
前面已经讲到了,通过module.exports
可以导出模块,但这样未免有些繁琐。
为了方便,Node为每个模块提供一个exports
变量,指向module.exports
。这等同在每个模块头部,有一行这样的命令。
var exports = module.exports;
它允许一个快捷方式,即 module.exports.f = ...
可以更简洁地写成 exports.f = ...
。
举个例子,以下是 circle.js
的内容:
const {
PI } = Math;
exports.are