使用 dojo.connect 实现事件驱动设计

探讨 dojo.connect 和 pub/sub 如何使 web 应用程序更易维护

本文探讨如何对事件驱动设计进行松耦合,以及如何植入到基于浏览器的应用程序和 Dojo 的面向对象框架。其结果是有一个工具可以帮助维护代码的模块性。然后使用 dojo.connect 将模块粘合到一起,这是一个功能强大的函数,您不仅可用于 Document Object Model (DOM) 事件,也可用来定制应用程序事件。最后,本文将对 dojo.connect 和 pub/sub 进行比较,包括不同程度的松耦合。

Mike Wilcox, 技术总监, BetterVideo

http://www.ibm.com/developerworks/i/p-mwilcox.jpgMike Wilcox 是位于德克萨斯州 Frisco 市的一个发展迅速的新公司 BetterVideo 的一名技术总监,他主要负责前端设计和在线视频服务。Mike 经常发表关于 Ajax 和其他 web 技术的演讲,还在各种会议上发表演讲,比如 2009 Rich Web Experience 和 2009 Dallas TechFest。他的开源工作是 Dojo Toolkit 中的显示,在其中,他实现了很多多元技术,比如 Multi-File Uploader、音频和视频组件、以及向量的 DojoX Drawing 。



2012 年 1 月 12 日

什么是事件驱动设计?

事件驱动设计 是一个编程范式,其中程序的流程是由事件确定的 — 而不是由一系列函数确定 — 在一个预定义或线性流程中执行。相对一个函数执行周期完成后再调用另一个,或者函数注册一个周期然后等待回调函数被激活,模块 “监听” 周期的完成进度,这通过事件发出信号。

优势

Web 页面本质上是鼠标操作或键盘输入触发的用户界面。因为事件驱动设计已被植入浏览器,将其扩展到 web 应用程序也是顺其自然的。JavaScript 语言使用事件监听程序来检测用户操作和资源加载。您可以使用同一个模式在您的代码中监听自定义事件或操作。这一设计的优势是 JavaScript 模块(或类)作为黑盒子 可以更有效地独立使用;这些是松耦合对象,可以轻松地添加、删除、替换和维护,很少中断或不中断整体程序代码。

缺点

跨浏览器问题使得使用 JavaScript 代码实现一个事件监听系统较为困难。Microsoft® Windows® Internet Explorer 使用一个非标准、非正规实现的事件监听程序,多数情况下不能断开,不能将事件作为一个参数传递,不能提供一个明确的方法来阻止事件。当其他所有浏览器使用标准化的 addListener 系统时,不同浏览器之间,甚至是不同操作系统的相同浏览器之间,生成的事件都不同。

什么是松耦合?

耦合 指的是一个类对于另一个类的了解程度。这并不是说类不能 了解彼此的属性和方法,而是注重它们的知识和对类本身的依赖。如果您有两个紧耦合的类,您就不可能改变一个,而不打乱另一个的实现。

强耦合的一个示例是,Class A 加载一些数据,然后调用 Class B 上的 dataLoaded(),然后从 Class A 获取 Foo 属性。如果 Class B 被删除、Foo 属性重命名,或者 dataLoaded 的函数签名被更改的话,这个系统很容易被破坏。

松耦合的一个示例是,Class A 加载数据,然后激活 onDataLoaded 事件。Class BonDataLoaded 上使用一个事件监听程序,当它被激活时,读取事件对象来获取 Foo 属性。

耦合是相对的;也就是说,如果第二个示例被认为是松散的,Class B 仍然需要了解 Class A 来连接其中的一个事件。全局事件系统被用于获取一个更高级别的松耦合。

什么是 dojo.connect?

dojo.connect 允许事件源是一个规则的 JavaScript 函数或者是一个 DOM 事件。它提供一个统一接口来监听一个应用程序可能遇到的所有类型的事件,尽管是一个单一的、统一的接口。该函数对于那些尝试创建代码来连接、断开以及非标准化 DOM 事件的开发人员是很有价值的。

清单 1 中,代码连接到 myButtonclick 事件。当 click 事件激活时,匿名函数被调用。

清单 1. Simple dojo.connect 示例
dojo.connect(dojo.byId("myButton"), "click", function(evt){
	console.log("The button was clicked: ", evt.target.id);
});

优势

所有库都有一个可以连接到 DOM 事件的函数。dojo.connect 的设置与众不同的是它拥有连接到自定义事件或常规函数的能力,如 清单 2 所示。

清单 2. 一个 dojo.connect 连接到函数的示例
myObject = {
	method: function(){
		//
	}
};
dojo.connect("myObject", "method", function(evt){
	console.log("myObject.method fired");
});

缺点

由于 dojo.connect 旨在处理一切,有时候莫名其妙的错误难以跟踪。此外,生成多个连接和一个一次连接 函数的代码难以处理。最后,从本质上讲,事实是您不得不连接到一个特定对象来监听它,您必须知道对象是第一位的。这可能使代码更为棘手,开发人员都想要避免。

dojo.connect 解决的问题

清单 3 所示,编写您自己的函数进行回调并不是很难。

清单 3. 自定义回调函数示例
myObject = {
	method: function(data, callback){
		// do something asynchronous here...
		setTimeout(function(){
			var value = doSomething(data);
			callback(value);
		});
	}
};
myObject.method(42, function(result){
	console.log("the result: ", result);
})

但是回调可能会受到限制。第二个调用可能先行一步。您可能会发现在您的应用程序中有几个地方都要回调,每一个都需要新代码。这需要大量的重复,几乎没有效率。

示例项目

本小节提供一个简单但紧耦合的应用程序来演示一个事件驱动的设计,然后展示如何使用 dojo.connect 解除类之间的耦合。本文中显示的只是代码的相关部分,完整代码见 下载 小节。

样例应用程序有两个下拉选项。第一个包含美国的各个州,第二个列出选中州的一些城市。第一个列表中的一个州选项可以改变在第二个下拉框中的城市选项列表,如 图 1 所示。

图 1. 含有州和城市下拉选项的样例应用程序
两个选项,第一个修改第二个

清单 4 显示了用于所有示例的基础 Hypertext Markup Language (HTML) 代码。它为一个 dijit.form.Select 加载了必要的 Dojo文件,包括新 Claro 样式表。两个自定义选项在标记中实例化。

清单 4. 示例的基础 HTML 代码
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 5//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html lang="en">
<head>
<title>Tightly Coupled Example</title>
<link href="http://ajax.googleapis.com/ajax/libs/dojo/1.5/dijit/themes/dijit.css"
	rel="stylesheet" />
<link href="http://ajax.googleapis.com/ajax/libs/dojo/1.5/dijit/themes/claro/claro.css"
	rel="stylesheet" />
<script src="http://ajax.googleapis.com/ajax/libs/dojo/1.5/dojo/dojo.xd.js"></script>
<script>
	dojo.require("dijit.form.Select");
</script>
<script>
	dojo.ready(function(){
		// code will go here
		dojo.parser.parse();
	});
</script>

</head>
<body class="claro">
    <h1>Tightly Coupled Example</h1>
	<div dojoType="StateSelect" id="stateSelect"></div>
	<div dojoType="CitySelect" id="citySelect"></div>
</body>
</html>

紧耦合应用程序

什么是 CDN?

CDN 是一个内容分发网络。Google 和 AOL 提供 Dojo 代码和其他 Asynchronous JavaScript + XML (Ajax) 库供您的 web 页面使用。

代码替换注释 code will go here,被放在一个 dojo.ready 内,当所有代码被从 CDN 中加载时激活。当 DOM 准备访问时,它通过 dojo.parser.parse() 完成,这将实例化标记。两个自定义小部件扩展 dijit.form.SelectStateSelect 在第一个示例中使用,执行所有繁重的任务,而 CitySelect 用于第二个示例,但是还不能执行任何具体任务。参见 清单 5

清单 5. 紧耦合代码
dojo.declare("StateSelect", [dijit.form.Select], {
	postCreate: function(){
		this.inherited(arguments);
		dojo.xhrGet({
			url:"geography.json",
			handleAs:"json",
			load: dojo.hitch(this, function(data){
				this.data = data;
				this.addOption(this.data.items);
			})
		});
	},
	onChange: function(value){
		for(var i = 0; i < this.data.items.length; i++){
			if(this.data.items[i].value == value){
				var menu = dijit.byId("citySelect").getOptions();
				dijit.byId("citySelect").removeOption(menu);
				var newMenu = this.data.items[i].children;
				dijit.byId("citySelect").addOption(newMenu);
			}
		}
	}
});

dojo.declare("CitySelect", [dijit.form.Select], {

});

该示例是紧耦合的。当 StateSelect 被创建时,Dijit 系统激活 postCreate。在那时,geography.json 文件被使用 dojo.xhrGet 加载,下拉菜单在回调机制(load)内创建。

当从下拉菜单选中一个新菜单项时,Dijit 系统激活 onChange,然后传递那个选项的值。代码对数据执行循环操作直至发现对应数据项 — 这包含 CitySelect 的子条目。CitySelect 通过 dijit.byId 找到;其当前菜单删除,用一个新菜单来替换。

什么使这个代码成为紧耦合的?创建可维护代码的理想方法是减少其负面效应;您可以通过计算方法数、以及将其与采用的操作数相比较来估算负面效应。该示例演示了 2 个方法(postCreateonChange)和 4 个操作:

  1. 初始化小部件(postCreate
  2. 处理选项(onChange
  3. 加载数据
  4. 处理数据加载

此代码为紧耦合的另一个原因是 StateSelect 随意改动了 CitySelect 的方法。尽管这些方法都是公共方法,可以从外部访问,但这些访问可以被删除或减少 来实现松耦合。目标是创建一个黑盒子 — 一个可以自给自足的对象。语义也可使代码更易维护:加载数据发生在 loadData 方法中,处理加载数据发生在 onDataLoaded 方法中。这个应用程序相当小。如果数据是其 100 倍之大且使用该样式,寻找其中数据的加载和解析将会非常困难。如果您只是突然遇到一个 CitySelect 问题,您可能会惊讶地发现这里没有代码 — 至少在这个子类中没有。

松耦合应用程序

清单 6 显示了改编过的应用程序,使用恰当的语义方法且负面效应较少。

清单 6. 松耦合代码
dojo.declare("StateSelect", [dijit.form.Select], {

	postCreate: function(){
		this.inherited(arguments);
		this.loadData();
	},
	loadData: function(){
		dojo.xhrGet({
			url:"geography.json",
			handleAs:"json",
			load: dojo.hitch(this, "onDataLoaded")
		});
	},
	onDataLoaded: function(data){
		this.data = data;
		this.addOption(this.data.items);
	},
	onChange: function(value){
		// stub to connect to
	}
});

dojo.declare("CitySelect", [dijit.form.Select], {
	postCreate: function(){
		this.inherited(arguments);
		this.stateSelect = dijit.byId("stateSelect");
		dojo.connect(this.stateSelect, "onChange", this, "onStateChange");
		dojo.connect(this.stateSelect, "onDataLoaded", this, "onDataLoaded");
	},
	onDataLoaded: function(data){
		this.data = data;
		this.dataMap = {};
		for(var i = 0; i < this.data.items.length; i++){
			this.dataMap[this.data.items[i].value] = this.data.items[i];
		}
	},
	onStateChange: function(value){
		this.removeOption(this.getOptions());
		var item = this.dataMap[value];
		var menu = item.children;
		this.addOption(menu);
	}
});

通过分离功能,CitySelect 现在有了可以附加的方法,使用 dojo.connect。当 onStateChange 事件被激活时,CitySelect 现在可以自行处理并修改其菜单选项。CitySelect 现在也可以维护其自身对数据的应用。

Hash 映射提高性能

您可能会注意到清单 6 中使用的创建的 dataMap 属性。一个性能技巧是为您的数据构建一个 hash 映射,这样您就可以立刻找到您的数据条目,而不需要遍历数组。

如果您对应用程序重写的第一反应是对其所包含的代码之多而感到惊讶,记住:在一个小应用程序中,大小差异明显,但是随着应用程序的增长,您将会发现代码重复越来越少。这意味着代码更少,即使代码的实际大小偏大,为了更好地维护进行折中也是值得的。

扩展 dojo.connect

您不能使用 dojo.connect 只传递一个节点 ID 作为第一个参数,原因是您可以传递 2 个参数到 dojo.connect,因为字符串表示两个全局函数,如 清单 7 所示。

清单 7. 连接两个全局函数
foo = function(){}
bar = function(){
	console.log("bar");
}
dojo.connect("foo","bar");
foo(); // outouts "bar"

有了这个功能,您不能假定,如果第一个参数是一个字符串那么它只是一个 DOM ID,因为传统的假设是一个函数名。但 JavaScript 语言的巧妙之处就在于您不会被锁定在 Dojo 的任何默认功能上。

接受一个 DOM ID

清单 8 展示了重写 dojo.connect 非常简单,通过为它指定另一个名称,添加您自己的条件,然后传递所有的参数到新命名的 dojo._extconnect

清单 8. 添加 ID 功能
dojo._extconnect = dojo.connect;

dojo.connect = function(source, event, object, method, once){
	source = typeof(source)=="string" ? dojo.byId(source) : source;
	if(!source) throw new Error("Bad source passed to dojo.connect:", source);
	dojo._extconnect.apply(dojo, arguments);
}

myObject = {
	onMouseAction: function(evt){
		console.log("mouse action:", evt.type);
	}
};
dojo.ready(function(){
	dojo.connect("btn", "click", myObject, "onMouseAction");
});

// HTML:
<button id="btn">Mouse Actions</button>

现在您可以传递 "btn" 而不是 dojo.byId("btn") 了。注意我添加了一行来检测一个 source,如果没有发现的话将会出现一个错误消息。依我之见,这可能比让 Dojo 假设 “null equals window” 有用得多。

当然,添加这行是可以选择的。这一扩展和所有的扩展抑制了某些默认功能,在这种情况下,您不能传递 2 个字符串作为全局函数。我个人认为所有这些扩展大于很少使用的默认行为。

多个连接

您可能曾遇到过以某种形式重复连接到 DOM 事件的代码,如 清单 9 所示。

清单 9. 必要的重复使用连接
dojo.connect(dojo.byId("btn"), "click", myObject, "onMouseAction");
dojo.connect(dojo.byId("btn"), "mouseover", myObject, "onMouseAction");
dojo.connect(dojo.byId("btn"), "mousedown", myObject, "onMouseAction");
dojo.connect(dojo.byId("btn"), "mouseout", myObject, "onMouseAction");

由于 connect 已经被修改,您可以进一步修改来减少这类重复模式。测试是否第二个参数是一个字符串,或者是一个多次连接资源的数组,如 清单 10 所示。

清单 10. 多次连接代码
dojo._extconnect = dojo.connect;

dojo.connect = function(source, event, object, method, once){
	source = typeof(source)=="string" ? dojo.byId(source) : source;
	if(!source) throw new Error("Bad source passed to dojo.connect:", source);
	if(dojo.isArray(event)){
		dojo.forEach(event, function(e){
			dojo.connect(source, e, object, method, once);
		});
	}else{
		dojo._extconnect.apply(dojo, arguments);
	}
}

myObject = {
	onMouseAction: function(evt){
		console.log("mouse action:", evt.type);
	}
};

dojo.ready(function(){
    dojo.connect("btn", ["click", "mousedown", "mouseover"], myObject, "onMouseAction");
});

此时,第二个参数中的所有事件都被传递给此方法。如果您要处理大量连接将会很方便。(注意,这个示例不支持断开事件连接,您可以通过扩展 dojo.disconnect 和检查参数类型来实现。)

一次连接

一个常见自定义扩展是连接到一个事件,一旦事件激活立即断开,在应用程序生命周期中两次激活或者多次激活对于定制事件而言是很有用的。一次连接代码如 清单 11 所示。

清单 11. 一次连接代码
dojo._extconnect = dojo.connect;

dojo.connect = function(source, event, object, method, once){
	source = typeof(source)=="string" ? dojo.byId(source) : source;
	if(!source) throw new Error("Bad source passed to dojo.connect:", source);
	if(once){
		var callback = dojo.hitch(object, method);
		var handle = dojo._extconnect(source, event, function(){
			callback.apply(object, arguments);
			dojo.disconnect(handle);
		});
		return handle;
	}else{
		return dojo._extconnect.apply(dojo, arguments);
	}
}

myObject = {
	onMouseAction: function(evt){
		console.log("mouse action:", evt.type);
	}
};

dojo.ready(function(){
	dojo.connect("btn", "click", myObject, "onMouseAction", true);
});

代码免责声明

注意,本文中的示例用于演示扩展 dojo.connect,但并没有最终确定的生产环境就绪代码的能力。

因为 true 被作为第 5 个参数传递,连接方法只激活一次。第二次单击按钮无效。注意 once 作为第 5 个参数用来重写 don't fix 的默认功能,这是一个我从未使用过的功能。

您可以将这些功能封装到一个主函数中,然后添加其他功能,比如抑制事件激活,这用于连接来调整窗口大小。

Dojo pub/sub

Dojo pub/sub 系统遵循计算机科学 pub/sub 模式,这是保存在主题注册表中的一个简单的全局回调集合。一个简单的 pub/sub 代码示例如 清单 12 所示。

清单 12. 简单的 pub/sub 代码
dojo.subscribe("data/event/load", function(data){
	console.log("data loaded: ", data)
});
dojo.publish("data/event/load", [{answer:42}]);

因为在 Dojo 名称空间中,集合是全局的,您可以编写耦合非常松散的代码。换句话说,松耦合代码是一柄双刃剑;如果 pub/sub 被滥用,您的应用程序将可能拆散,难以维护。记住松耦合不意味着缺乏结构。此外,要意识到如果您有大量彼此相互依赖的 pub/subs,则主题可以无序激活。如果一个主题在您订阅之前被激活或者 “预激活”,您可能会忽略它。

扩展 Dojo pub/sub

您可以通过扩展 Dojo 的主题系统修复预激活主题,来针对忽略的主题处理一个 backlog;这意味着一个发布可被首先激活,随后当它被注册时,订阅将仍然接收主题,如 清单 13 所示。

清单 13. 用于扩展 Dojo pub/sub 的代码
(function(){
	var topics = {};
	var topicsHandles = {};
	var backlog = {};
	var uid = 0;
	dojo.subscribe = function(topic, object, method){
		var callback = dojo.hitch(object, method);
		if(!topics[topic]) topics[topic] = {};
		var handle = "topic_"+uid++;
		topics[topic][handle] = callback;
		topicsHandles[handle] = topic;
		if(backlog[topic] !== undefined) callback(backlog[topic]);
		return handle;
	}
	dojo.unsubscribe = function(handle){
		delete topics[topicsHandles[handle]][handle];
	}
	dojo.publish = function(topic, data){
		backlog[topic] = data;
		if(topics[topic]){
			for(var nm in topics[topic]){
				topics[topic][nm](data);
			}
		}
	};
})();

// firing our publish with nothing subscribed yet:
dojo.publish("data/event/load", {answer:42});
// act of subscribing will fire topic:
var handle = dojo.subscribe("data/event/load", function(data){
	console.log("data loaded: ", data.answer); // fires right away!
});
// test that a subsequent topic still fires:
dojo.publish("data/event/load", {answer:24});
// unsubscribe from the topic:
dojo.unsubscribe(handle);
// should not fire:
dojo.publish("data/event/load", {answer:99});

不要再次错过一个主题!不要被代码的数量所吓倒;它拥有可以取消订阅的能力(这解释为何使用所有 hash 映射来跟踪主题和句柄)。这不是一个扩展,而是现有方法的一个完整重写,因为 Dojo 的 pub/sub 系统太小了。

耦合更为松散的应用程序

现在您可以再次访问应用程序,并使其成为更松散的耦合。要查看当前结构潜在的问题,可更改小部件次序并查看无序加载的效果,如 清单 14 所示。

清单 14. 无序小部件
<div dojoType="CitySelect" id="citySelect"></div>
<div dojoType="StateSelect" id="stateSelect"></div>

清单 14 展示了这样一种情况:Dojo 创建 CitySelect,接着是 CitySelectpostCreate 激活;这调用 dijit.byId("stateSelect"),结果还是失败了,是因为 StateSelect 目前尚不存在。如果重新整理小部件会破坏代码,显然,耦合依然是紧密的。

通过使用 pub/sub 代替 dojo.connect,您可以删除小部件彼此相互了解的需求。通过实现扩展的 pub/sub,您不需要担心事件的创建次序或者事件开始激活的时间。清单 15 显示了这个最终的松耦合应用程序代码。扩展的 pub/sub 被使用但是在这没有显示。同样,可以在 下载 部分获取所有代码。

清单 15.更为松散的耦合代码
dojo.declare("StateSelect", [dijit.form.Select], {

	postCreate: function(){
		this.inherited(arguments);
		this.loadData();
	},
	loadData: function(){
		dojo.xhrGet({
			url:"geography.json",
			handleAs:"json",
			load: dojo.hitch(this, "onDataLoaded")
		});
	},
	onDataLoaded: function(data){
		this.data = data;
		this.addOption(this.data.items);
		dojo.publish("data/event/loaded", data);
	},
	onChange: function(value){
		dojo.publish("state/change", value);
	}
});

dojo.declare("CitySelect", [dijit.form.Select], {
	postCreate: function(){
		this.inherited(arguments);
		dojo.subscribe("state/change", this, "onStateChange");
		dojo.subscribe("data/event/loaded", this, "onDataLoaded");
	},
	onDataLoaded: function(data){
		this.data = data;
		this.dataMap = {};
		for(var i = 0;i < this.data.items.length; i++){
			this.dataMap[this.data.items[i].value] = this.data.items[i];
		}
	},
	onStateChange: function(value){
		this.removeOption(this.getOptions());
		var item = this.dataMap[value];
		var menu = item.children;
		this.addOption(menu);
	}
});

// HTML - "backwards" widgets still work!
<div dojoType="CitySelect" id="citySelect"></div>
<div dojoType="StateSelect" id="stateSelect"></div>

结束语

JavaScript 已经成为一种基于事件的语言,本文介绍了在 web 应用程序中包含基于事件的模式来伴随它。Dojo 使用 dojo.connect 和 pub/sub 提供功能强大的工具,您可以进行修改来确保您的应用程序更为高效,您可以使用这些工具来创建不仅仅是基于事件的、而且还是松耦合的代码,从而最大限度实现可维护性。


下载

描述名字大小
本文使用的所有示例EventDrivenDesignExamples.zip7KB

参考资料

学习

获得产品和技术

讨论

条评论

developerWorks: 登录

标有星(*)号的字段是必填字段。


需要一个 IBM ID?
忘记 IBM ID?


忘记密码?
更改您的密码

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件

 


在您首次登录 developerWorks 时,会为您创建一份个人概要。您的个人概要中的信息(您的姓名、国家/地区,以及公司名称)是公开显示的,而且会随着您发布的任何内容一起显示,除非您选择隐藏您的公司名称。您可以随时更新您的 IBM 帐户。

所有提交的信息确保安全。

选择您的昵称



当您初次登录到 developerWorks 时,将会为您创建一份概要信息,您需要指定一个昵称。您的昵称将和您在 developerWorks 发布的内容显示在一起。

昵称长度在 3 至 31 个字符之间。 您的昵称在 developerWorks 社区中必须是唯一的,并且出于隐私保护的原因,不能是您的电子邮件地址。

标有星(*)号的字段是必填字段。

(昵称长度在 3 至 31 个字符之间)

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件.

 


所有提交的信息确保安全。


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Web development
ArticleID=785124
ArticleTitle=使用 dojo.connect 实现事件驱动设计
publish-date=01122012