11月中旬在伦敦举行的jQuery Summit顶级大会上有个session讲的是大型JavaScript应用程序架构,看完PPT以后觉得甚是不错,于是整理一下发给大家共勉。
PDF版的PPT下载地址:http://www.slideshare.net/jibyjohnc/jqquerysummit-largescale-javascript-application-architecture
注:在整理的过程中,发现作者有些思想是返来复去地说,所以删减了一部分,如果你的英文良好,请直接阅读英文的PPT。
以下是本文的主要章节:
1. 什么叫“JavaScript大型程序”?
2. 顾当前的程序架构
3. 长远考虑
4. 头脑风暴
5. 建议的架构
5.1 设计模式
5.1.1 模块论
5.1.1.1 综述
5.1.1.2 Module模式
5.1.1.3 对象自面量
5.1.1.4 CommonJS模块
5.1.2 Facade模式
5.1.3 Mediator模式
5.2 应用到你的架构
5.2.1 Facade - 核心抽象
5.2.2 Mediator - 程序核心
5.2.3 紧密联合运作起来
6. 发布Pub/订阅Sub的延伸:自动注册事件
7. Q & A
8. 致谢
在我们开始之前,我们来定义一下什么叫大型JavaScript站点,很多有经验的JS开发高手也都被challenge住了,有人说超过10万行JavaScript代码才算大型,也有人说JavaScript代码要超过1MB大小才算,其实2者都不能算对,因为不能安装代码量的多少来衡量,很多琐碎的JS代码很容易超过10万行的。
我对“大”的定义如下,虽然可能不太对,但是应该是比较接近了:
我个人认为,大型JavaScript程序应该是非常重要并且融入了很多卓越开发人员努力,对重量级数据进行处理并且展示给浏览器的程序。
我不能强调说这个问题有多重要,很多有经验的开发人员经常说:“现有的创意和设计模式在我上一个中型项目上运行得非常好,所以在稍微大型点的程序里再次使用,应该没问题,对吧?”,在一定程序上是没错的,但别忘记了,既然是大型程序,通常就应该有大的Concerns需要分解关注,我简短解释一下要花时间来review当前运行了很久的程序架构。大多数情况下,当前的JavaScript程序架构应该是如下这个样子的(注意,是JS架构,不是大家常说的ASP.NET MVC):
custom widgets
models
views
controllers
templates
libraries/toolkits
an application core.
你可能还会将程序单独封装成多个modules,或者使用其他的设计模式,这很好,但是如果这些结构完全代表你的架构的话,就可能会有一些潜在的问题,我们来看看几个重要的点:
1.你架构里的东西,有多少可以立即拿出来重用?
有没有一些单独的module不依赖别的代码?是自包含么?如果我到你们正在使用的代码库上去随即挑选一些模块module代码,然后放在一个新页面,是否能立即就能使用?你可能会说原理通就可以了,我建议你长久打算一下,如果你的公司之前开发很多重要的程序,突然有一天有人说,这个项目里的聊天模块不错,我们拿出来放在另外一个项目里吧,你能直接拿过来不修改代码就能使用么?
2.系统里有多少模块module需要依赖其他模块?
系统的各个模块是不是都很紧耦合?在我将这个问题作为concern之前,我先解释一下,不是说所有的模块都绝对不能有任何依赖,比如一个细粒度的功能可能是从base功能扩展来的,我的问题和这种情况不一样,我说的是不同功能模块之前的依赖,理论上,所有的不同功能模块都不应该有太多的依赖。
3.如果你程序的某一部分出错了,其他部分是否能够依然工作?
如果你构建一个和Gmail差不多的程序,你可以发现Gmail里很多模块都是动态加载的,比如聊天chat模块,在初始化页面的时候是不加载的,而且就算加载以后出错了,页面的其他部分也能正常使用。
4.你的各个模块Module能很简单的进行测试么?
你的每一个模块都有可能用在数百万用户的大型站点上,甚至多个站点都使用它,所以你的模块需要能经得住测试,也就是说,不管是在架构内部还是架构外部,都应该能很简单的去测试,包括大部分的断言在不同的环境下都能够通过。
架构大型程序的时候,最重要的是要有前瞻性,不能只考虑一个月或者一年以后的情况,要考虑更长久的情况下,有什么改变的可能性?开发人员经常将DOM操作的代码和程序绑定得太紧,尽管有时候已经封装单独的逻辑到不同的模块里了,想想一下,长久以后,为什么不是很好。
我的一个同事曾经说过,一个精确的架构可能不适合未来的情景,有时候是正确的,但是当你需要该做的话,你所付出的money那可是相当地多哦。比如,你可能因为某些性能,安全,设计的原因需要在Dojo, jQuery, Zepto, YUI之间需要选择替换,这时候就有问题了,大部分模块都有依赖,需要钱呀,需要时间啊,需要人呀,对不?
对于一些小型站点没事,但是大型站点确实需要提供一个更加灵活的机制,而不去担心各个模块之间的各种问题,这既然节约钱,又能节约时间。
总结一下,现在你能确定你能不重写整个程序就能替换一些类库么?如果不能,那估计我们下面要讲的内容,就比较适合你了。
很多有经验的JavaScript开发者给出了一些关键的notes:
JavaScriptMVC的作者Justin Meyer说:
构建大型程序最大的秘密就是从来不构建大型程序,而是将程序分解成各个小的模块去做,让每个小模块都可测试,可size化,然后集成到程序里。
High-performance JavaScript websites作者Nicholas,Zakas:
"The key is to acknowledge from the start that you have no idea how this will grow. When you accept that you don't know everything, you begin to design the system defensively. You identify the key areas that may change, which often is very easy when you put a little bit of time into it. For instance, you should expect that any part of the app that communicates with another system will likely change, so you need to abstract that away." -
一大堆文字问题,太麻烦了,总结一句就是,一切皆可变,所以要抽象。
jQuery Fundamentals作者Rebecca Murphey:
各个模块之间联系的越密切,重用性越小,改变起来困难越大。
以上这些重要观点,是构建架构的核心要素,我们需要时刻铭记。
我们来头脑风暴一下,我们需要一个松耦合的架构,各模块之间没有依赖,各个模块和程序进行通信,然后中间层接管和处理反馈相应的消息。
例如,我们如果有一个JavaScript构建在线面包店程序,一个模块发出了一个信息可能是“有42个圆面包需要派件”。我们使用不同的layer层来处理模块发来的消息,做到如下:
这将防止我们因为某个模块出错,而导致所有的模块出错。
另外一个问题是安全,真实的情况是,大多数人都不认为内部安全是个问题,我们自己心里说,程序是我自己构建的,我知道哪些是公开的那些私有的,安全没问题,但你有没有办法去定义哪个模块才能权限访问程序核心?例如,有一个chat聊天模块,我不想让他调用admin模块,或者不想让它调用有DB写权限的模块,因为这之间存在很脆弱,很容易导致XSS攻击。每个模块不应该能做所有的事情,但是当前大多数架构里的JavaScript代码都有这种的问题。提供一个中间层来控制,哪个模块可以访问那个授权的部分,也就是说,该模块最多只能做到我们所授权的那部分。
我们本文的重点来了,这次我们提议的架构使用了我们都很熟知的设计模式:module, facade和mediator。
和传统的模型不一样的是,为了解耦各个模块,我们只让模块发布一些event事件,mediator模式可以负责从这些模块上订阅消息message,然后控制通知的response,facade模式用户限制各模块的权限。
以下是我们要注意讲解的部分:
1 设计模式
1.1 模块论
1.1.1 综述
1.1.2 Module模式
1.1.3 对象自面量
1.1.4 CommonJS模块
1.2 Facade模式
1.3 Mediator模式
2 应用到你的架构
2.1 Facade - 核心抽象
2.2 Mediator - 程序核心
2.3 紧密联合运作起来
大家可能都或多或少地使用了模块化的代码,模块是一个完整的强健程序架构的一部分,每个模块都是为了单独的目的为创建的,回到Gmail,我们来个例子,chat聊天模块看起来是个单独的一部分,其实它是有很多单独的子模块来构成,例如里面的表情模块其实就是单独的子模块,也被用到了发送邮件的窗口上。
另外一个是模块可以动态加载,删除和替换。
在JavaScript里,我们又几种方式来实现模块,大家熟知的是module模式和对象字面量,如果你已经熟悉这些,请忽略此小节,直接跳到CommonJS部分。
Module模式
module模式是一个比较流行的设计模式,它可以通过大括号封装私有的变量,方法,状态的,通过包装这些内容,一般全局的对象不能直接访问,在这个设计模式里,只返回一个API,其它的内容全部被封装成私有的了。
另外,这个模式和自执行的函数表达式比较相似,唯一的不同是module返回的是对象,而自执行函数表达式返回的是function。
众所周知, JavaScript不想其它语言一样有访问修饰符,不能为每个字段或者方法声明private,public修饰符,那这个模式我们是如何实现的呢?那就是return一个对象,里面包括一些公开的方法,这些方法有能力去调用内部的对象。
看一下,下面的代码,这段代码是一个自执行代码,声明里包括了一个全局的对象basketModule, basket数组是一个私有的,所以你的整个程序是不能访问这个私有数组的,同时我们return了一个对象,其内包含了3个方法(例如addItem,getItemCount,getTotal),这3个方法可以访问私有的basket数组。
var basketModule = (function() {
var basket = []; //private
return { //exposed to public
addItem: function(values) {
basket.push(values);
},
getItemCount: function() {
return basket.length;
},
getTotal: function(){
var q = this.getItemCount(),p=0;
while(q--){
p+= basket[q].price;
}
return p;
}
}
}());
同时注意,我们return的对象直接赋值给了basketModule,所以我们可以像下面一样使用:
//basketModule is an object with properties which can also be methods
basketModule.addItem({item:'bread',price:0.5});
basketModule.addItem({item:'butter',price:0.3});
console.log(basketModule.getItemCount());
console.log(basketModule.getTotal());
//however, the following will not work:
console.log(basketModule.basket);// (undefined as not inside the returned object)
console.log(basket); //(only exists within the scope of the closure)
那在各个流行的类库(如Dojo, jQuery)里是如何来做呢?
Dojo
Dojo试图使用dojo.declare来提供class风格的声明方式,我们可以利用它来实现Module模式,例如如果你想再store命名空间下声明basket对象,那么可以这么做:
//traditional way
var store = window.store || {};
store.basket = store.basket || {};
//using dojo.setObject
dojo.setObject("store.basket.object", (function() {
var basket = [];
function privateMethod() {
console.log(basket);
}
return {
publicMethod: function(){
privateMethod();
}
};
}()));
结合dojo.provide一起来使用,非常强大。
YUI
下面的代码是YUI原始的实现方式:
YAHOO.store.basket = function () {
//"private" variables:
var myPrivateVar = "I can be accessed only within YAHOO.store.basket .";
//"private" method:
var myPrivateMethod = function () {
YAHOO.log("I can be accessed only from within YAHOO.store.basket");
}
return {
myPublicProperty: "I'm a public property.",
myPublicMethod: function () {
YAHOO.log("I'm a public method.");
//Within basket, I can access "private" vars and methods:
YAHOO.log(myPrivateVar);
YAHOO.log(myPrivateMethod());
//The native scope of myPublicMethod is store so we can
//access public members using "this":
YAHOO.log(this.myPublicProperty);
}
};
} ();
jQuery
jQuery里有很多Module模式的实现,我们来看一个不同的例子,一个library函数声明了一个新的library,然后创建该library的时候,在document.ready里自动执行init方法。
function library(module) {
$(function() {
if (module.init) {
module.init();
}
});
return module;
}
var myLibrary = library(function() {
return {
init: function() {
/*implementation*/
}
};
}());
对象自面量
对象自面量使用大括号声明,并且使用的时候不需要使用new关键字,如果对一个模块里的属性字段的publice/private不是很在意的话,可以使用这种方式,不过请注意这种方式和JSON的不同。对象自面量:var item={name: "tom", value:123} JSON:var item={"name":"tom", "value":123}。
var myModule = {
myProperty: 'someValue',
//object literals can contain properties and methods.
//here, another object is defined for configuration
//purposes:
myConfig: {
useCaching: true,
language: 'en'
},
//a very basic method
myMethod: function () {
console.log('I can haz functionality?');
},
//output a value based on current configuration
myMethod2: function () {
console.log('Caching is:' + (this.myConfig.useCaching) ? 'enabled' : 'disabled');
},
//override the current configuration
myMethod3: function (newConfig) {
if (typeof newConfig == 'object') {
this.myConfig = newConfig;
console.log(this.myConfig.language);
}
}
};
myModule.myMethod(); //I can haz functionality
myModule.myMethod2(); //outputs enabled
myModule.myMethod3({ language: 'fr', useCaching: false }); //fr
CommonJS
关于 CommonJS的介绍,这里就不多说了,博客园有很多帖子都有介绍,我们这里要提一下的是CommonJS标准里里有2个重要的参数exports和require,exports是代表要加载的模块,require是代表这些加载的模块需要依赖其它的模块,也需要将它加载进来。
/*
Example of achieving compatibility with AMD and standard CommonJS by putting boilerplate around the standard CommonJS module format:
*/
(function(define){
define(function(require,exports){
// module contents
var dep1 = require("dep1");
exports.someExportedFunction = function(){...};
//...
});
})(typeof define=="function"?define:function(factory){factory(require,exports)});
有很多CommonJS标准的模块加载实现,我比较喜欢的是RequireJS,它能否非常好的加载模块以及相关的依赖模块,来一个简单的例子,例如需要将图片转化成ASCII码,我们先加载encoder模块,然后获取他的encodeToASCII方法,理论上代码应该是如下:
var encodeToASCII = require("encoder").encodeToASCII;
exports.encodeSomeSource = function(){
//其它操作以后,然后调用encodeToASCII
}
但是上述代码并没用工作,因为encodeToASCII函数并没用附加到window对象上,所以不能使用,改进以后的代码需要这样才行:
define(function(require, exports, module) {
var encodeToASCII = require("encoder").encodeToASCII;
exports.encodeSomeSource = function(){
//process then call encodeToASCII
}
});
CommonJS 潜力很大,但是由于大叔不太熟,所以就不过多地介绍了。
Facade模式在本文架构里占有重要角色,关于这个模式很多JavaScript类库或者框架里都有体现,其中最大的作用,就是包括High level的API,以此来隐藏具体的实现,这就是说,我们只暴露接口,内部的实现我们可以自己做主,也意味着内部实现的代码可以很容易的修改和更新,比如今天你是用jQuery来实现的,明天又想换YUI了,这就非常方便了。
下面这个例子了,可以看到我们提供了很多私有的方法,然后通过暴露一个简单的 API来让外界执行调用内部的方法:
var module = (function () {
var _private = {
i: 5,
get: function () {
console.log('current value:' + this.i);
},
set: function (val) {
this.i = val;
},
run: function () {
console.log('running');
},
jump: function () {
console.log('jumping');
}
};
return {
facade: function (args) {
_private.set(args.val);
_private.get();
if (args.run) {
_private.run();
}
}
}
} ());
module.facade({run:true, val:10});
//outputs current value: 10, running
Facade和下面我们所说的mediator的区别是,facade只提供现有存在的功能,而mediator可以增加新功能。
讲modiator之前,我们先来举个例子,机场飞行控制系统,也就是传说中的塔台,具有绝对的权利,他可以控制任何一架飞机的起飞和降落时间以及地方,而飞机和飞机之前不允许通信,也就是说塔台是机场的核心,mediator就相当于这个塔台。
mediator就是用在程序里有多个模块,而你又不想让各个模块有依赖的话,那通过mediator模式可以达到集中控制的目的。实际场景中也是,mediator封装了很多不想干的模块,让他们通过mediator联系在一起,同时也松耦合他们,使得他们之间必须通过mediator才能通信。
那mediator模式的优点是什么?那就是解耦,如果你之前对观察者模式比较了解的话,那理解下面的mediator图就相对简单多了,下图是一个high level的mediator模式图:
想想一下,各模块是发布者,mediator既是发布者又是订阅者。
可以看到,各模块之间并没有通信,另外Mediator也可以实现监控各模块状态的功能,例如如果Module 3出错了,Mediator可以暂时只想其它模块,然后重启Module 3,然后继续执行。
回顾一下,可以看到,Mediator的优点是:松耦合的模块由同一的Mediator来控制,模块只需要广播和监听事件就可以了,而模块之间不需要直接联系,另外,一次信息的处理可以使用多个模块,也方便我们以后统一的添加新的模块到现有的控制逻辑里。
确定是:由于所有的模块直接都不能直接通信,所有相对来说,性能方面可能会有少许下降,但是我认为这是值得的。
我们根据上面的讲解来一个简单的Demo:
var mediator = (function(){
var subscribe = function(channel, fn){
if (!mediator.channels[channel]) mediator.channels[channel] = [];
mediator.channels[channel].push({ context: this, callback: fn });
return this;
},
publish = function(channel){
if (!mediator.channels[channel]) return false;
var args = Array.prototype.slice.call(arguments, 1);
for (var i = 0, l = mediator.channels[channel].length; i < l; i++) {
var subscription = mediator.channels[channel][i];
subscription.callback.apply(subscription.context, args);
}
return this;
};
return {
channels: {},
publish: publish,
subscribe: subscribe,
installTo: function(obj){
obj.subscribe = subscribe;
obj.publish = publish;
}
};
}());
然后有2个模块分别调用:
//Pub/sub on a centralized mediator
mediator.name = "tim";
mediator.subscribe('nameChange', function(arg){
console.log(this.name);
this.name = arg;
console.log(this.name);
});
mediator.publish('nameChange', 'david'); //tim, david
//Pub/sub via third party mediator
var obj = { name: 'sam' };
mediator.installTo(obj);
obj.subscribe('nameChange', function(arg){
console.log(this.name);
this.name = arg;
console.log(this.name);
});
obj.publish('nameChange', 'john'); //sam, john
一个facade是作为应用程序核心的一个抽象来工作的,在mediator和模块之间负责通信,各个模块只能通过这个facade来和程序核心进行通信。作为抽象的职责是确保任何时候都能为这些模块提供一个始终如一的接口(consistent interface),和sendbox controller的角色比较类似。所有的模块组件通过它和mediator通信,所以facade需要是可靠的,可信赖的,同时作为为模块提供接口的功能,facade还需要扮演另外一个角色,那就是安全控制,也就是决定程序的哪个部分可以被一个模块访问,模块组件只能调用他们自己的方法,并且不能访问任何未授权的内容。例如,一个模块可能广播dataValidationCompletedWriteToDB,这里的安全检查需要确保该模块拥有数据库的写权限。
总之,mediator只有在facade授权检测以后才能进行信息处理。
Mediator是作为应用程序核心的角色来工作的,我们简单地来说一下他的职责。最核心的工作就是管理模块的生命周期(lifecycle),当这个核心扑捉到任何信息进来的时候,他需要判断程序如何来处理——也就是说决定启动或停止哪一个或者一些模块。当一个模块开始启动的时候,它应该能否自动执行,而不需要应用程序核心来决定是否该执行(比如,是否要在DOM ready的时候才能执行),所以说需要模块自身需要去判定。
你可能还有问题,就是一个模块在什么情况下才会停止。当程序探测到一个模块失败了,或者是出错了,程序需要做决定来防止继续执行该模块里的方法,以便这个组件可以重新启动,目的主要是提高用户体验。
另外,该核心应该可以动态添加或者删除模块,而不影响其他任何功能。常见的例子是,一个模块在页面加载初期是不可用,但是用户操作以后,需要动态加载这个模块然后执行,就像Gmail里的chat聊天功能一样,从性能优化的目的来看,应该是很好理解的吧。
异常错误处理,也是由应用程序核心来处理的,另外各模块在广播信息的时候,也广播任何错误到该核心里,以便程序核心可以根据情况去停止/重启这些模块。这也是松耦合架构一个很重要的部分,我们不需要手工改变任何模块,通过mediator使用发布/订阅就可以来做到这个。
各模块包含了程序里各种各样的功能,他们有信息需要处理的时候,发布信息通知程序(这是他们的主要职责),下面的QA小节里提到了,模块可以依赖一些DOM工具操作方法,但是不应该和系统的其它模块有依赖,一个模块不应该关注如下内容:
Facade抽象应用程序的核心,避免各个模块之间直接通信,它从各模块上订阅信息,也负责授权检测,确保每个模块有用自己单独的授权。
Mediator(应用程序核心)使用mediator模式扮演发布/订阅管理器的角色,负责模块管理以及启动/停止模块执行,可以动态加载以及重启有错误的模块。
这个架构的结果是:各模块之间没有依赖,因为松耦合的应用,它们可以很容易地被测试和维护,各模块可以很容易地在其它项目里被重用,也可以在不影响程序的情况下动态添加和删除。
关于自动注册事件,需要遵守一定的命名规范,比如如果一个模块发布了一个名字为messageUpdate的事件,那么所有带有messageUpdate方法的模块都会被自动执行。有好处也有利弊,具体实现方式,可以看我另外一篇帖子:jQuery自定义绑定的魔法升级版。
尽管架构的大纲里提出了facade可以实现授权检查的功能,其实完全可能由mediator去做,轻型架构要做的事情其实是几乎一样的,那就是解耦,确保各模块直接和应用程序核心通信是没问题的就行。
这其实就是一个两面性的问题,我们上面说到了,一个模块也许有一些子模块,或者基础模块,比如基本的DOM操作工具类等,在这个层面上讲,我们是可以用第三方类库的,但是请确保,我们可以很容易地能否替换掉他们。
我打算去搞一份代码样本供大家参考,不过在这之前,你可以参考Andrew Burgees的帖子Writing Modular JavaScript 。
技术上来将,没有理由现在模块不能和应用程序核心直接通信,但是对于大多数应用体验来说,还是不要。既然你选择了这个架构,那就要遵守该架构所定义的规则。
感谢Nicholas Zakas的原始贴,将思想总结在一起,感谢Andree Hansson的technical review,感谢Rebecca Murphey, Justin Meyer, John Hann, Peter Michaux, Paul Irish和Alex Sexton,他们所有的人都提供了和本Session相关的很多资料。
也非常感谢博客园的汤姆大叔(TomXu),将本Session的内容整理成中文版本,如对你有用,请推荐一把。
联系客服