在前端领域提及模块化时,人们首先联想到的无疑是ES6 Module。作为ECMAScript颁布的标准模块化方案,ES6 Module因其权威性在前端模块化方案领域占据了举足轻重的地位。那么,为什么模块化对我们来说是必要的?为何在JavaScript诞生之初并未设计出模块化方案,而是直到ES6标准时才推出了ES6 Module呢?带着这些疑问,我们将回顾前端模块化方案的历史演变,并简要探讨其应用场景。
在浏览器早期发展阶段,其功能相对单一,主要集中在“图文展示”。因此,当时的浏览器并不需要复杂的模块化设计。可以说,那时还没有真正意义上的前端和模块化概念,只有基本的脚本语言JavaScript和HTML。仅凭这两者,就能构建出一个功能完善、能够展示图文的浏览器界面。
早期的HTML页面并没有CSS,而CSS的出现正是为了解决HTML在样式处理上的不足。使用HTML同时处理结构和样式既不优雅,也容易导致代码冗余和混乱。因此,能够将结构和样式分离的CSS(层叠样式表)应运而生,并在前端开发中发挥着至关重要的作用。
稍微跑题了,接着回到模块化的讨论。当我们的页面仅包含简单的图文展示,且规模较小时,可以通过这种方式编写代码:
function a(){};
function b(){};
即便情况变得极端,我们仍可以使用全局搜索,或者干脆等待页面报错后再逐一纠正命名错误。然而,随着互联网的迅猛发展,项目规模日益庞大,单个页面往往需要引入众多JavaScript文件,命名冲突也层出不穷。在这种情况下,显然没有人愿意逐个文件去检查潜在的命名冲突。那么,我们该如何应对呢?
可以用命名空间来解决这个问题:
var zaking1 = {
a:function(){},
b:function(){}
}
var zaking2 = {
a:function(){},
b:function(){}
}
然而,这样的做法似乎也不够理想,因为它只是形式上的隔离,而非真正意义上的隔离。随着项目体积不断增大,调用可能会变得冗长,例如:
zaking1.zaking2.zaking3.getProduct.xxx.xxx
即使设置了命名空间1和命名空间2,它们之间的方法仍能相互调用,导致混乱程度不言而喻。既然简单的隔离措施难以奏效,为何不利用闭包的特性来真正隔离各个命名空间?通过这种方式,我们可以精确控制哪些内容对外可见,从而实现更为清晰有序的结构。
var module = (function () {
var name = "zaking";
function getName() {
console.log(name);
}
return { getName };
})();
module.getName();
至此,我们成功解决了隔离性问题,通过闭包创建了独立的作用域,确保外部只能通过我们提供的接口与模块进行交互。然而,新的挑战随之而来:如果我们想向这个模块传递一些数据或信息,该如何处理呢?由于模块的封闭性,它无法获取外界的任何信息,而这显然不是我们所期望的结果。
var module = (function (neighbor) {
var name = "zaking";
function getName() {
console.log(name + "和邻居:" + neighbor);
}
return { getName };
})("xiaowang");
module.getName();
我们可以通过向函数传递参数的方式来解决向模块传递数据的问题。听起来很熟悉,对吧?没错,这正是早期版本的jQuery外层架构所采用的策略。
这种设计构成了早期模块化的高级方案,在jQuery中的应用尤为突出,甚至在当今许多项目中仍被广泛使用。
随着社会的发展,模块化规则的出现已成为必然。于是,CommonJS举起了模块化的大旗,引领JavaScript迈向新的发展阶段。CommonJS的诞生源于社区的努力,最初是由JavaScript社区的Mozilla工程师Kevin Dangoor在Google Groups创建了SeverJS小组。该小组的目标是为Web服务器、桌面、命令行应用程序以及浏览器构建一个生态系统。鉴于其宏大的愿景和目标,SeverJS后来更名为CommonJS,因为SeverJS这个名称看起来与浏览器无关。
同年年底,Node.js横空出世。此时,JavaScript的应用范围已突破浏览器的限制,开始在服务器端展现出强大实力。Node.js的初衷是基于CommonJS社区来实现模块化规范,但它并未完全照搬CommonJS的所有内容,而是进行了创新和发展,去除了其中一些过时且不合时宜的元素,最终取得了超越前人的成就。
// a.js
module.exports = 'zaking'
// b.js
const a = require("./a");
console.log(a); // zaking
在Node.js环境中,每个JS文件都被视为一个独立的模块。每个模块都有自己的作用域,这意味着在一个JS文件中定义的函数和对象都是私有的,无法被其他JS文件访问。此外,Node.js在首次加载某个模块时,会将该模块缓存起来。当再次加载同一模块时,系统会直接从缓存中取出它的module.exports属性并返回,从而避免了重复加载。例如:
// a.js
var name = "zaking";
exports.name = name;
// b.js
var a = require("./a.js");
console.log(a.name); // zaking
a.name = "xiaoba";
var b = require("./a.js");
console.log(b.name); // xiaoba
这里第二次打印的b.name显示为更改后的name,这正是前面提到的缓存机制。当模块第一次被引入时,Node.js会执行模块内的代码并缓存结果。在后续引入中,Node.js不会重新执行模块代码,而是直接返回之前缓存的module.exports对象。
在前面的示例代码中,虽然没有直接使用module.exports,但使用了exports。为了简化模块的使用,Node.js为每个模块创建了一个私有变量exports,它指向module.exports。
需要注意的是,exports是模块内部的私有变量,仅仅是对module.exports的引用。因此,直接修改exports的值并不会影响module.exports,也就是说,这样的操作是无效的。
exports = "aaa"
这样的操作只是让exports不再指向module.exports,但不会改变module.exports的内容。
此外,还需要了解一个关键点:当我们导入一个模块时,实际上获得的是导出值的副本。这意味着,一旦模块内部的值被导出,后续对该值的任何更改都不会影响已导出的副本。
// a.js
var name = "zaking";
function changeName() {
name = "xiaowang";
}
exports.name = name;
exports.changeName = changeName;
// b.js
var a = require("./a.js");
console.log(a.name); // zaking
a.changeName();
console.log(a.name); // zaking
AMD和CMD是前端历史上两种颇具影响力的模块化方案,它们从不同的角度出发,为浏览器端的模块化做出了杰出的贡献。可以说,它们共同书写了前端模块化历史中不可磨灭的一页。
AMD(Asynchronous Module Definition,异步模块化定义)的主要目标是解决浏览器端缺乏模块化方案的问题,从而鼓励开发者在浏览器页面中采用模块化开发方式。
需要强调的是,AMD是一种模块化方案,而非具体的实现。RequireJS正是这一方案的具体实现者。RequireJS的视野并不局限于浏览器环境,而是希望能够在任何使用JavaScript语言的宿主环境中应用这一模块化方案。
其中,a.js文件是这样的:
这个机制简单易懂:define用于声明一个模块,require用于导入一个模块。在使用require导入模块时,还可以传入一个回调函数作为参数,以便在所有模块都导入后执行:
require(["./a"], function () {
alert("load finished");
});
最后,我们来看一个稍微复杂一点的例子:
打开页面后,可以看到如图1-1所示的结果。
图1-1 AMD使用示例效果
可以看到,这里只执行了fun1。我们审查一下元素,如图1-2所示。
图1-2 AMD模块化示例审查元素
虽然我们仅使用了a.js中的内容,但RequireJS并不关心你是否使用,它会直接将所有内容都导入进来。
与AMD规范的RequireJS相比,使用体验似乎不太理想。有时,它会导入一些我们并未使用的模块,让人感觉有些冗余。此外,RequireJS的野心很大,希望成为所有使用JavaScript环境的模块化方案,但这种贪心的策略往往导致在实际应用中难以满足所有需求。
在这样的背景下,基于CMD(Common Module Definition,通用模块化定义)规范的SeaJS应运而生。与RequireJS不同,SeaJS专注于为浏览器端提供模块化解决方案。
它的大致用法如下:
然后JS文件是这样的:
从示例代码中可以看到,SeaJS通过define函数封装整个模块,并在模块内部通过require和exports参数作为模块导入和导出的接口。这种设计使得SeaJS在使用上的负担接近于CommonJS。因此,开发者可以像在Node.js中使用CommonJS一样,无须花费太多时间学习,就能轻松地将SeaJS的基本用法应用于浏览器环境。这也是SeaJS的设计初衷。
在前面的内容中,我们详细探讨了3种由社区提供的模块化方案,它们在前端模块化历史进程中都扮演了至关重要的角色,可以说是一个群雄逐鹿的时代。
然而,正如了解中国历史的人所知,当群雄纷争、格局混乱时,往往也预示着统一的曙光即将到来。在这样的背景下,ECMAScript在语言层面提出了模块化的规范,即ES6 Module。
ES6 Module的问世标志着前端模块化方案的混乱时代即将结束,统一的时代即将来临,同时Node.js也在不断加强对它的支持。可以预见,在未来,ES6 Module将成为前端领域唯一的模块化方案。因此,读者一定要加强对ES6 Module的学习,这将是进入前端领域必备的技能。