下面的几个小节致力于探讨Ajax开发中的一些问题,并且考察一些通用的解决方案。在每一个案例中,我们都会为你展示重构如何减轻了与这个问题相关的痛苦,然后我们会识别出可以在其他地方重用的这个方案中的元素。
为了保持《设计模式》这部著作的光荣传统,我们继续沿用它的经典表述方式,我们按照问题、技术解决方案,然后是相关的更大问题这样的顺序来讨论。
3.2.1 跨浏览器不一致性:Façade和Adapter模式
如果你问任何一位Web开发者,他在工作中抱怨最多的是什么?无论他是程序员、设计师、图形艺术家或者其他的相关人员,如何能让他的作品在不同的浏览器上都能正确地显示,这个问题通常都会名列前茅。Web世界技术标准林立,大部分浏览器厂商的实现都或多或少的与这些标准存在差异。有的时候,标准本身就很含糊,容易引起不同的解释;有的时候,浏览器厂商出于易用的目的,各自通过不一致的方式来扩展这些标准;还有的时候,仅仅是因为浏览器中残留着过时的bug。
JavaScript编程人员从很早以来就通过检查代码运行在哪个浏览器中,或者通过测试某个特定的对象是否存在来解决这个问题。我们来看一个简单的例子。
1. 使用DOM元素
我们在第2章中已经讨论过,Web页面是通过DOM公开给JavaScript的,以一个树状结构表示,其元素对应HTML文档中的标签。当通过程序来维护一棵DOM树的时候,想要找到某个元素在页面上的位置是一种非常常见的需求。不幸的是,这些年来浏览器厂商提供了好几种非标准的方法,使得我们很难写出安全可靠的跨浏览器代码来完成这个任务。代码清单3-2是来自Mike Foster的x库(见3.5节)的一个函数简化版本。这个函数接受一个参数e,以一种全面的方式发现这个DOM元素的左边界的像素位置。
代码清单3-2 getLeft()函数

通过在第2章中见到过的style数组,不同的浏览器提供了很多方法来确定节点的位置。W3C的CSS2标准支持一个叫做style.left的属性,其定义为一个字符串,描述了值和单位,例如100px。除了像素,它也支持其他的单位。而作为对照,style.pixelLeft的值则是一个数字,它假设所有的值都是以像素为单位的。pixelLeft只有微软的IE才支持。这里讨论的getLeft()方法首先检查浏览器是否支持CSS,然后测试这两个值,首先尝试使用W3C的标准属性,如果没有找到这两个值,就会返回一个0作为缺省值。注意,我们没有明确地检查浏览器的名称或者版本,而是使用了在第2章中讨论过的更加健壮的对象检测技术。
编写这样功能的一些代码以适应不同的浏览器,确实是一件很乏味的事情。不过,一旦完成了,开发者就再也不必为这样的事情而烦恼,而可以专下心来开发真正的应用。像经过良好测试的函数库(如x库),已经为我们完成了大部分困难的工作。通过这样一个可靠的适配器函数来发现DOM元素在页面上的位置,无疑将会大大加速Ajax用户界面的开发。
2. 发送请求到服务器
在第2章中,我们还提到过另一个类似的跨浏览器不兼容性问题。浏览器厂商提供了非标准机制来获得XMLHttpRequest对象,用于发送异步请求到服务器。当想要从服务器加载一个XML文档的时候,我们需要确定可以使用哪一个方法。
IE只能通过访问ActiveX组件获得这个对象,而Mozilla和Safari则是以内建的原生对象的形式提供这个对象[4],只有加载XML的代码本身需要知道这个差别。一旦XMLHttpRequest对象被返回给其余的代码,这个对象的行为在两种情况下都是相同的。调用它的代码既无须理解ActiveX,也无须理解原生的对象子系统;它只需要理解net.ContentLoader()的构造函数就足够了。
3. Façade模式
无论是在getLeft()还是在new net.ContentLoader()中,完成对象检测的代码都显得丑陋而乏味。我们可以通过定义一个函数来隐藏这些代码,这使得其他代码更加容易阅读,并且将对象检测代码集中到了一个地方。这就是重构的一个基本原则——不要重复你自己,常常简写为DRY(don’t repeat yourself)。如果发现在某种边界情况下对象检测代码不能正常工作,那么只需要在一个地方修改,就可以将影响扩散到所有调用它的代码中,无论这些代码是用来发现DOM元素的左坐标、创建XML请求对象,或者是在做其他我们试图做的事情。
用设计模式的行话来说,我们正在使用一种称作Façade的模式。Façade模式可以用来为一个服务或者一些功能的不同实现方式提供公共的访问点。例如,XMLHttpRequest对象提供了有用的服务,只要它还能工作,应用就不会真正去关心它究竟是以什么方式实现的(图3-1)。
图3-1 Façade模式的示意图,涉及跨浏览器的XMLHttpRequest对象。loadXML()函数需要一个XMLHttpRequest对象,但是它并不关心实际的实现。底层实现可能需要提供相当复杂的HTTP请求的语义,但是两种实现在这里都做了简化,以提供了调用函数所需要的基本功能
在很多情况下,我们也想要简化对子系统的访问。例如,在获取DOM元素左坐标的情况下,CSS规约提供了过多的选择,允许使用像素、点、em(屏幕字体尺寸)以及其他的单位来指定

该值。代码清单3-2中的getLeft()函数以及布局系统全部都使用像素作为单位。以这种方式来简化这个子系统是Façade模式的另一种功能。
4. Adapter模式
与Façade模式密切相关的是Adapter模式。在Adapter模式中,就像在微软和Mozilla的浏览器中得到一个XMLHttpRequest对象一样,我们是与两个提供相同功能的子系统共同工作。与前面为每个子系统构造一个新的Façade不同,我们为其中的一个子系统提供了一个额外的层,使得这个子系统展现出与另一个子系统相同的API。这个层称作适配器层(adapter)。将在3.5.1节讨论的用于Ajax开发的Sarissa XML库使用了Adapter模式,使IE的ActiveX控件看起来像是与Mozilla内建的XMLHttpRequest对象一样。这两种方法都是有效的,都可以帮助我们将遗留的或第三方的代码(包括浏览器本身)集成到我们的Ajax项目中。
我们来继续研究下一个案例,考虑JavaScript的事件处理模型中的问题。
3.2.2 管理事件处理函数:Observer模式
我们无法不使用基于事件的编程技术而编写大量的Ajax代码。JavaScript的用户界面严重依赖于事件驱动,而在Ajax引入异步请求之后,应用程序需要处理的回调函数和事件更多了。在相对简单的应用中,我们可以使用单个函数来处理类似鼠标点击或者服务器端数据到达的事件。然而,随着应用的规模和复杂性日渐增长,我们可能想要通知几个不同的子系统,甚至需要提供一种机制,即对事件感兴趣的各方可以自行登记所需要的通知。我们通过一个例子来看看这里面有些什么问题。
1. 使用多个事件处理函数
当使用JavaScript对DOM节点进行脚本处理的时候,通常都需要定义一个window.onload函数,这个函数会在页面(以及其所对应的DOM树)全部加载完成的时候执行。一旦页面加载完成,页面上有一个DOM元素,用来显示从服务器获取的动态生成的数据。完成数据获取和显示的JavaScript代码需要一个到DOM节点的引用,于是它通过定义一个window.onload事件处理函数来得到这个引用。
window.onload=function(){
displayDiv=document.getElementById('display');
}
看起来还不错。假设我们现在想要增加另一个视觉效果,例如提供一个新闻提要警告框(如果你对实现这个功能感兴趣,参见第13章)。控制新闻提要显示的代码也需要在一开始就获取到某个DOM元素的引用,于是它也定义了一个window.onload事件处理函数:
window.onload=function(){
feedDiv=document.getElementById('feeds');
}
我们分别在独立的页面中测试这两段代码,发现它们都可以正常工作。但是一旦将这两段代码放在一起,第二个window.onload函数就会覆盖第一个,导致来自服务器的数据无法显示,而是产生了JavaScript错误。问题就在于在window对象上只允许附加一个onload函数。
2. 组合事件处理函数的局限
第二个事件处理函数覆盖了第一个,通过将两者组合在单个的函数中可以解决这个问题:
window.onload=function(){
displayDiv=document.getElementById('display');
feedDiv=document.getElementById('feeds');
}
对于我们当前的例子来说,这个方法是有效的,但是它导致本来毫不相干的数据显示与新闻提要阅读器的代码混杂在了一起。如果我们要处理的不是2个系统,而是10个或20个系统,它们都需要得到对几个DOM元素的引用,那么像这样的组合事件处理函数将会变得难以维护。从中插入和取出单独的组件将会变得非常困难而且极易出错,出现本章开头所描述的情况,没有任何人愿意去动这段一碰就坏的代码。我们可以通过为每个子系统定义一个加载函数来重构一下:
window.onload=function(){
getDisplayElements();
getFeedElements();
}
function getDisplayElements(){
displayDiv=document.getElementById('display');
}
function getFeedElements(){
feedDiv=document.getElementById('feeds');
}
这样写显得清晰一些,将组合window.onload()函数中每个子系统的代码缩减到一行,但这个组合函数仍然是设计中的一个薄弱环节,还是有可能带来麻烦。下面,我们考察这个问题的略微复杂、但扩展性更好的解决方案。
3. Observer模式
一个操作应该是谁的职责?询问这个问题有些时候是很有帮助的。在这个组合函数的解决方案中,window对象负责获得到DOM元素的引用,随后window对象必须知道当前页面中包括哪些子系统。理想情况下,每个子系统应该自己负责获取它们需要的引用。按照这种方式,如果它包括在页面中,就会获得自己需要的引用;如果没有包括在页面中,就不必做这件事情。
为了清晰地分离这个职责,可以允许这些系统通过传递一个函数来登记,从而在发生onload事件时得到通知,这些函数会在window.onload事件触发时调用。这里是一个简单的实现:
window.onloadListeners=new Array();
window.addOnLoadListener(listener){
window.onloadListeners[window.onloadListeners.length]=listener;
}
窗口完全加载后,window对象只需要遍历这个数组,并且依次调用每个方法:
window.onload=function(){
for(var i=0;i<window.onloadListeners.length;i++){
var func=window.onlloadListeners[i];
func.call();
}
}
如果每一个子系统都使用这种方法,我们就可以提供更清晰的方式来设置所有的子系统,而不必将它们混杂在一起。当然,只需要少量的恶意代码[5]就可以直接覆盖window.onload,使我们的努力功亏一篑。但是,我们必须负起照看代码库以避免此类问题发生的责任。
还有一点值得指出的是,新的W3C事件模型也实现了一个多事件处理函数的系统。我们之所以选择在老的JavaScript事件模型之上建造系统,是因为W3C的模型在不同浏览器中的实现不一致。第4章将会更深入的讨论这个问题。
在这里,将重构的设计模式叫做Observer模式。Observer模式定义了一个Observable对象,在我们这个例子中,它是内建的window对象,一组Observer或Listener可以将自己登记在这个对象上(图3-2)。

图3-2 Observer模式中的职责分离。希望得到事件通知的对象(即Observer)可以在消息源(Observable)上登记,也可以取消登记。当事件发生时消息源会通知所有已登记的对象
通过Observer模式,职责被适当地分配到了事件源和事件处理函数之间。处理函数负责它们自己的登记或取消登记;事件源则负责维护已登记各方的列表,并且在事件发生时通知它们。这个模式在事件驱动的用户界面编程领域拥有悠久的使用历史,我们在第4章深入讨论JavaScript事件的时候还会回到Observer模式上。正如我们将会看到的,Observer模式也可以独立于浏览器的鼠标和键盘事件的处理,使用在我们自己编写的对象之上。
现在,我们进入到下一个经常重复出现的问题,通过重构来解决它。
3.2.3 重用用户操作处理函数:Command模式
在大多数应用中,都是由用户来告诉(通过点击鼠标和按下键盘)应用程序要做些什么事情,然后应用程序照着做,这可能是一件显而易见的事情。在一个简单程序中,可能只会给用户提供一种方法来完成一个操作,但在更加复杂的界面中,我们常常想让用户通过几种途径来触发相同的操作。
1. 实现按钮UI组件
假设有一个DOM元素,通过设置样式使它看起来像是一个按钮UI组件。当按下它时会执行一个计算,然后使用计算的结果更新一个HTML表格。我们可以为这个button元素定义一个鼠标点击的事件处理函数,就像这样:
function buttonOnclickHandler(event){
var data=new Array();
data[0]=6;
data[1]=data[0]/3;
data[2]=data[0]*data[1]+7;
var newRow=createTableRow(dataTable);
for (var i=0;i<data.length;i++){
createTableCell(newRow,data[i]);
}
}
在这里假定dataTable变量是一个到现有表格的引用,而createTableRow()和create- TableCell()函数管理DOM处理的细节。这里真正有意思的是计算阶段,在实际应用中,这个阶段可能会包括上百行代码。我们将这个事件处理函数分配给button元素,像这样:
buttonDiv.onclick=buttonOnclickHandler;
2. 支持多种事件类型
假设我们现在为Ajax应用做压力测试。我们轮询服务器以获取更新的数据,如果某个特定的值被服务器更新了,需要重新执行计算,然后使用得到的数据更新另外一个表格。在这里,没有必要深入讨论重复轮询服务器的细节。假定有一个到poller对象的引用,在其内部使用了XMLHttpRequest对象,并且将它的onreadystatechange处理函数设置为调用onload函数,当来自服务器的更新数据加载完成后会调用这个函数。我们可以将计算和显示的阶段抽象到帮助函数中,就像这样:
function buttonOnclickHandler(event){
var data=calculate();
showData(dataTable,data);
}
function ajaxOnloadHandler(){
var data=calculate();
showData(otherDataTable,data);
}
function calculate(){
var data=new Array();
data[0]=6;
data[1]=data[0]/3;
data[2]=data[0]*data[1]+7;
return data;
}
function showData(table,data){
var newRow=createTableRow(table);
for (var i=0;i<data.length;i++){
createTableCell(newRow,data[i]);
}
}
buttonDiv.onclick=buttonOnclickHandler;
poller.onload=ajaxOnloadHandler;
我们看到,大量常用的功能已经抽象到了calculate()和showData()函数中,在onclick和onload处理函数中只有少量重复的代码。
现在已经将业务逻辑和用户界面更新很好地分离开来了。我们又发现了一个有用的可重复解决方案,称作Command模式。Command对象定义了一些具有任意复杂性的活动,可以很容易地在代码之间传递,或者在UI元素之间交换。在面向对象语言的传统Command模式中,用户的交互都封装为Command对象,通常继承自一个基类或者实现一个接口。在这里我们使用一种略微不同的方法来解决相同的问题。因为在JavaScript中,函数本身就是头等对象,我们可以直接将它们当作Command对象来处理,与此同时仍然提供了相同的抽象级别。
将用户所做的事情都封装为Command对象可能看起来有点麻烦,但是这样做是有回报的。当所有的用户行为都封装在Command对象中时,我们就可以很容易地联合使用其他标准的功能。讨论最多的扩展是增加undo()方法,一旦完成了这个工作,就为在整个应用中提供通用的撤销(undo)功能奠定了良好的基础。在一个更加复杂的例子中,Command在执行时可以被记录在一个栈中,用户可以通过撤销按钮来回退这个栈,从而将应用返回到以前的状态(图3-3)。

图3-3 在字处理应用中,使用Command模式来实现通用的撤销栈。所有的用户交互都用Command对象来表示,可以同时支持执行和撤销操作
每个新的Command对象都放在栈的顶端,可以按顺序逐个回退。用户通过一系列写的操作创建了一个文档,然后选择整个文档时,一不小心点了删除按钮。当调用undo功能时,从栈中弹出最顶端的条目[6],然后调用它的undo()方法,恢复被删除的文本。后续的撤销操作可能是取消文本的选择,等等。
当然,使用Command模式来创建一个撤销栈,还要确保这些执行和撤销操作的组合能够返回系统的初始状态,对于开发者来说这意味着一些额外的工作。提供完善的撤销功能可以使得产品显得与众不同,特别是对于频繁或长时间使用的应用来说,这个功能更加重要。正如我们在第1章中讨论过的,这正是Ajax正在努力扩张的版图。
当我们需要在应用中跨越子系统的边界传递信息的时候,Command对象也能派上用场。而网络正是这样的一个边界,在第5章中,讨论客户/服务器之间的交互时,还会再次谈到Command模式。
3.2.4 保持对资源的唯一引用:Singleton模式
在一些场合,确保只能从一个地点访问某个特殊的资源是很重要的。这一点最好还是通过一个特殊的例子来解释,我们来看一下。
1. 简单的交易例子
假设Ajax应用可以操作股票市场的数据,允许我们在真实的市场上进行交易,执行what-if计算,并且通过网络与其他用户进行模拟交易。我们按照交通信号灯,为应用定义了3种模式。在实时模式(绿色模式)下,当股市开盘的时候,我们可以在真实的市场上买卖股票,并且使用保存的真实数据来执行what-if计算。当股市封盘的时候,我们恢复到分析模式(红色模式),仍然可以执行what-if计算,但是不能买卖。在模拟模式(黄色模式)下,我们可以执行所有绿色模式可以执行的操作,但是并不是与真实的股票市场交互,而是使用虚拟的数据。
客户端代码将这种变换表示为一个JavaScript对象,定义如下:
var MODE_RED=1;
var MODE_AMBER=2;
var MODE_GREEN=2;
function TradingMode(){
this.mode=MODE_RED;
}
我们可以在代码中的很多地方查询和设置这个对象所表示的模式,还可以提供getMode()和setMode()函数,在其中检查一些条件是否满足,例如真实的市场是否已经开盘,但是在目前我们最好还是简单一些。
假设我们为用户提供了买和卖两个选项,在真正执行交易之前可以先计算出可能的盈利和损失。买和卖的操作依赖于操作的模式指向不同的Web服务:黄色模式指向内部的服务;绿色模式指向经纪人服务器上的服务;红色模式则不提供任何服务。类似地,分析基于所获取的当前和最近的价格数据:黄色模式使用模拟数据,绿色模式使用真实市场的数据。要知道该指向哪个数据来源,两者都需要查询一个如下定义的TradingMode对象(图3-4):
两个活动都指向相同的TradingMode对象是很有必要的。如果用户根据对真实市场数据所做的分析在模拟市场上进行买卖,那么她可能会输掉这次游戏;如果她根据对模拟市场数据所做的分析在真实市场上进行买卖,那么她可能很快就会丢掉饭碗!
一个对象只有一个实例,有时也描述为一个单例(singleton)。我们先来考察一下在面向对象语言中是如何处理单例的,然后找到在JavaScript中使用它的策略。
2. Java中的单例
在类似Java的语言中,实现单例的方法通常是隐藏对象的构造函数,并且提供一个getter方法,如代码清单3-3所示。
图3-4 在Ajax交易应用的例子中,买/卖功能和分析功能都基于TradingMode对象的状态来确定是使用真实数据还是使用模拟数据。在黄色模式下访问模拟服务器,而在绿色模式下访问在线交易服务器。如果系统中有多于一个的TradingMode对象,系统将会出现状态不一致的情况
代码清单3-3 Java中TradingMode对象的单例实现

![]()

基于Java的解决方案利用private和public的访问修饰符来强化单例的行为,下面的代码是无法编译的:
new TradingMode().setMode(MODE_AMBER);
因为构造函数不是公共的,而下面的代码则可以编译:
TradingMode.getInstance().setMode(MODE_AMBER);
这行代码保证了每次调用都得到相同的TradingMode对象。我们在这里使用了几种JavaScript所没有的语言特征,我们来看看如何解决这个问题。
3. JavaScript中的单例
在JavaScript中,虽然没有内建的对于访问修饰符的支持,但是可以通过不提供构造函数的方式来“隐藏”构造函数。JavaScript是基于原型的,构造函数是普通的Function对象(如果不理解这是什么意思,参见附录B)。我们可以按照平常的方式来定义TradingMode对象:
function TradingMode(){
this.mode=MODE_RED;
}
TradingMode.prototype.setMode=function(){
}
然后提供一个全局变量作为一个伪单例:
TradingMode.instance=new TradingMode();
但是这无法阻止恶意代码调用构造函数。另一方面,我们可以不使用原型,手工创建整个对象:
var TradingMode = new Object();
TradingMode.mode = MODE_RED;
TradingMode.setMode = function() {
...
}
也可以用更加简洁的方式来定义它:
var TradingMode = {
mode:MODE_RED,
setMode:function(){
...
}
};
这两个例子都会生成相同的对象。前一种方式对于Java或C#程序员或许更加熟悉。我们展示后一种方式,是因为在Prototype库和这个库所衍生的框架中经常使用这种方式。
这种解决方案只在单独的脚本上下文范围内是有效的。如果脚本加载到另外一个IFrame中,它将使用自己的单例副本。我们可以明确指定通过最顶层的文档来访问单例对象(在JavaScript中,top总是指向这个文档),如代码清单3-4所示。
代码清单3-4 JavaScript中TradingMode对象的单例实现


这使得脚本可以安全地包括在多个IFrame中,同时保持单例对象的唯一性。(如果你计划支持跨多个顶层窗口的单例,你需要研究一下top.opener。限于篇幅,我们将这个留给读者作为练习。)
在编写UI代码的时候,对单例的需要可能不是很强烈,但是在使用JavaScript编写业务逻辑代码的时候,它会变得非常有用。在传统的Web应用中,业务逻辑通常都位于服务器上,但是以Ajax的方式进行开发改变了这种状况,单例会变得很有用,因此有必要熟练掌握。
在实用的层面上,重构可以为我们做些什么?上面的例子为我们提供了一点初步的印象。到目前为止,我们所看到的例子都是相当简单的,即便如此,通过重构使代码更加清晰仍然帮助我们排除了代码中的一些薄弱环节,否则随着应用规模逐渐增大,这些问题可能会神出鬼没,使我们寝食难安。
我们讲述了几种设计模式,在下一节中将来考察一个大规模的服务器端模式,看看如何重构一些最初纠缠在一起的代码,使得它们变得更加清晰、更加灵活。







