我最早接触前端应该是在 2013 年左右,虽然那个时候还在读大二,但已经和同学开始折腾一些校园创业项目。当时希望开发一个面向校园的网上零食商城,我们从批发市场进货然后在校园内网上以低于「小卖部」的价格出售。由于人手不足,写后端的我也同时兼顾了前端的开发工作[1]。
[1] 这种情况在读研甚至工作的时候也在不断发生,以至于现在我都怀疑自己的前端代码量可能快要超过后端代码量了。
虽然当时没有系统性的学习过前端,但得益于诸多开发者在网上分享和开源自己的前端组件,使得我在开发过程中能够随时使用现成组件,从而快速搭建出了系统的第一版。
海量的前端组件库为当时的开发带来了极大的便利,但也带来了一些困扰。这些组件很多时候需要使用 script
命令引入,例如:
<!-- a 组件需要用到的库 -->
<script src="a1.js"></script>
<!-- b 组件需要用到的库 -->
<script src="b1.js"></script>
<script src="b2.js"></script>
<!-- c 组件需要用到的库 -->
<script src="c1.js"></script>
在开发过程中,经常会遇到:
- 引入的
.js
文件越来越多,越来越复杂 - 上述
.js
文件引入顺序被不小心改变导致某个库不可用 - 引入一个新的
.js
文件导致某个库不可用
随着使用的组件越来越多,引入的库越来越复杂,上面的问题反复出现,很多时候需要花很多时间去理清各个组件库的依赖,必要时更新库的版本、调整引入顺序,甚至修改组件内部的冲突代码才能使得页面正常运行。可以说当时我有大量的时间都花费在了这些不必要的调试上,最终导致我的前端开发初体验并不好,甚至可以说糟糕。
直到后面接触了 Node.js
,我才意识到之前在浏览器端遇到的种种问题的根本原因是:模块化的缺失。
模块化历史与演化
函数封装
朴实无华:全局函数写法
上面提及的通过 script
引入的 .js
文件通常都是第三方 UI 组件所需要的模块,这些模块内封装了对应的函数或功能,为的是提高程序的复用性和可维护性。
那么该如何封装一个可被复用的模块?最为原始和简单的方式就是将公用函数放到一个 xxx.js
文件中:
/* utils.js */
function HandleDog() {
// ...
}
function HandleCat() {
// ...
}
使用时通过 script
语句导入皆可:
<script src="utils.js"/>
但是通过 script
语句引入之后,HandleDog
函数 和 HandleCat
函数 以及内部的其他变量实际就成为了全局变量。
全局变量很容易遇到命名冲突问题,即很有可能你引用的另一个模块 utils2.js
内有同名的 HandleDog
函数。同时自己编写代码的时候更要处处当心自己的函数、变量和模块内的函数、变量产生冲突。
小小优化:对象写法
为了在一定程度上解决或改进「全局函数写法」,可以将函数、变量封装成一个对象,如:
var Utils = {
_name: 'utils',
_desc: 'utils for dog and cat',
HandleDog: function() {/* ... */},
HandleCat: function() {/* ... */},
}
因为对 HandleDog
、HandleCat
等变量封装了一层,所以减少了产生冲突的概率。但这依然不能从根本上解决命名冲突这个问题,所以当时有一些模块如 Yahoo! 的 YUI2
进一步引入了「命名空间」的概念,即给模块再加上一个类似「Java 包名」的唯一前缀:
var cn = {};
cn.front = {};
cn.front.Utils = {};
cn.front.Utils._name = 'utils';
cn.front.Utils._desc = 'utils for dog and cat';
cn.front.Utils.HandleDog = function() {/* ... */};
cn.front.Utils.HandleCat = function() {/* ... */};
上述代码确实算是解决了命名冲突的问题,但由于没有 Java 中的 import
关键字实现前缀省略,所以每次调用模块内函数或变量都得写全命名空间:
if (type === 'dog') {
var d_result = cn.front.Utils.HandleDog(dog);
} else {
var c_result = cn.front.Utils.HandleCat(cat);
}
这样的编程体验着实令人痛苦。同时当前的写法依然存在一个重大的缺陷,即变量和函数实际没能实现真正的封装,变量和函数对外都是公开暴露的,我们随时可以访问甚至修改模块内部的属性:
// 修改模块内部变量
cn.front.Utils._name = 'changed';
有点意思:立即执行函数写法 IIFE
立即执行函数(Immediately-Invoked Function Expression,IIFE),能够实现函数声明的同时立即被执行。在 JS 中,一个函数被执行时将创建一个新的上下文,在这个函数内定义的变量处于一个单独的作用域内,只能被函数内部访问,不会暴露到函数外部。这样再结合闭包[2],就能够实现私有变量封装的效果:
function createModule() {
var _name = 'utils';
var _desc = 'utils for dog and cat';
var HandleDog = function() {/* ... */};
var HandleCat = function() {/* ... */};
var GetName = function() {/* ... */};
const ret = {
HandleDog: HandleDog,
HandleCat: HandleCat,
GetName: GetName,
};
return ret;
}
const moduleA = createModule();
// 如果想要访问 _name,只能通过对外开放的 GetName 函数
// console.log(moduleA._name); // 错误,moduleA 无法直接访问内部私有的 _name
[2] 有关闭包可以阅读这篇文章: 一个故事讲闭包
我们需要再进一步,上面的 createModule
实际也是对这个模块的不必要命名,因为我们只是想得到上面函数返回的 ret
结果 而已,因为这个 ret
结果 就是模块本身。既然如此我们希望 createModule
函数是匿名函数且能够立即执行,从而获取到我们需要的模块。那么该如何让这个函数立即执行呢?调用形式是否可以如下呢:
// 错误写法
function createModule() {
/* ... */
}();
调用一个函数的语法,是在函数名的后面添加上 ()
符号,所以可能自然的想到上面的写法。但是上面写法是错误的,因为 JavaScript 引擎在解析时,在行首遇到 funciton
关键字时会将其视「函数声明」,而函数调用语句 funcA()
则是一个「函数表达式」。为了让 JavaScript 引擎知晓后面是一个函数表达式,我们可以在行首加上 (
,也就是用 ()
将函数声明包起来:
// 当然还有其他写法让 JavaScript 引擎知晓后面是一个函数表达式
// 例如行首添加 `+`、`-` 等符号,这里不做扩展
(function createModule() {
/* ... */
})();
同时 createModule
可以是一个匿名函数,最终得到:
var module = (function() {
/* ... */
})();
上述的立即执行函数 IIFE,最终实现了对模块内函数和私有变量的真正封装,我们可以通过 module
调用模块内的公开函数或公开变量,但同时又无法访问任何不该被外部知晓的私有变量。
立即执行函数 IIFE 比起之前的几种封装方案,已经有相当大的进步了,当年的 JQuery
就是大量采用 IIFE 实现模块封装。但模块化并不是一个简单的事情,要考虑的事情是多方面的。IIFE 方案依然面临一个最基本的问题:模块之间的依赖问题。
如果我们想要在一个模块中使用另一个模块,其实可以通过立即执行函数传参的方式实现:
var module = (function($) {
/* ... */
$('.class').css("color","red");
})(jQuery);
// 通过传入 window,将模块挂在 window 下
(function(window, $) {
/* ... */
$('.class').css("color","red");
window.utils = utils;
})(window, jQuery);
如上所示,将一个模块 jQuery
通过参数传入 module
模块,就能在 module
模块内部使用 jQuery
模块了。但是将 jQuery 这个参数传递给 module
的前提是:jQuery 已经被加载初始化。也就是在通过 script
引入模块时,需要将 jQuery 引入写在 module 模块引入之前,此时这个潜在的次序关系必须由程序员手动管理。这也就是上文我提到的现象:「需要经常调整和管理 script 脚本之间的先后顺序」。
随着模块的数量不断增加,模块之前的依赖变得愈发复杂,看来 JavaScript 的模块化[3]依然任重道远。
[3] 因为 JavaScript 的设计发明过于仓促和简化(Brendan Eich 仅用了十天就推出了第一版),所以在设计之处留下了很多的「坑」。模块化就是众多的「坑」之一,这些不完善的模块化方案都是由于 JavaScript 一开始没能提供模块化规范导致的。包括下文将要介绍的各种模块化规范有很多都是「民间」自发推行的模块化规范,实际上这多少增加了 JavaScript 语言的繁杂性。
最后再对立即执行函数 IIFE 做一些补充,由于 ES6 正式引入了模块化规范,所以立即执行函数 IIFE 实现模块化也就没有什么必要了。
同时立即执行函数 IIFE 由于作用域封装的特性,能够达到避免污染全局变量或外部变量的效果,所以在 ES6 之前经常被用来实现「块作用域」。但由于 ES6 引入了 let
、const
关键字实现了「块作用域」,所以也没必要通过立即执行函数 IIFE 来实现「块作用域」了。
模块化规范
前端领域在模块化这件事磕磕碰碰的前行了很久,虽然摸索出了上文提及的一些方案,但每种方案距离真正的模块化都有一定的距离。更重要的是,模块化需要每个人采用同一种方案,也就是模块化最终还是需要定下规范。
CommonJS
概述
时间来到了 2009 年,JavaScript 在浏览器端已经运行了十多年,逐步扎稳了脚跟。此时一个有趣的想法正在不断发酵中:即让 JavaScript 运行在服务器端。当然在具体实现之前,如果能够先统一出一个规范的话,JavaScript 在服务器端的发展可能会更加可期。
于是 2009 年 1 月,Mozilla 的工程师 Kevin Dangoor 发起了制定这个规范的提案,这个规范最初被命名为 ServerJS
。服务器端的 JavaScript 规范自然会涉及到文件系统、IO、HTTP 客户端等方方面面,当然其中最重要的是不会再重蹈浏览器端的覆辙,所以 ServerJS
一开始就引入了模块化规范并命名为 Modules/1.0
。
大概是同年的 2 月份左右,Ryan Dahl 开始开发 JavaScript 服务器端的运行时环境 Node.js
,而在模块化这一方面则采用了 ServerJS
的模块化规范。
2009 年的下半年,由于 ServerJS
规范推动的很不错,同时看到浏览器端一直缺少模块化规范的窘境,所以社区开始期望将已经得到 Node.js
实践的模块化规范推广到浏览器端,社区也改名为 CommonJS。
之后我们也将 CommonJS 社区推行,Node.js
实践的模块化规范称为 CommonJS 规范。
CommonJS 规定了一个文件就是一个模块,每个模块内定义的变量、函数等都是私有的,对其他模块不可见。但可通过 global
来定义对其他文件可见的变量:
/* utils.js */
var _name = 'utils';
var _desc = 'utils for dog and cat';
var HandleDog = function() {/* ... */};
var HandleCat = function() {/* ... */};
var GetName = function() {/* ... */};
global.isGlobal = true;
上述代码定义的 _name
、_desc
等变量或 HandleDog
等函数是 utils.js
模块私有的,其他文件(模块)不可见。但 isGlobal
则可以在其他文件访问[4]。
[4] 但一般情况下,不推荐使用。
模块导出
CommonJS
规范可使用 module.exports
实现模块导出:
/* utils.js */
var _name = 'utils';
var _desc = 'utils for dog and cat';
var HandleDog = function() {/* ... */};
var HandleCat = function() {/* ... */};
var GetName = function() {/* ... */};
module.exports.HandleDog = HandleDog;
module.exports.HandleCat = HandleCat;
module.exports.GetName = GetName;
console.log('模块信息:', module);
每个模块内部都自带了一个变量 module
,表示当前模块,上述代码的 console.log
将输出如下内容:
模块信息: Module {
id: '.', // 模块的识别符
exports: // 本模块导出的值
{ HandleDog: [Function: HandleDog],
HandleCat: [Function: HandleCat],
GetName: [Function: GetName] },
parent: null, // 调用本模块的模块,null 表明此文件为入口文件
filename: '/Users/xxxx/work/git_repository/demo/js-demo/es6_module.js', // 模块的文件名(带绝对路径)
loaded: false, // 模块是否已经被加载
children: [], // 本模块引用的其他模块
paths: // 模块的搜索路径
[ '/Users/xxxx/work/git_repository/demo/js-demo/node_modules',
'/Users/xxxx/work/git_repository/demo/node_modules',
'/Users/xxxx/work/git_repository/node_modules',
'/Users/xxxx/work/node_modules',
'/Users/xxxx/node_modules',
'/Users/node_modules',
'/node_modules' ] }
由上述结果可知 module
对象存储了本文件对应的模块信息,而其中 module.exports
属性更是重点,它表示的实际上就是本模块对外导出了什么。当我们进行所谓的导入加载模块时,实际上就是在获取 module.exports
属性。
同时为了写法的一点点简化,CommonJS
在每个模块内还设置一个属性 exports
指向 module.exports
,相当于在每个模块的头部帮我们执行了以下代码:
var exports = module.exports;
需要注意的是,我们可以在 exports
上挂载各种属性或函数,因为这相当于挂载在 module.exports
下,但不可以直接对 exports
进行赋值,因为这将导致 exports 指向另一个地方,也就没有导出的效果了:
/* utils.js */
var exports = module.exports; // exports 指向 module.exports;
var funcA = function() {/* ... */};
exports.funcA = funcA(); // 有效,导出 {funcA: funcA}
module.exports.funcA = funcA(); // 有效,与上条等价
module.exports = { funcA: funcA }; // 有效,与上面等价
module.exports = funcA(); // 有效,导出函数 funcA()
// 无效,exports 原先指向 module.exports,现在指向 funcA()
// 而 module.exports 内容依然为空,固最终导出的是 {}
exports = funcA();
exports
和 module.exports
混在一起有时候令人迷惑,在实际开发时,也可以全部统一使用 module.exports
。
模块导入
上文介绍了 CommonJS
如何编写模块并导出后,我们来看看 CommonJS
如何导入加载一个模块。
CommonJS
使用 require
来导入加载模块,如下所示:
/* main.js */
// 导入加载模块
var Utils = require('./utils.js');
// 使用模块的函数
Utils.HandleDog();
上述代码中的 require('./utils.js')
将加载并执行 utils.js 文件,然后返回 module.exports
属性。
require
的模块加载还具有以下几个特点:
-
同步加载:
CommonJS
的require
进行的是「同步加载」,依次同步加载模块。 -
缓存机制:
Node.js
在第一次加载时执行模块并对模块进行缓存,之后的加载将直接从缓存中获取。 -
值拷贝与引用拷贝:网上不少中文资料将
require
的输出视为「值拷贝」,但实际上是不严谨的说法。这里需要区分导出的是基本类型还是复合类型,如果导出的是布尔、数字等原始基本类型,那么得到的是「值拷贝」,对这份值拷贝进行修改不会影响到模块内变量。但是如果是对象、数组等引用类型,那么得到的是「引用拷贝」,这份「引用拷贝」和模块内引用变量指向同一块内存区域(即一个闭包空间,Node.js 实际会将每个文件包装在一个函数中实现闭包),所以通过「引用拷贝」改变值将会影响到模块内的变量。
/* utils.js 模块 */
var count = 1;
var moreInfo = {
innerCount: 1,
};
module.exports = {
count: count,
moreInfo: moreInfo
}
/* main.js 使用 utils 模块*/
const Utils = require('./es6_module_utils.js');
let count = Utils.count;
count++;
console.log('导出 count,自增后: ', count);
console.log('模块内 count 不受影响: ', Utils.count);
let moreInfo = Utils.moreInfo;
moreInfo.innerCount++;
console.log('导出 inner_count, 自增后: ', moreInfo.innerCount);
console.log('模块内 inner_count 受影响: ', Utils.moreInfo.innerCount);
AMD
在上一节对 CommonJS
的介绍中提及了 2009 年由于 ServerJS
模块化规范在服务器端推动的较好,于是社区改名为 CommonJS
,期望将服务器端的成功经验推广至浏览器端。
但由于浏览器端和服务器端还是存在许多差异,所以这个推广过程中产生了诸多不同的观点和看法,社区逐步形成了三种流派:
Modules/1.x 流派。
这个流派认为服务器端的Modules/1.0
规范(即上文介绍的CommonJS
规范)已经够用了,直接移植到浏览器端即可。当然还需要添加一个所谓的「转换」规范即Modules/Transport
,用来将服务器端模块翻译为浏览端模块。Modules/2.0 流派。
这个流派则认为浏览器端有自己的特点(例如模块可能通过网络加载等),直接使用Modules/1.0
规范不合适,需要针对浏览器端的特点做更改,但另一方面应该尽可能的接近 Modules/1.0。Modules/Async 流派。
这个流派则是更为激进一些,认为浏览器端和服务器端差异较大,所以需要对浏览器端进行相对独立的模块化规范设计。这个流派的代表就是本节介绍的 AMD 规范。
上述不同流派都有相应的实现,但最终在浏览器端被广泛应用的是 Modules/Async 流派的 AMD 规范,即异步模块定义规范 Asynchronous Module Definition。
再次回看当初各个流派的观点,不难发现其中大家关注的关键点在于浏览器端与服务器端存在差异。
服务器端的模块通常从本地加载,所以同步加载的方式不会影响程序运行的效率和性能。但浏览器的模块通常需要从网络加载,如果一个模块加载完成才能执行后续代码,那么将会影响浏览器端程序的执行效率,甚至直接影响用户的浏览体验。
针对上述差异,AMD
规范提倡采用异步加载的方式加载模块,模块加载时不影响其他代码执行,在加载完成之后,再通过回调函数的方式执行相关逻辑。
模块定义与导出
不同于 CommonJS
的一个文件对应一个模块,AMD
规定了一个全局函数 define
来实现模块定义[5]:
[5]: 需要注意的是,虽然可以将多个模块 define 写在同一个文件里,但 requirejs 官方文档 还是提倡一个文件里只写一个模块 define
define(id?, dependencies?, factory);
其中:
- id:表示定义的模块的唯一标识符,可选。默认为脚本名称。
-
dependencies:数据类型,声明本模块所需要的依赖模块,可选。默认为
["require", "exports", "module"]
- factory:模块初始化需要执行的函数或对象。如果是函数,则会被执行,其中的返回值为模块的输出值。如果是对象,则该对象即为模块的输出值。
AMD
定义一个简单模块如下所示:
// id -> utils 本模块名称(标识符)为 utils
// dependencies -> 本模块依赖 jquery 模块
// factory -> 本模块初始化执行函数,且函数的参数依次为第二个参数声明的依赖的模块
define('utils', ['jquery'], function ($) {
/* ... */
// 本模块输出值(导出值)
return {
HandleDog: function() { /* ... */ }
};
});
注意 dependencies
处模块的定义风格是「前置声明所有模块」,而且不管模块实际执行时是否会用到某个依赖模块,所以模块都会被提前执行。
例如代码实际执行路径中未用到模块,但依然会被加载执行:
// AMD 依赖模块加载的两个特点
// 1. 本模块所依赖的模块都需要一次性「前置声明」
// 2. 且无论是否被使用到,都会被提前执行
define('utils', ['jquery'], function ($) {
/* ... */
// 只有当 flag === xxx 时才会使用到 jquery
// 但无论代码如何执行,AMD 在一开始就已经加载和执行 jquery
if (flag === xxx) {
$('.class').css("color","red");
}
// 本模块输出值(导出值)
return {
HandleDog: function() { /* ... */ }
};
});
AMD
后续补充了一些更为接近 CommonJS
的写法,仿佛能够实现「就近声明」:
/* utils.js */
// 省略模块 id,则 id 默认为脚本文件名称 utils
define(function(require, exports, module) {
var a = require('a'); // 在函数体内通过 require 加载模块
var b = require('b');
//Return the module value
return function () {};
}
);
但是上述代码只是为满足当时开发者对 CommonJS
风格的诉求实现的「CommonJS 伪支持」,其底层依然是 AMD
的「前置声明和执行」。
如果不依赖其他模块,还可简化为如下形式:
/* utils.js */
// 模块 id 也同样是可选,不填则默认为脚本文件名称 utils
define(function () {
/* ... */
// 本模块输出值(导出值)
return {
HandleDog: function() { /* ... */ }
};
});
模块导入
AMD
使用 require
函数实现模块的导入和加载,如下所示:
require(modules?, callback);
其中:
- modules: 数组形式,声明需要使用的模块。
- callback: 所需模块加载完成后的回调函数。
一个简单的模块加载实例如下所示:
require(['utils'], function (Utils) {
/* ... */
Utils.HandleDog();
})
一点补充
AMD
规范的主要践行者是 RequireJS,或者反过来说 RequireJS
是 AMD
规范的主要推动者和制定者。对更多 RequireJS
语法和细节兴趣的读者可以参阅其 官方文档。
虽然 RequireJS
在当时具有很好的推广度,但是由上文的介绍不难看出,RequireJS
和 CommonJS
在模块定义和使用的风格有较大差异,虽然补充了对 CommonJS
风格的支持,但底层加载执行方式依然是「前置执行」[6]。这些差异导致 AMD
不太受 CommonJS
认可,甚至于后期 AMD
流派从 CommonJS
社区独立出去成立了自己的 AMD
社区,由 RequireJS
实际推动。
[6] RequireJS 从 2.0 开始实现了下文即将介绍的 CMD 所一直推崇的「延迟执行」
CMD
CMD(Common Module Definition)规范是 玉伯 在推广自己的前端模块加载库 seajs 的过程中产出的模块化规范。
上面提及了早期 AMD
依赖模块加载的两个关键特点:
-
模块前置声明。
AMD
的依赖模块需要通过参数在模块定义时给出。 - 模块提前执行。不管函数体内执行时是否会用到模块,所有依赖模块都会被提前加载执行。
而 CMD
规范则是主要针对这两点实践了自己的理念:
-
模块就近声明。
CMD
的模块可以在代码实际需要的地方声明导入。 -
模块延迟执行。
CMD
的模块在代码被实际需要的时候才会执行。
模块定义与导出
在 CMD
规范中,规定一个模块对应一个文件,使用 define
函数进行模块定义:
define(factory);
其中:
- factory: 可以为对象或函数。当传入对象是,该模块对外输出就是该对象。当传入函数时,则函数相当于该模块的构造函数,被执行后返回的值即为该模块对外的输出。
CMD
定义一个简单的模块:
// factory 函数默认传入三个参数:require, exports, module
// require:用来加载模块
// exports:导出模块
// module:含有模块基本信息的对象
define(function(require, exports, module) {
// 模块加载
var utils = require('./utils');
var HandleAnimal = function(type) {
if (type === 'dog') {
// 就近声明,延迟执行
var module1 = require('./module1');
module1.handle();
utils.HandleDog();
/* ... */
} else if (type === 'cat') {
// 异步加载可使用:require.async(id, callback?)
var module2 = require.async('./module2', function() {/**/});
module2.handle();
utils.HandleCat();
/* ... */
}
}
exports.HandleAnimal = HandleAnimal; // 模块对外输出
});
CMD
也支持 define(id?, deps?, factory)
的写法:
但这不是 CMD 默认或推崇的写法,只是对 Modules/Transport 规范的支持
// define(id?, deps?, factory)
define('animal', ['utils', 'module1', 'module2'], function(require, exports, module) {
/* ... */
});
模块导入
CMD
使用 use
函数在页面中导入一个或多个模块:
// 加载多个模块,加载完成后执行回调
seajs.use('./utils', './module1', './module2', function(utils, module1, module2) {
utils.HandleDog();
module1.handle();
module2.handle();
});
一点补充
更多有关 CMD
的语法和细节可查阅 官方文档。
UMD 模式
UMD(Universal Module Definition)通用模块定义。网上不少文章将其称为 UMD
规范,实际上 UMD 与其说是一种规范,不如说是一种编程模式,一种为了兼容 CommonJS、AMD、CMD 等规范的编程模式。且实现原理也非常简单,本质就是通过 if-else
判断当前环境支持哪种规范,支持哪种规范就按某种规范做相应的模块封装。按照这种模式封装后,可以让代码运行在不同模块化规范的服务器或浏览器环境中。
UMD
作为编程模式,在不同场景可以有不同的写法:
(function (root, factory) {
if (typeof define === 'function' && define.amd) { // 如果支持 AMD 规范
// 则注册为一个 AMD 模块
define(['b'], function (b) {
return (root.returnExportsGlobal = factory(b));
});
} else if (typeof module === 'object' && module.exports) { // 不支持 AMD 但支持 CommonJS 规范的 Node 环境
// 则注册一个 CommonJS 模块
module.exports = factory(require('b'));
} else {
// 都不支持,则将模块输出注册到浏览器的全局变量
root.returnExportsGlobal = factory(root.b);
}
}(typeof self !== 'undefined' ? self : this, function (b) {
/* 模块构造函数本体 */
/* 使用 b */
// 模块输出值
return {/* ... */};
}));
更多的 UMD
写法可以查阅 umdjs,其中收集和维护了一些常用的 UMD
写法。
ESM
从 CommonJS
到 AMD
、CMD
甚至于 UMD
,前端模块化规范可谓「百家争鸣」。但天下大势分久必合,群雄并起的时代终究是要落下帷幕。无论是上面哪种模块化规范都只是各种前端社区推行的「民间方案」,做的再好也只是割据一方罢了。最终一统天下的还是 JavaScript 的官方标准,即 ECMA
标准。
2015 年 ECMA
的 39 号技术委员会(Technical Committee 39,简称 TC39) 终于推出 ECMAScript 2015 也就是耳熟能详的 ES6 规范。其中就包含了 JavaScript 模块化标准 ECMAScript Module,也被简称为 ESM。
模块定义与导出
在 ESM
中,一个模块对应一个文件,所以直接在文件中定义模块。同时使用 export
实现模块导出:
/* utils.js */
export const _name = 'utils';
export const _desc = 'utils for dog and cat';
export function HandleDog() {/*...*/};
export function HandleCat() {/*...*/};
或者将导出值统一写在一处:
// ......
// 导出非 default 变量时必须加括号,以下语法是错误的
// 错误用法:export _name;
// 错误用法:export 123;
export { _name, _desc, HandleDog, HandleCat };
模块导出的同时,可以对导出的内容进行重命名:
// ......
export {
_name as name,
_name as another_name,
_desc as desc,
HandleDog as handleDog,
HandleCat as handleCat,
};
ESM
中还规定了一个「默认导出」:
const HandleDefault = function() {/* ... */};
// export default 无需添加大括号 {}
// 相当于将 HandleDefault 赋值给 default 变量
export default HandleDefault;
对于模「非默认输出值」,我们需要使用如下方式导入:
// 想要导入的是模块导出的 name
// name 必须和 export 导出的名称对应
import { name } from '模块'; // name 就是 export 时起的名字
而对于模块的「默认输出值」,我们无需指定名称即可获得:
// 从模块获取值,由于没有指定名称,所以获取的是默认输出
// xxx 不与模块导出值的名称对应,xxx 是给默认输出起的任意名称
import xxx from '模块';
模块导入
ESM
使用 import
命令实现导入加载模块:
import { name, another_name, handleDog, handleCat } from './utils.js';
// 如上所示,使用 import 导入时,括号内的变量名需要和 utils 模块导出的变量名相同
// 当然也可以在导入的同时使用 as 起一个新名称
import { name as old_name, another_name as new_name } from './utils.js';
也可以不按名称一一导入,使用 *
符号将模块导出的所有变量作为一个整体一次性导入到一个对象:
import * as Utils from './utils.js';
console.log(Utils.name);
console.log(Utils.another_name);
Utils.handleDog();
对于「默认输出」,其对应的变量名为 default
,所以在导入时无需指定特定名称,还可以直接为其自定义一个任意名称:
// 模块内导出的 HandleDefault 已经被赋值为默认 default,所以导出的是 default 变量名
// 获取模块的默认 default 时无需通过模块内的定义特定名称获取
// 直接给这个默认 default 自定义一个任意名称即可,例如 HandleOther
import HandleOther from './utils.js';
一点补充
ESM
规范中的 import
具有一些特性,如导入的变量是只读的、import
具有提升效果、比起 CommonJS
、AMD
等 import 为编译期执行(语句分析阶段) 等。同时 ESM
中还有 import
和 export
混合使用等语法,关于这些更多内容可以查阅 ES6~ES11 特性介绍之 ES6 篇 中的第 4 节「模块 Module」。
小结
在那个 JavaScript 还在野蛮生长的「远古」时代,全局函数封装、对象封装以及立即函数执行 IIFE 这些模块化的「不完全体」陪伴着无数开发者度过了一段漫长的静默期。但随着 JavaScript 的快速成长,无数 JavaScript 开发人员在日益庞大和复杂的系统中磕磕碰碰,艰难前行。
时间来到 2009,CommonJS
社区建立,Node.js
更是横空出世,从此开启了 CommonJS
、AMD
、CMD
、UMD
百家争鸣的时代,这一晃就是 6 个春秋,最终这一切随着 2015 年 ES 官方规范的推出而落下帷幕。
原本 ESM
规范推出后,各个浏览器对此实现支持需要一定的开发时间。然而时代浪潮滚滚向前,这次不再给我们留下回味 AMD
、CMD
等规范的时间。
2014 年,babel 诞生。这款能够将 「ECMAScript 2015+ 语法代码」编译成「运行在旧平台的旧语法代码」的工具使得开发人员能够使用还未被浏览器支持的最新语法进行开发。甚至于将前端带入了一个新的阶段,一个开发阶段代码与生产阶段代码不同的阶段,即预编译阶段。
同时随着 webpack
的发明,前端领域后续又进入全新的自动化构建阶段。这个过程中当然还有 broswerify
、webpack
、grunt
、gulp
等工具的发展和兴衰,这又是另一个「前端故事」了。
如同 CommonJS
、AMD
、CMD
、UMD
一样,前端领域每年都有新的技术不断产生、更有新生力量不断注入,也有旧的技术被淘汰,而这故事仍然在不断上演。技术更替会不断的被后人整理记录,但绝大多数的程序员恐怕只会被淹没在历史的浪潮中了吧。
参考资料
前端模块化开发那点历史
前端模块化的前世
前端模块化的十年征程
RequireJS 官方文档
CMD 模块定义规范
JavaScript for impatient programmers - 24 Modules
汪
汪