AJAX企业级开发— AJAX MVC

繁简对译:[]  字体设置:[] 2008-09-11  作者:Johnson,D,White.A.,Charland  来源:人民邮电出版社  阅读  次

AJAX MVC

现在我们已经对MVC模式有了基础性的认识,同时对MVC模式在实际应用中的表现也有了基本的了解。接下来让我们进一步了解如何在构建AJAX组件的上下文中充分利用这个模式,这些组件在企业应用中可以重用。
1 AJAX模型
当回顾MVC模式的各个方面时,一种好的方式是从比较形象的视图开始,随后逐渐过渡到背后比较抽象的模型概念。但是,当我们开始给AJAX组件应用MVC时,从模型开始再过渡到视图更有意义。并且以这种顺序实现通常能够更加容易以测试驱动方式(test-driven approach)来编写我们的代码,在具体编码之前我们会先写测试,然后编写保证测试都能通过的代码,通过这种方式可以逐层构建应用。逻辑上,开发AJAX应用时我们需要做的第一件事是访问数据。实际上,从服务器读取数据只是AJAX组件实际使用AJAX的一部分,大多数所谓的AJAX实际上只是DHTML。我们从一个独立JavaScript模型开始,它与服务器没有联系,仅仅实现了CRUD功能。任何模型的基本功能都要求能够在客户端维护一组数据记录列表,类似于MySQL的ResultSet和ADO的RecordSet。这是我们可以创建的最简单的模型类型,它几乎不包含任何域信息(domain information)。记录集可以以任何数据格式进行保存例如XML或者普通的JavaScript对象(POJSO,Plain Old JavaScript Object)。然而,在简单模型中也存在一些公共的功能,这些功能与存储格式无关。为了让基本的MVC模型适合观察者模式(Observer pattern),我们需要考虑一些基础方面的问题。最重要的是,基本模型的每个重要的CRUD操作都需要有相应的事件。下面是DataModel类,它定义了一个简单的模型。
entAJAX.DataModel = function() {
  this.onRowsInserted = new entAJAX.SubjectHelper();
  this.onRowsDeleted = new entAJAX.SubjectHelper();
  this.onRowsUpdated = new entAJAX.SubjectHelper();
}
entAJAX.DataModel.prototype.insert = function(items, index) { }
entAJAX.DataModel.prototype.read = function() { }
entAJAX.DataModel.prototype.update = function(index, values) {
}
entAJAX.DataModel.prototype.remove = function(index) { }
这里,我们根据第2章讨论过的定义类的指导原则创建了一个称为DataModel的新JavaScript类,这个类代表基本模型,它在构造函数里实例化基本的事件并且为4种固有的数据操作——CRUD定义了桩方法。实际上,这个DataModel类应该是一个抽象类,因为缺少CRUD方法内的具体定义,但是因为在JavaScript里并没有一种简单的方法来表明抽象类,所以暂时还必须这么采用这种方式来实现。无论如何,DataModel类提供了一个很好的基础,我们可以根据它来为各种数据存储种类创建更加具体的模型。
请注意,这里的事件(onRowsDelete等)是作为DataModel类的属性创建的,它们的类型是SubjectHelper。SubjectHelper类是观察者模式的一个重要部分,图3-4对这个模式进行了描述。

观察者模式
 

图3-4 观察者模式的类图
在这个观察者模式的具体实现中,我们没有像往常一样使用ConcreteSubject实现ISubject接口,而是使用一个SubjectHelper类来实现ISubject接口。通过这种方式实现观察者模式,我们可以将多个SubjectHelper类(或者继承于SubjectHelper的更加具体的类)与一个单独的Subject关联,如图3-5所示,这里的Subject是DataModel类。

DataModel观察者模式
 

图3-5 观察者模式精化设计
使用SubjectHelper类有多个优点。不仅可以帮助从观察者模式的实现中分离出域逻辑,还允许我们为更加细粒度的目标(Subject)创建具体的辅助类。我们的DataModel域对象存在一些可能发生的操作,例如数据插入、删除和更新。对于每个操作而言,都可能存在不同的观察者对这些事件中的一部分事件感兴趣。通过观察者模式里的SubjectHelper,某个观察者可以只订阅他想被通知的特定目标,而不是让所有观察者都订阅DataModel对象本身,否则会导致不管他们是否对特定的事件感兴趣,当任一事件发生改变时他们都会被通知到。我们也可以想象,如果所有的观察者都订阅域对象本身而不是他们所关注的具体事件,这样在运行期就会产生很大的开销。UML模型里的ISubject接口在JavaScript中的编码如下所示:
entAJAX.ISubject = function() {
  this.observers = [];
  this.guid = 0;
}
entAJAX.ISubject.prototype.subscribe = function(observer) {
  var guid = this.guid++;
  this.observers[guid] = observer;
  return guid;
}
entAJAX.ISubject.prototype.unSubscribe = function(guid) {
  delete this.observers[guid];
}
entAJAX.ISubject.prototype.notify = function(eventArgs) {
  for (var item in this.observers) {
    var observer = this.observers[item];
    if (observer instanceof Function)
      observer.call(this, eventArgs);
    else
      observer.update.call(this, eventArgs);
  }
}
ISubject中只有3个方法。所有对特定目标感兴趣的观察者都被保存在一个观察者对象散列数组中,观察者可以分别通过subscribe()和unSubscribe()方法进行增加或者移除。notify()方法被用来遍历观察者的集合并调用它们的update()方法。我们也对熟悉的观察者模式进行了细微的扩展,这种扩展使得开发者可以指定观察者对象的一个自定义方法,当通知观察者时调用这个方法而不再需要调用update()方法。当指定全局方法作为事件处理函数时,这个实现非常有用。
尽管我们给ISubject类加上了前缀I以表明它是一个接口,但是由于在JavaScript里缺少对接口的支持,它更多的是作为一个伪接口(pseudo interface)或是抽象类。考虑到JavaScript的动态特性,我们也可以充分利用接口来作为一种实现多重(单层)继承的方法。
entAJAX.SubjectHelper = function() {
  this.observers = {};
  this.guid = 0;
}
在这段代码中,我们第一次有机会使用到JavaScript的继承模型。实际上,我们定义了一个提供接口的简单方法,这个简单的方式同时还支持默认值的使用。尽管我们并不能保证子类一定实现接口的方法,但是至少可以保证被实现的方法在接口本身中有某些默认的实现。诚然,这只是一个权宜之计,但是同时,它确实提供了一种方法让我们可以实现传统的继承和接口。实现接口的方法实际上可以被更多的认为是实现多重、单层继承的方法。
为了让继承可以工作,在定义了类之后,我们必须立即调用entAJAX.implements方法,这个方法将子类和父类作为它的两个参数,代码如下所示:
entAJAX.implements = function(klass, interface) {
  for (var item in interface.prototype) {
    klass.prototype[item] = interface.prototype[item];
  }
}
entAJAX.SubjectHelper = function() {
  ...
}
entAJAX.implement(entAJAX.SubjectHelper, entAJAX.ISubject);
现在,我们可以创建一个新的SimpleDataModel类,这个类继承自DataModel类,通过它来填充DataModel类的抽象CRUD方法,这些方法具体实现了父类里的桩方法,它们以一种特定的方式管理数据。采用哪种方式存储数据取决于很多问题,其中最突出的问题是这些数据将用来做什么以及目标用户的浏览器是什么。首先,使用可存放任意对象的JavaScript数组来存储数据。这使得CRUD操作非常简单,同时也意味着这种方式应该和格式为JSON的数据是可互操作的,这些数据可以来自于服务器也可以来自于客户端。SimpleDataModel类可以实现CRUD方法并且保证当这些方法被调用时相应的事件能够被触发。这个类的代码如下所示:
entAJAX.SimpleDataModel = function()
{
  //调用基类构造函数来初始化事件对象
  entAJAX.SimpleDataModel.baseConstructor.call(this);
  // 模型里的记录集
  this.Items = [];
}
// 继承自DataModel
entAJAX.extend(entAJAX.SimpleDataModel, entAJAX.DataModel);
entAJAX.SimpleDataModel.prototype.insert = function(items,
index)
{
  this.Items.concat(items);
  this.onRowsInserted.notify({"source":this, "items":items});
}
entAJAX.SimpleDataModel.prototype.read = function(query)
{
  return this.Items;
}
entAJAX.SimpleDataModel.prototype.update = function(index,
values)
{
  var item = this.Items[index];
  for (var field in values)
  {
    item[field] = values[field];
  }
  this.onRowsUpdated.notify({"source":this, "items":[item]});
}
SimpleDataModel.prototype.remove = function(index)
{
  var item = this.Items.splice(index, 1);
  this.onRowsDeleted.notify({"source":this, "items":[item]});
}
CRUD的操作相当基础,这里为了保持简单和高效,我们直接使用了数组对象的内置JavaScript方法,例如concat()和splice()。我们让SimpleDataModel对存储在数组里的对象类型保持不可知性,但是稍后会分析对此的一些改进,我们将对数据行执行更强的类型检查。这里需要注意的是在SimpleDataModel的每一个create()、update()和remove()方法中,我们分别调用了onRowsInserted、onRowsUpdated和onRowsDeleted属性的notify()方法,这一点很重要。在MVC的上下文中,通知观察者数据的变化让我们能够在模型数据发生改变时通知视图。为了通知视图数据发生了变化,需要完成至少两件事情:一组模型在其上发出通知的事件,一组注册它们自己来接受通知的对象。这两者都由SubjectHelper类管理。
为了使用观察者模式,我们需要定义事件,因为其他对象可能对这些事件感兴趣并且监听这些事件。对模型而言,这些事件可能是插入数据列和删除数据列之类的操作。这些事件的种类完全取决于应用架构师并且高度取决于应用。当向模型里插入数据时,OnRowsInserted事件被触发。触发这个事件只是意味着调用notify()方法。在insert()、update()和remove()方法的下方,我们都添加了一行代码,它们调用各自SubjectHelper的notify()方法,如下所示:
this.onRowsInserted.notify({"source":this, "items":items});
传入notify()方法的参数随后又被提供来调用观察者上的update()方法。这允许观察者获取关于所发生事件的一些上下文信息。当调用SimpleDataModel类的create()方法时,我们希望实际提供给事件处理函数的是被创建的特定数据信息,这样处理函数就可以对这条数据采取相应的动作了。创建一个RowsCreatedEventArgs类而不是,比如使用结构,也许更为稳妥,这取决于我们所使用的开发方法学。作为数据结构来将信息传递到观察者。通常情况下,我们使用JavaScript类来传递信息,采用这种方式最迫切的理由是其文档化和易于编程实现。另一种选择方案是不将关于事件的信息传递到观察者的update()方法,而是让观察者自己通过Subject- Helper的方法(例如getData())来请求信息。如果存在大量的数据和事件关联,并且对于多数观察者来说这些数据是不需要用到的,这是一个很好的方法。
这是一个简化的模型。这个模型存在一个很明显的不足,因为数据被存储在JavaScript数组中并且没有与服务器连接实现持久化,所以数据只存在于应用的持续运行时间内。尽管我们已经提出了一个完全基于JavaScript的模型,但是我们也可以很容易地使用一种将数据存储在客户端的替代方案,例如XML文档。如果我们的服务器环境提供基于XML的数据或者消费基于XML的数据,或者在我们的应用中数据的互操作性具有很高的优先级,XML可以是一种很好的选择。XML还可以充分利用XSLT,XSLT可以从运算上、技术上甚至开发流程上简化构建AJAX视图的过程。此外,在客户端使用XSLT可以很容易并高效地操作基于XML的数据(想想分组和转换)。但是,无论我们在客户端存储数据采用哪种方法,当用户关闭Web浏览器或者浏览另一个不同的Web网站时,数据销毁的问题仍然存在。
通过重构代码,我们可以集成一些AJAX功能,这些功能可以从服务器加载数据以及把数据保存到服务器上,并且,在某些情况下,甚至可以在客户端使用特定于浏览器的技术或者Flash技术。但是这个简单模型至少在目前为我们提供了一个很好的框架,我们可以开始在Web浏览器的JavaScript沙箱(sandbox)里操作数据,并且当通过我们的事件驱动控制器将它和基于DOM的视图联系起来时,它也应该足够的出色。当理解了如何让模型跨越网络从客户端连接到服务器之后,我们可以分析如何使用一些很重要的设计模式,例如活动记录模式(ActiveRecord),该模式通过Ruby on Rails平台已经变得非常流行。利用这些众所周知的模式(例如MVC、观察者以及活动记录)是构建一个具有高性能和可用性的AJAX应用的关键因素。
为了了解模型背后的思想,让我们分析一个简单的例子。首先,我们创建一个实现IObserver接口的Listener类,事实上,它只需要实现update()方法。Listener类的实例随后被订阅到SimpleDataModel的onRowsInserted事件。现在,当我们手动调用SimpleDataModel类实例的create()方法时,相关订阅观察者的update()方法会被执行。
// 创建一个简单类来监听模型更新
Listener = function() {}
// 当收到更新通知时alert
Listener.prototype.update = function(eventArgs) {
  alert(eventArgs.type + ' – ' + eventArgs.data.getFullName());
}
// 创建监听函数实例
var CustomerListener = new Listener();
// 创建一个没有数据的新模型
var CustomerModel = new entAJAX.SimpleDataModel();
// 注册CustomerListener对象监听数据的变化
CustomerModel.onRowsInserted.subscribe(CustomerListener);
// 最后插入一个新的客户信息,我们应该看到弹出提示框
CustomerModel.insert(new Customer('John', 'Doe');
如果我们将这个功能整合到一个更大的例子,这个例子会根据某些用户的交互将客户记录插入到数据库并随后更新应用的视图,它的时序图如图3-6。
图3-6 演示如何在MVC模式的上下文中使用SimpleDataModel时序图

onRowslnserted.
notify
 
更新
 
插入
 
insertCo-
mplete
 
handleEvent
 
单击按钮
 
终端用户              视图             控制器               时序图
 
SimpleDataModel时序图
 
既然已经有了可操作的数据,那么接下来我们将仔细分析如何为AJAX应用构建一个数据驱动(data-driven)的MVC视图。

2 AJAX视图
有了模型之后,不管模型是完全位于客户端还是跨越客户端和服务器端,我们都可以使用视图呈现存在于模型中的信息。在传统的N层Web架构中,应用被清晰地划分为表现层、业务逻辑层和数据访问层,服务器负责生成视图,并将HTML以流的方式发送到客户端进行展现。用户动作从视图以一个完整的HTTP POST请求的方式发送回服务传播到控制器,在控制器中处理所有的客户端信息,创建新的视图并以流的方式把视图发送到客户端。视图通常使用某种脚本语言例如PHP或者一些具有更完备特性的语言例如Java生成,而这个过程又可能会使用到其他一些模板技术例如XSLT、Velocity(Java)和Smarty(PHP)。
当在AJAX里分析构建MVC视图的方式时,本质上存在两种选择。最常见的选择是视图的变化完全在客户端通过使用客户端模板或者DOM操作完成。在客户端完成所有的视图变化是AJAX的精髓所在,这种方式可以被充分利用来创建一个真实的富用户界面。另外一个不太常见的选择是在服务器生成视图的一些小的片段,然后在后台获得这些片段并将它们直接插入到DOM中,客户端没有或者只有很少的逻辑。当逻辑复杂或者需要充分利用遗留资源时,经常采用后一种方式。门户(Portal)也许经常使用这种架构,因为存在来自于许多不同数据源的小片段数据。第6章将研究AJAX架构中的一些细则。
作为导言 ,我们将适当地实现一些基本的JavaScript操作。我们已经讨论过,当构建视图时有几种选择可以考虑。要做出正确的选择需要考虑各个方面的因素,例如服务器和客户端的性能、服务器负载、可维护性、可测试性以及开发者的技能。在这些因素中,性能是至关重要的。迁移到AJAX架构的最初的一个主要原因是传统的基于回传机制的Web应用存在固有的性能问题。作为一种解释性语言,JavaScript往往效率缓慢,并且效率取决于不同的应用,JavaScript解释的速度可能是一个主要的瓶颈。构建视图最显而易见的解决方案是使用DOM所提供的方法来操作DOM元素。我们在第2章分析过一些这样的方法,例如document.createElement(),document.appendChild()以及其他一些方法。例如,如果想使用DOM而不是字符串拼接来创建关于客户信息的视图,我们可以像下面这样编写代码:
var aCustomerList = CustomerData.read();
var iCustomers = aCustomerList.length;
for (var i=0; i<iCustomers; i++) {
  var dCustomerDiv = document.createElement('DIV');
  var sCustomerName = aCustomerList[i].getFullName();
  var dCustomerName = document.createTextNode(sCustomerName);
  dCustomerDiv.appendChild(dCustomerName);
  document.body.appendChild(dCustomerDiv);
}
注意,因为JavaScript并不是一种编译性语言,并且开发者并不总是在一个带有代码自动完成功能的集成开发环境(IDE)里编写代码,所以像下面这样通过变量名称来辨别变量类型有助于编码实现:
var aVariable = []; // 数组
var dVariable = $("myDomNode"); // DOM元素对象
var sVariable = "string"; // 字符串
var nVarialbe = 1; // 数目
在这段代码中,我们使用了标准的DOM方法。然而,当构建表格式的结构时,我们也可以选择使用经常被人们忽略的、特定的DOM方法<table>。多数情况下,我们不赞成使用<table>标签,而是倾向于使用<div>元素和CSS来布局。但是使用<table>元素有两个优点,它可以快速地呈现,另外,很多软件为了Web页面的可访问性,例如自由科学公司(Freedom Scientific)的JAWS,使用<table>元素标记在Web页面里搜集关于数据的信息(参见第8章更多关于页面可访问性的讨论)。尽管当从XML的角度来思考解决问题的方案时,使用标准的DOM API非常直观,但是也存在几个值得关注的其他选择。最常见的方式,正如我们已经讨论过的,是使用事实上的标准HTML元素的innerHTML属性。innerHTML是把大量数据插入到Web页面中最快速的方式并且往往也是最简单的方式之一。使用DOM API一般比使用innerHTML来操作DOM要低效。[稍后会展示一些基线测量(benchmarking)的结果。]鉴于使用innerHTML是最快速的方式并且它可以直接接受字符串值,生成视图的真正问题就变成了如何创建设置给innerHTML属性的HTML字符串。也许你已经猜到,构建字符串的一种快速但是丑陋的方法是直接将相关的字符串拼接在一起创建HTML。如果想创建一组客户名称并将它们插入到DOM中,我们可以采用以下方式对上文的DOM操作代码做出少量的修改:
var sCustomerList = "";
var aCustomerList = CustomerModel.read();
var iCustomers = aCustomerList.length;
for (var i=0; i<iCustomers; i++) {
  sCustomerList +=
'<div>'+aCustomerList[i].getFullName()+'</DIV>');
}
$('CustomerList').innerHTML = sCustomerList;
这种方式不仅代码更少,而且执行起来非常快速。尽管许多JavaScript框架都有构建字符串的某种简单的API,但是和其他许多编程语言不同,JavaScript里并没有提供对字符串构建进行优化的功能,所以我们只需要简单地将字符串拼接起来就可以了。
此时,许多开发者可能要感叹字符串拼接竟然是构建HTML片段的首选方法。幸好,我们可以利用一些模板技术,这样可以同时避免丑陋的字符串拼接以及缓慢的DOM API。
从保持开发的工作流程和代码清晰分离的角度考虑,使用模板的确是最诱人的选择。我们可以基于不同程度的复杂性和性能来实现模板技术。一个基本的模板scheme可以使用某些专门的语法,并且大多数模板技术都使用了正则表达式。现在继续构建我们的客户名单,首先定义一个基本的模板,用它来在列表里显示每个客户的名称。语法指定替换值使用${对象属性}来表明应该将当前JavaScript执行上下文中的对象属性放置到模板的这个位置。所以,对于我们的客户名单,模板如下所示:
<div>${firstName} ${lastName}</div>
为了给数据应用模板,我们可以使用如下的一个函数:
function Render(oCustomer, sTemplate) {
  while ((match = /\$\{(.*?)\}/.exec(sTemplate)) != null) {
    sTemplate = sTemplate.replace(match[0],
oCustomer[match[1]]);
  }
  // 返回被填充的模板
  return sTemplate;
}
这个函数允许我们创建基本的模板,为给定对象上的各个属性使用查找和替换。请注意,我们实际上是使用了一个正则表达式在模板里查找和替换适当的信息——和很多语言一样,JavaScript同样能够熟练地使用正则表达式。我们可以对这个函数进行重构,让它能够执行更多的操作,例如调用对象上的方法,调用其他的全局方法以及根据数据进行条件判断和循环。尽管在模板里使用条件判断和循环看起来非常有用,但是这样会让模板的构建和维护变得很困难。此外,通过在HTML模板外部维护应用逻辑,能够让我们的数据和表现保持良好的分离。当然,我们也可以为用户界面的一个特定部分应用多个模板并且使用JavaScript对条件进行求值,然后根据条件值应用不同的模板。例如,可以像下面这样根据客户的账户余额的正负来给我们的客户数据应用不同的模板:
function Render(oCustomer, sPositiveTemplate,
sNegativeTemplate) {
  var sTemplate = sPositiveTemplate;
  if (oCustomer.balance > 0) {
    sTemplate = sNegativeTemplate;
  }
  while ((match = /\$\{(.*?)\}/.exec(sTemplate)) != null) {
    sTemplate = sTemplate.replace(match[0],
oCustomer[match[1]]);
  }
  //返回被填充的模板
  return sTemplate;
};
使用这种技术,模板的调试工作会非常少,并且因为模板里没有包含任何编程逻辑,所以它们拥有更好的复用性,而且设计师可以独立于应用的其他部分很容易地构建它们。设计师只需要了解这样的信息:客户名称将以两种不同的方式呈现,一种方式表明此时客户有未清账款,另一种则表明没有。尽管如此,我们也可以使用某些具有更完备特性的模板系统例如XSLT和JavaScript原生方法,例如JSON模板(JSONT)。我们将在下一章对这些内容进行更深入的分析。
3 AJAX控制器
现在,我们已经分析了基本的模型和创建视图的一些基本原则,接下来我们需要把它们“粘合”起来,创建一个终端用户真正可以进行交互的应用。在MVC的AJAX应用中,控制器负责这种“粘合”的实现。控制器需要响应用户动作并负责编制(orchestrate)视图和模型,这种本性决定了它将高度依赖于DOM事件的API。我们在第2章已经讨论了许多DOM事件模型,并且展示了如何以一种跨浏览器友好的方式给DOM元素附加事件。其中存在一个很重要的问题我们还没有考虑,这个问题即IE中存在的众所周知的内存泄漏问题。在IE中,内存泄露通常和DOM事件的附加联系在一起。在某些情况下,附加事件处理函数可能会造成DOM与JavaScript之间的循环引用。当一个匿名函数或者闭包被用来作为事件处理函数,并且,在这个匿名函数捕获的执行作用域里有一个被它所附加的HTML元素的引用时,将发生循环引用。下面的代码描述了这个概念:
<html>
  <head>
    <script type="text/javascript">
var example = {};
example.init = function() {
  var customers = $("customerList");
  customers.onclick = function() {this.style.fontWeight =
"bold"};
}
window.onload = example.init;
    </script>
  </head>
  <body>
    <div id="customerList">
      <div>Jim</div><div>Bob</div><div>Mike</div>
    </div>
  </body>
</html>
在example.init函数中,我们通过Id “customerList”获得对HTML元素的一个引用,然后设置这个HTML元素的onclick属性为一个匿名函数。和所有闭包一样,这个匿名函数捕获example.init函数的局部作用域(local scope),这个作用域包含一个customers变量,这个变量指向该匿名函数当前被附加到的同一个HTML元素,因此,产生了一个循环引用。就循环应用本身而言并不是一个问题,但是如果在页面重新加载之前不销毁它就会产生问题,因为这会消耗内存,因为IE的垃圾收集规则并不能对DOM对象与JavaScript之间的循环引用进行处理。这个问题在IE 6 和IE 7里都存在,所以我们需要一种避开问题的解决方案。我们需要采用一种通用且不唐突的方式来处理这个问题。尽管处理这个问题可能看上去很麻烦,尽管事实上它只是JavaScript带来的问题,但是这个内存泄露在复杂的应用里实际上往往会变得难以控制。尤其是企业应用,一次业务操作经常需要很长的时间,所以即使是很小的内存泄露都会开始累积并且极大地影响性能。为了处理这个问题,推荐的方法是保持对所有附加有事件处理函数的HTML元素的追踪,并且在随后Web页面卸载时将事件处理函数从HTML元素上分离。
为了帮助缓解事件管理所带来的麻烦,我们遵循单件模式创建了一个事件对象,事件可以通过它在跨浏览器的环境里被附加到HTML元素上或是从HTML元素分离。我们并不是要在事件管理器类上麻烦地创建一个正式的单件getInstance方法,而是利用JavaScript的特性创建一个唯一的EventManager对象,这个对象存在于Web页面的存活期间。事件管理的一般做法是在JavaScript里保持对所有被依附事件的追踪,对给定元素的所有特定事件只附加一个单独的事件处理函数,不再需要明确地将每个事件处理函数都附加到HTML元素上。这个单独的事件处理函数是EventManager对象上的一个方法,它负责把具体的事件委托到我们手动管理的每个事件处理函数。
这种处理事件的方法有几个重要的优点。尽管处理IE垃圾收集的问题是我们的事件管理策略中最主要的目标,但是它还有其他几个重要目标,这种处理事件的方法将有助于我们实现以下目标:
l 以一种跨浏览器的方式附加事件处理函数;
l 支持事件捕获(event capturing);
l 提供对全局Event对象的访问;
l 提供对触发事件的元素的访问;
l 提供对事件处理所在元素的访问;
l 防止IE内存泄露。
如果我们不在entAJAX的命名空间里使用静态方法和属性,而是以一个“适当的”单件类来封装对事件的管理,那么这个EventManager类的定义将如图3-7所示 。

EventManager
 

图3-7 事件管理类EventManager
这个类中最重要的部分是私有的m_elements数组,它包含对所有注册了事件处理函数的HTML元素的引用。我们通过这个数组存储了我们手动管理的所有元素,元素关联的事件和事件处理函数的信息,而不是把每一个事件处理函数都明确地附加到元素上。我们在附加事件的过程中给HTML元素设置了一个专门的expando属性。这个属性包含了手动管理事件所需要的所有信息。下面是事件附加以及通知的JavaScript代码:
// 单件对象
entAJAX.EventMangager = {};
entAJAX.EventManager.attachEvent =
  function(element, type, handler, context, capture)
{
  // 递增我们的唯一性id以保持唯一性
  var handlerGuid = this.handlerId++;
  var elementGuid = this.elementId++;
 
  // 检查处理函数是否已经拥有一个唯一性标识符
  if (typeof handler.ea_guid != "undefined")
    handlerGuid = handler.ea_guid;
  else
    handler.ea_guid = handlerGuid;
  // 检查HTML元素的expando属性ea_guid是否已定义
  if (typeof element.ea_guid == "undefined")
  {
    element.ea_guid = elementGuid;
    // 把元素添加到私有的元素数组
    this.m_elements[elementGuid] = element;
  }
  // expando属性ea_events包含所有为该元素注册的事件
  if (typeof element.ea_events == "undefined")
    element.ea_events = {};
  // 检查expando属性ea_events里是否已经存在该事件类型
  if (element.ea_events[type] == null)
  {
    element.ea_events[type] = {};
    // 检查浏览器是IE还是遵循W3C标准的浏览器
    if (element.addEventListener)
    {
    // W3C 事件附加
    element.addEventListener(type, function () {
      entAJAX.EventManager.m_notify.call(this, arguments[0],
element)
      }, capture);
    }
    else if (element.attachEvent)
    {
      // IE 事件附加
      element['ea_event_'+type] = function () {
      entAJAX.EventManager.m_notify.call(this, window.event,
element);
      };
      // 为了避免内存泄露,未来需要分离事件处理函数!
      element.attachEvent('on'+type,
element['ea_event_'+type]);
      // 支持事件捕捉和冒泡
      if (capture) element.setCapture(true);
    }
  }
  //将处理函数加入列表,跟踪处理函数和上下文
  element.ea_events[type][handlerGuid] = {
    'handler': handler,
    'context': context};
}
这里有很多代码需要进一步消化。正如我们已经讨论过的,事件管理的目的是附加一个事件处理函数到HTML元素上,它使用特定于浏览器的element.attachEvent()或者element. addEventListener()方法,并且仅当某个事件类型首次使用时才调用这些方法。在这些实例中,当事件处理函数第一次为某一事件类型被附加到一个元素上时,实际指定的处理函数是静态的entAJAX.EventManager.m_notify()方法,这个方法负责实际执行我们在m_elements数组里手动管理的所有真实的事件处理函数。m_notify()方法如下所示:
entAJAX.EventManager.m_notify = function(eventObj, element)
{
  // 设置全局的entAJAX.Event 对象为事件对象
  entAJAX.Event = eventObj;
  //扩展对象的属性,保持对处理事件元素的追踪
  entAJAX.Event.handlerElement = element;
  if (!entAJAX.IE)
  {
    entAJAX.Event.srcElement = eventObj.target;
    entAJAX.Event.fromElement = eventObj.relatedTarget;
    entAJAX.Event.toElement = eventObj.relatedTarget;
  }
  for (var handlerGuid in element.ea_events[e.type])
  {
    var handler = element.ea_events[e.type][handlerGuid];
    if (typeof handler.context == "object")
      //在JavaScript对象的上下文里调用处理函数
      handler.call(handler.context, eventObj, element);
    else
      //在事件被触发的元素的上下文里调用处理函数
      handler.call(element, eventObj, element);
  }
}
当事件基于终端用户的某些交互实际触发时,entAJAX.EventManager.m_notify()方法将处理这个事件并且随后将事件委托到所有对该事件感兴趣的处理函数。因为m_notify()方法编制被附加的处理函数的调用,所以我们可以确定事件处理函数的调用顺序,多数浏览器中是无法保证这一点的,并且我们还可以任意指定参数。这种间接性使得我们可以回避各个浏览器在事件处理上的差异,并且给了我们更多没有要求的灵活性。所以,例如,如果想为同一个HTML元素附加两个事件处理函数,当该元素被点击时两个处理函数都将被触发,那么entAJAX.Event- Manager.m_notify()方法将作为onclick事件的处理函数被附加到这个元素上,并且我们想要附加的各个事件处理函数将被存储到HTML元素自身的ea_events['onclick'] expando属性中。随后,当终端用户点击该元素时,和上文中在m_notify()方法里看到的那样,m_notify()方法将调用定义在HTML元素的ea_events['onclick'] expando属性里的各个处理函数。如果我们想在两个不同的onclick事件处理函数附加后序列化该HTML元素,那么代码如下所示:
<div
  onclick="entAJAX.EventManager.m_notify(event, this)"
  ea_guid="7"
  ea_events="{'click':
                  {'0':{'handler':Function, 'context':Object},
                   '1':{'handler':Function, 'context':Object}},
                   'mouseover':{...}
                 }" ... >
</div>
现在,我们可以考察一下这种附加事件的方式是如何解决我们的六个主要问题的。为了支持以一种跨浏览器友好的方式附加事件处理函数(要点1),我们可以在entAJAX.EventManager对象的静态attachEvent()和detachEvent()方法里封装一个对浏览器类型的检查,如图3-8所示,我们有效地使用了外观模式(Façade pattern)。在EventManager内部,我们只需要在IE里使用attachEvent()方法,在其他浏览器,例如Firefox、Safari和Opera里使用addEventListener()方法。(其实,Opera对这两种方法都支持。)
图3-8 有效地使用外观模式
正如在第2章讨论过的,事件捕获(event capture)尽管在不同的浏览器里运行的方式各不相同,但是它在IE和W3C事件模型里都是可用的。这里,我们再次使用外观模式来屏蔽事件捕获的具体细节,在附加方法里提供一个布尔参数,如果该参数为true则支持捕获,默认false则不支持捕获。事件捕获在某些情况下还是有用的。我们在IE里使用setCapture()函数,在其他Web浏览器里使用addEventListener()方法里对定义捕获事件的支持,通过这两种方法我们也支持了要点2(支持事件捕获)。尽管事件捕获是一项有用的技术以及完全被误解的事件处理技术,但是由于IE和Firefox浏览器之间实现的不同,它呈现出来的效率非常低下。
IE通过全局的window.Event对象提供对事件信息的访问,尽管这种实现不是W3C事件规范的组成部分。正如我们已经实现的,通过拦截和委托事件,我们可以创建自己的全局事件对象,它本质上与IE里的全局事件对象是相同的。此外,在其他浏览器中,我们将事件对象作为第一个参数传递到事件处理函数的方法中,这也是W3C DOM事件规范所定义的访问事件对象的方式。不管开发者是习惯于在IE里开发还是在基于遵循W3C规范的浏览器里开发,以这种方式处理事件对象意味着它对所有的开发者来说都是熟悉的,同时,我们也避免了和其他AJAX库的冲突,例如微软的Atlas(微软公司的AJAX解决方案),在Atlas中,事件模型在IE和Firefox里都使用window.event属性。尽管事件对象确实可以解决很多问题,但是我们还没有详细分析事件对象的方法和属性在IE和遵循W3C规范的模型之间的差别。
和事件对象紧密相关的是如何访问事件的各种属性。其中一个重要对象,也是我们需要访问的对象是触发事件的元素(要点4)。在IE中可以通过访问Event对象的srcElement属性,在其他许多浏览器中可以通过访问事件对象的target属性,可以很容易地获得这个元素。这些对象属性上的差别一般都存在两种方法进行处理,我们可以从中选出一种方法。尽管已经为事件管理使用了外观模式,但是我们也可以对Gecko和基于KHTML/WebKit的浏览器中的本地对象进行扩展,让这些浏览器支持直接在HTMLElement 类型的对象上调用IE中的attachEvent()方法。为了在浏览器中扩展Event对象使之支持getter方法和setter方法,我们可以采用以下代码在类的原型属性上使用专门的__defineGetter__方法。
Event.prototype.__defineGetter__("srcElement", function () {
    var node = this.target;
    while (node.nodeType != 1) node = node.parentNode;
return node;
}
对于开发者而言,当创建一个跨浏览器的Event对象时,存在有其他可供利用的选择方案。其中包括扩展本地对象给对象添加额外的属性,或者也可以将本地Event对象的所有属性复制到一个完整的自定义对象中。
尽管获得对触发事件的DOM元素的一个引用非常简单,但是当在IE里使用本地的attachEvent()方法时,想要访问处理事件的元素,即事件处理函数所依附的DOM元素,却是不可能实现的。为了访问处理事件的DOM元素(要点5),这个元素在许多情况下可以为我们提供很多便利,我们可以将对这个元素的引用作为第二个参数传入到事件处理函数的方法中。作为另外一种选择方案,我们也可以给Event对象添加一个handlerElement属性,这个属性是对元素的一个引用。关于IE的事件模型,常会遭到抱怨是事件模型没有提供对处理事件元素的引用。相反,在Firefox中,开发者通常可以通过this关键字在处理函数中访问这个处理事件的元素,即,处理函数在该HTML元素的上下文里被执行。然而,这种方式也不总是合适的,特别是当工作在一个面向对象的环境里时,处理函数通常不是一个全局函数,相反,它是一个对象的方法,这个方法将在它所属对象的上下文里被执行。幸好,我们的事件管理方法也对这些细节进行了封装。
剩下的最后一件事就是要确保事件附加不会造成任何内存泄露(要点6),实现这一点需要进行一些编码工作。如果创建一个简单的Web页面,并使用entAJAX.EventManager. attachEvent()方法把事件处理函数连接到HTML元素,由于运行期在HTML DOM和JavaScript之间创建了循环引用(通过闭包和expando属性),我们在IE里仍然会像筛子一样泄露内存。为了防止让人头疼的内存泄露问题,我们需要在Web页面被卸载之前确保将事件处理函数从HTML元素上分离出去。当然,一个负责的开发者可能会在他的应用卸载时总是编写代码来分离事件或取消对事件的注册,但这种情况非常少见。通过使用我们自定义的静态方法附加事件,这些附加了事件处理函数的元素被从内部进行管理,当Web页面unload事件被触发时,所有的事件处理函数会自动被分离。
实际上事件的分离相当简单,这个分离在私有的m_detach()方法里执行。当然,在这种情况下使用私有这个术语相当不严谨。我们在代码里将该方法注释为私有并使用“m_”前缀来命名它,“m_”前缀通常用于指出这是一个私有成员。当事件处理函数被分离时,我们需要做一些工作来确保当(且仅当)元素特定事件类型的最后一个事件处理函数被从元素移除时,为相应的Web浏览器使用本地的detachEvent() 或removeEventListener()方法将entAJAX.Event Manager.m_notify()处理函数也从HTML元素里分离。深入思考时,你可能会注意到,我们会在onunload事件上遇到问题,因为除了这个重要的用来清除所有附加事件的onunload事件处理函数之外,开发者也可能想要注册他们自己的onunload事件。所有通过我们的事件接口被注册的onunload事件在被调用之前就已经像所有其他的事件一样依次被分离和垃圾收集了——本质上是我们在扔掉不要的东西的时候把宝贵的东西也一起无意给扔掉了。为了避开这种情况,我们将onunload事件与其他事件分开管理,并在entAJAX.EventManager.m_unload数组里保持对它们的追踪。window.onunload事件像下面这样设置用来触发对所有被管理的事件的分离:
window.onunload = entAJAX.EventManager.detachAll();
当然,以这种方式设置onunload事件可能具有破坏性,但是我们可以避开它。为了防止onunload事件上的冲突,总为所有自定义事件使用onbeforeunload事件也是一个不错的主意,这个事件是微软引入的另一个事实上的标准。
4 面向方面的JavaScript
我们利用这个机会插入一个如何使用面向方面编程(AOP,Aspect-Oriented Programming)的例子,通过这个例子设置window.onunload事件,且不会破坏所有其他它已经引用的事件处理函数。当讨论JavaScript框架时,如何处理页面或者应用范围内的事件,例如onload和onunload,可能会变得非常棘手。我们已经讨论过,当处理这些事件时,我们需要当一个好邻居。如果你期望你的代码和来自不同开发团队或者组件提供商的其他代码能够共同运行在一个Web页面中,那么经典的原则是尽可能保持各自代码的功能。与此相反,一位使用你提供代码的最终开发者很可能也非常大意,他开发的方式会具有破坏性,这样就会给你带来很大的麻烦。onload事件就是其中的主要关注点之一。正如本章前面讨论过的,对于启动AJAX应用而言,使用onload事件可以说是极为重要的。当然,如果组件依赖于window.onload,我们应该意识到不仅其他的组件或者框架将会使用到这个事件,甚至连最终开发者也可能会使用到该事件来运行他们自己的JavaScript代码,作为一种选择方案,他们也会明确地使用<body>元素的onload属性。首先,如果要使用这个window.onload事件,我们需要以一种非破坏性的方式设置它。在一个mashup的环境中,围绕着onload事件往往会存在很多问题,所以需要考察一些我们已经讨论过的更加高级的启动技术。
我们已经分析了在需要保护事件之前所要引用的所有功能,如何给window.onload事件增加事件处理函数,实际上,我们所采用的正是AOP的一种形式。AOP背后的想法是我们可以在运行期以一种和装饰模式相似的方式动态地给对象增加功能,然而,不需要很多的预先设计,鉴于JavaScript语言的特性,实现AOP非常容易。我们在entAJAX命名空间里创建一个静态方法,这个方法采用两个方法作为参数,每次在第一个方法被调用之后都会调用第二个方法。在AOP里,这被称为向“连接点”中添加“通知”。因为JavaScript是动态的,所以我们并不需要在应用设计期间就给通知明确地定义连接点,而是我们可以以一种更加特别的方式添加连接点。例如,给window.onload指派一个函数,在这种情况下,我们想要采用这个函数并且确保它能够像其他所有我们想附加到这个事件的函数一样得到调用。为了实现这一点,我们利用JavaScript的闭包并再次使用关联数组,代码如下所示:
entAJAX.attachAfter = function(oContext, oMember, oAContext,
oAMember)
{
  var fFunc = oContext[oMember] || function() {};
  oContext[oMember] = function() {
    fFunc.apply(oContext || this, arguments);
    oAContext[oAMember].apply(oAContext, arguments);
  }
}
entAJAX.attachAfter(window, "onunload", myObj, "myFunc");
因为JavaScript是一种动态语言,所以AOP仅仅是这种语言所特有的能力之一。AOP可以是一个很有用的工具,可以在运行期动态地改变类或者实例的功能,还可以提供其他的很多功能,例如可以更加容易地使用装饰模式,甚至让这个模式在JavaScript中失去了意义。
面向方面编程
面向方面编程是一种程序设计方法,这种方法用于处理存在所谓的横切关注点的场合。特别是这种方法会出现在面向对象的编程中,把代码实现封装到各层的包和类的分组中,例如,当我们需要创建动物的类层级时,这种实现方式就很出色。然而,横切关注点其实是指那些跨越水平范围覆盖垂直分组的类的编程方面。一个典型的应用是日志。我们可能想给两组不同继承分层的类添加一些记录日志的功能,此时,就可以应用面向方面编程横跨类的层级来添加日志功能。
面向方面编程一般需要相当数量的“辅助”代码,但JavaScript能够相对容易地实现该编程。

打印 收藏 关闭