JavaScript 设计模式学习与实践(三)

本文进行迭代器模式和发布订阅模式的总结。这两个模式算是比较经典的模式,甚至已经到了语法本身支持的程度。

迭代器模式

定义

迭代器模式是指提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象的内部表示。迭代器模式可以把迭代的过程从业务逻辑中分离出来,在使用迭代器模式之后,即使不关心对象的内部构造,也可以按顺序访问其中的每个元素。

比如我们定义一个数据结构,内部是用链表构造的一个队列,只需要对外提供一个方法可以顺序遍历这个队列就行,并不需要让外部了解到这个队列用的是怎样的技术去实现的。

内部迭代器和外部迭代器

我们来实现一个迭代器each函数,接受两个参数,第一个位被循环的数组,第二个为循环中每一步后将被触发的回调函数:

1
2
3
4
5
6
7
8
9
var each = function(arr, callback) {
for(var i = 0, l = arr.length; i < l; i++) {
callback.call(arr[i], i, arr[i])
}
}

each([1,2,3], function(index, n)) {
console.log(index, n);
}

这个each函数就属于内部迭代器,each函数的内部已经定义好了迭代规则,完全接手整个迭代过程,外部只需要一次初始调用。

内部迭代器在调用的时候非常方便,外界不关心迭代器内部的实现,跟迭代器的交互仅是一次初始调用,但这也正好是内部迭代器的缺点。

由于内部迭代器的迭代规则已经被提前定义,上面的each函数就无法同时迭代两个数组。

比如现在有个需求,比较两个数组的元素是否完全相同,如果不改写each函数,我们能够入手的地方似乎只剩下each的回调函数了,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var compare = function(arr1, arr2) {
if (arr1.length !== arr2.length) {
throw new Error("arr1 and arr2 is not equal")
}

each(arr1, function(i, n) {
if (n !== arr2[i]) {
throw new Error("arr1 and arr2 is not equal")
}
})

alert("arr1 and arr2 is equal")
}

compare([1,2,3],[1,2,4])

在一些没有闭包的语言中,内部迭代器本身的实现也相当复杂。比如C语言中的内部迭代器,利用函数指针实现的,循环处理的函数都要通过参数的形式明确从外面传递进去。

相比于内部迭代器,外部迭代器则是必须显式地请求迭代下一个元素。

外部迭代器增加了一些调用的复杂度,但是也增加了迭代器的灵活性,我们可以手工控制迭代的过程和顺序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
var Iterator = function(obj) {
var current = 0;

var next = function() {
current++;
}

var isDone = function() {
current >= obj.length;
}

var getCurrent = function() {
return obj[current];
}

return {
next,
isDone,
getCurrent,
}
}

var compare = function(iterator1, iterator2) {
while(!iterator1.isDone() && !iterator2.isDone()) {
if (iterator1.getCurrent() !== iterator2.getCurrent()) {
throw new Error("iterator1 and iterator2 is not equal")
}
iterator1.next();
iterator2.next();
}

if (iterator1.isDone() && iterator2.isDone()) {
alert('iterator1 and iterator2 is equal');
} else {
throw new Error('iterator1 and iterator2 is not equal');
}
}

compare(Iterator([1,2,3]), Iterator([1,2,3]));

中止迭代器

迭代器可以像普通for循环中的break一样,提供一种跳出循环的方法。如:

1
2
3
4
5
6
7
8
9
10
11
12
var each = function(arr, callback) {
for(var i = 0, l = arr.length; i < l; i++) {
if (callback.call(arr[i], i, arr[i]) === false) {
break;
}
}
}

each([1,2,3,4,5,6], function(i, n) {
if (n > 3) return false;
console.log(n);
})

迭代器模式举例

假设有如下代码,可以根据不同的浏览器获取不同的上传组件对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
var getUploadObj = function() {
try {
return new ActiveXObject("TXFINActiveX.FTNUpload");
} catch(e) {
if (supportFlash()) {
var str = "<object type='application/x-shockwave-flash'></object>"
return $(str).appendTo($('body'));
} else {
var str = "<input name='file' type='file'/>"
return $(str).appendTo($('body'))
}
}
}

我们用迭代器模式改造一下上面那段代码,将每种获取上传组件的方法封装为一个函数,然后见这些函数放入一个数组中去迭代,直到有一个可以正确返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
var iteratorUploadObj = function() {
const uploadObjCreators = Array.prototype.slice(arguments);
for(var i = 0; i < uploadObjCreators.length; i++) {
var uploadObj = uploadObjCreators[i]();
if (uploadObj !== false) {
return uploadObj;
}
}
}

var getActiveUploadObj = function() {
try {
return new ActiveXObject("TXFINActiveX.FTNUpload");
} catch(e) {
return false;
}
}

var getFlashUploadObj = function() {
if (supportFlash()) {
var str = "<object type='application/x-shockwave-flash'></object>"
return $(str).appendTo($('body'));
} else {
return false;
}
}

var getFormUploadObj = function() {
var str = "<input name='file' type='file'/>"
return $(str).appendTo($('body'))
}

var uploadObj = iteratorUploadObj(getActiveUploadObj, getFlashUploadObj, getFormUploadObj);

发布订阅模式

发布订阅模式又叫做观察者模式,它定义对象之间的一种一对多的依赖关系,当一个对象的状态发生改变的时候,所有依赖它的对象都会得到通知。

在JavaScript中,我们一般用事件模式来代替发布订阅模式。

发布订阅模式可以广泛应用于异步编程中,这是一种替代传递回调函数的方案。同时,发布订阅模式可以取代对象之间硬编码的通知机制,一个对象不再显式地调用另外一个对象的某个接口。发布订阅模式让两个对象松耦合地联系在一起。

DOM事件

实际上,只要我们曾经在DOM节点上绑定过事件函数,那我们就曾经使用过发布订阅模式:

1
2
3
4
5
document.body.addEventListener('click', function() {
alert(2);
}, false)

document.body.click();

自定义事件

除了DOM事件,我们经常还会实现一些自定义事件,这种依靠自定义事件完成的发布订阅模式可以用于任何JavaScript代码中。

我们看看如何一步步实现发布订阅模式

  • 首先要指定好谁充当发布者
  • 然后给发布者添加一个缓存列表,用于存放回调函数以便通知订阅者
  • 最后发布消息时候,发布者会遍历这个缓存列表,依次触发里面存放的订阅者回调函数

另外,我们还可以往回调函数里填入一些参数,订阅者可以接收这些参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var salesOffices = {}; 

salesOffices.clientList = [];

salesOffices.listen = function(fn) {
this.clientList.push(fn);
}

salesOffices.trigger = function() {
for(const fn of this.clientList) {
fn.apply(this, arguments)
}
}

salesOffices.listen(function(price, squareMeter) {
console.log("A know:", price, squareMeter);
})

salesOffices.listen(function(price, squareMeter) {
console.log("B know:", price, squareMeter);
})

salesOffices.trigger(2000000, 80);
salesOffices.trigger(3000000, 110);

至此,我们实现了一个简单的发布订阅模式,但这里还存在一些问题。我们看到订阅者接收到了发布者发布的每个消息,虽然A只想买88平方米的房子,但是发布者会把110房子的消息也推送给A,这是没有必要的,所以我们可以优化一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
var salesOffices = {}; 

salesOffices.clientList = {};

salesOffices.listen = function(key, fn) {
if (!this.clientList[key]) {
this.clentList[key] = [];
}
this.clentList[key].push(fn);
}

salesOffices.trigger = function() {
const key = Array.prototype.shift.call(arguments);
const fns = this.clentList[key];

if (!fns || fns.length === 0) {
return false;
}
for(const fn of fns) {
fn.apply(this, arguments)
}
}

salesOffices.listen('squareMeter80', function(price) {
console.log("A know:", price);
})

salesOffices.listen('squareMeter110', function(price) {
console.log("B know:", price);
})

salesOffices.trigger('squareMeter80', 2000000);
salesOffices.trigger('squareMeter110', 3000000);

发布订阅的通用实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
var event = {
clientList: {},
listen: function(key, fn) {
if (!this.clientList[key]) {
this.clentList[key] = [];
}
this.clentList[key].push(fn);
},
trigger: function() {
const key = Array.prototype.shift.call(arguments);
const fns = this.clentList[key];

if (!fns || fns.length === 0) {
return false;
}
for(const fn of fns) {
fn.apply(this, arguments)
}
}
}

// 为任何对象安装发布订阅模式
var installEvent = function(obj) {
for(var i in event) {
obj[i] = event[i];
}
}

此时我们可以

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var salesOffices = {}; 

installEvent(salesOffices);

salesOffices.listen('squareMeter80', function(price) {
console.log("A know:", price);
})

salesOffices.listen('squareMeter110', function(price) {
console.log("B know:", price);
})

salesOffices.trigger('squareMeter80', 2000000);
salesOffices.trigger('squareMeter110', 3000000);

当然,我们也可以先发布,再订阅,当暂时没有订阅者时将消息缓存起来,一旦出现订阅者再依次调用订阅者并清空消息