02 设计模式(上)

本篇是《JavaScript 设计模式与开发实践》第二部分读书笔记,总结前 7 种设计模式:单例模式、策略模式、代理模式、迭代器模式、发布-订阅模式、命令模式、组合模式。

单例模式

单例模式的定义是:保证一个类仅有一个实例,并提供一个访问它的全局访问点。单例模式常见如线程池、全局缓存、浏览器中的 window 对象等。

代理单例

通过引入代理类的方式来管理单例逻辑:

var CreateDiv = function(html) {
this.html = html
this.init()
}
CreateDiv.prototype.init = function() {
var div = document.createElement('div')
div.innerHTML = this.html
document.body.appendChild(div)
}
var ProxySingletonCreateDiv = (function() {
var instance
return function(html) {
if (!instance) {
instance = new CreateDiv(html)
}
return instance
}
})()
var a = new ProxySingletonCreateDiv('sven1')
var b = new ProxySingletonCreateDiv('sven2')
alert(a === b) // true

惰性单例

惰性单例指的是在需要的时候才创建对象实例。可以把管理单例的逻辑从原来的代码中抽离出来,封装在 getSingle 函数内部,创建对象的方法 fn 被当成参数动态传入 getSingle 函数。

下面以创建登录悬浮窗为例:

var getSingle = function(fn) {
var result
return function() {
return result || (result = fn.apply(this, arguments))
}
}
var createLoginLayer = function() {
var div = document.createElement('div')
div.innerHTML = '我是登录浮窗'
div.style.display = 'none'
document.body.appendChild(div)
return div
}
var createSingleLoginLayer = getSingle(createLoginLayer)
document.getElementById('loginBtn').onclick = function() {
var loginLayer = createSingleLoginLayer()
loginLayer.style.display = 'block'
}

比较上面的代理单例,可以发现只是将立即执行函数表达式提取出单独函数 getSingle,其余毫无二致。

策略模式

策略模式的定义是:定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换。

一个基于策略模式的程序至少由两部分组成。第一个部分是一组策略类,策略类封装了具体的算法,并负责具体的计算过程。第二个部分是环境类 Context,用来接受客户的请求,随后把请求委托给某一个策略类。要做到这点,说明 Context 中要维持对某个策略对象的引用。

计算奖金

var strategies = {
S: function(salary) {
return salary * 4
},
A: function(salary) {
return salary * 3
},
B: function(salary) {
return salary * 2
}
}
var calculateBonus = function(level, salary) {
return strategies[level](salary)
}
calculateBonus('S', 20000)

通过使用策略模式重构代码,消除了程序中大片的条件分支语句。通过替换 Context 中当前保存的策略对象,便能执行不同的算法来得到想要的结果,这也是多态在策略模式中的体现。

缓动动画

var tween = {
linear: function(t, b, c, d) {
return (c * t) / d + b
},
easeIn: function(t, b, c, d) {
return c * (t /= d) * t + b
},
strongEaseIn: function(t, b, c, d) {
return c * (t /= d) * t * t * t * t + b
},
strongEaseOut: function(t, b, c, d) {
return c * ((t = t / d - 1) * t * t * t * t + 1) + b
},
sineaseIn: function(t, b, c, d) {
return c * (t /= d) * t * t + b
},
sineaseOut: function(t, b, c, d) {
return c * ((t = t / d - 1) * t * t + 1) + b
}
}
var Animate = function(dom) {
this.dom = dom // 进行运动的 dom 节点
this.startTime = 0 // 动画开始时间
this.startPos = 0 // 动画开始时,dom 的初始位置
this.endPos = 0 // 动画结束时,dom 的目标位置
this.propertyName = null // dom 节点需要被改变的 css 属性名
this.easing = null // 缓动算法
this.duration = null // 动画持续时间
}
Animate.prototype.start = function(propertyName, endPos, duration, easing) {
this.startTime = +new Date() // 动画启动时间
this.startPos = this.dom.getBoundingClientRect()[propertyName] // dom 节点初始位置
this.propertyName = propertyName // dom 节点需要被改变的CSS属性名
this.endPos = endPos // dom 节点目标位置
this.duration = duration // 动画持续时间
this.easing = tween[easing] // 缓动算法
var self = this
var timeId = setInterval(function() {
if (self.step() === false) {
// 启动定时器,开始执行动画
clearInterval(timeId) // 如果动画已结束,则清除定时器
}
}, 19)
}
Animate.prototype.step = function() {
var t = +new Date() // 取得当前时间
if (t >= this.startTime + this.duration) {
this.update(this.endPos)
return false
}
var pos = this.easing(t - this.startTime, this.startPos, this.endPos - this.startPos, this.duration)
this.update(pos)
}
Animate.prototype.update = function(pos) {
this.dom.style[this.propertyName] = pos + 'px'
}
var div = document.getElementById('div')
var animate = new Animate(div)
animate.start('left', 500, 1000, 'strongEaseOut')

上面的缓动动画使用策略模式把算法传入动画类中,来达到各种不同的缓动效果,这些算法都可以轻易地被替换为另外一个算法,这是策略模式的经典运用之一。策略模式的实现并不复杂,关键是如何从策略模式的实现背后,找到封装变化、委托和多态性这些思想的价值。

表单验证

var strategies = {
isNonEmpty: function(value, errorMsg) {
if (value === '') {
return errorMsg
}
},
minLength: function(value, length, errorMsg) {
if (value.length < length) {
return errorMsg
}
},
isMobile: function(value, errorMsg) {
if (!/(^1[3|5|8][0-9]{9}$)/.test(value)) {
return errorMsg
}
}
}
var Validator = function() {
this.cache = [] // 保存校验规则
}
Validator.prototype.add = function(dom, rules) {
var self = this
for (var i = 0, rule; (rule = rules[i++]); ) {
// @蝉時雨:没有必要用立即执行函数表达式,用 forEach 是否更合适
;(function(rule) {
var strategyAry = rule.strategy.split(':')
var errorMsg = rule.errorMsg
self.cache.push(function() {
// 把校验的步骤用空函数包装起来,并且放入 cache
var strategy = strategyAry.shift()
strategyAry.unshift(dom.value)
strategyAry.push(errorMsg)
return strategies[strategy].apply(dom, strategyAry)
})
})(rule)
}
}
Validator.prototype.start = function() {
for (var i = 0, validatorFunc; (validatorFunc = this.cache[i++]); ) {
var msg = validatorFunc() // 开始校验,并取得校验后的返回信息
if (msg) {
return msg // 如果有确切的返回值,说明校验没有通过
}
}
}
var validataFunc = function() {
var validator = new Validator() // 创建一个 validator 对象
/*************** 添加一些校验规则 ****************/
validator.add(registerForm.userName, [
{ strategy: 'isNonEmpty', errorMsg: '用户名不能为空' },
{ strategy: 'minLength:10', errorMsg: '用户名长度不能小于10位' }
])
validator.add(registerForm.password, [{ strategy: 'minLength:6', errorMsg: '密码长度不能小于6位' }])
validator.add(registerForm.phoneNumber, [{ strategy: 'isMobile', errorMsg: '手机号码格式不正确' }])
var errorMsg = validator.start()
return errorMsg // 返回校验结果
}
var registerForm = document.getElementById('registerForm')
registerForm.onsubmit = function() {
var errorMsg = validataFunc() // 如果 errorMsg 有确切的返回值,说明未通过校验
if (errorMsg) {
return false // 阻止表单提交
}
}

策略模式优缺点

通过以上三个例子,总结策略模式优点:

  • 策略模式利用组合、委托和多态等技术和思想,可以有效地避免多重条件选择语句。

  • 策略模式提供了对开放—封闭原则的完美支持,将算法封装在独立的 strategy 中,使得它们易于切换,易于理解,易于扩展。

  • 策略模式中的算法也可以复用在系统的其他地方,从而避免许多重复的复制粘贴工作。

  • 在策略模式中利用组合和委托来让 Context 拥有执行算法的能力,这也是继承的一种更轻便的替代方案。

当然,策略模式也有一些缺点:

  • 使用策略模式会在程序中增加许多策略类或者策略对象,但实际上这比把它们负责的逻辑堆砌在 Context 中要好。

  • 要使用策略模式,必须了解所有的 strategy,必须了解各个 strategy 之间的不同点,这样才能选择一个合适的 strategy。此时 strategy 要向客户暴露它的所有实现,违反最少知识原则。

一等函数对象与策略模式

在以类为中心的传统面向对象语言中,不同的算法或者行为被封装在各个策略类中,Context 将请求委托给这些策略对象,这些策略对象会根据请求返回不同的执行结果,这样便能表现出对象的多态性。

在函数作为一等对象的语言中,策略模式是隐形的。strategy 就是值为函数的变量。 -- Peter Norvig

在 JavaScript 中,除了使用类来封装算法和行为之外,使用函数当然也是一种选择。这些“算法”可以被封装到函数中并且四处传递,也就是常说的“高阶函数”。 实际上在 JavaScript 这种将函数作为一等对象的语言里,策略模式已经融入到了语言本身当中,我们经常用高阶函数来封装不同的行为,并且把它传递到另一个函数中。当对这些函数发出“调用”的消息时,不同的函数会返回不同的执行结果。在 JavaScript 中,“函数对象的多态性”来得更加简单。

代理模式

代理模式是为一个对象提供一个代用品或占位符,以便控制对它的访问。

代理模式的关键是,当客户不方便直接访问一个对象或者不满足需要的时候,提供一个替身对象来控制对这个对象的访问,客户实际上访问的是替身对象。替身对象对请求做出一些处理之后,再把请求转交给本体对象。

常见代理模式有:

  • 虚拟代理:把一些开销很大的对象,延迟到真正需要它的时候才去创建。

  • 缓存代理:缓存代理可以为一些开销大的运算结果提供暂时的存储。

  • 保护代理:用于对象应该有不同访问权限的情况,过滤请求。

  • 防火墙代理:控制网络资源的访问,保护主题不让“坏人”接近。

  • 远程代理:为一个对象在不同的地址空间提供局部代表,在 Java 中,远程代理可以是另一个虚拟机中的对象。

  • 智能引用代理:取代了简单的指针,它在访问对象时执行一些附加操作,比如计算一个对象被引用的次数。

在 JavaScript 开发中最常用的是虚拟代理和缓存代理。

虚拟代理

图片预加载

var myImage = (function() {
var imgNode = document.createElement('img')
document.body.appendChild(imgNode)
return {
setSrc: function(src) {
imgNode.src = src
}
}
})()
var proxyImage = (function() {
var img = new Image()
img.onload = function() {
myImage.setSrc(this.src)
}
return {
setSrc: function(src) {
myImage.setSrc('loading.gif')
img.src = src
}
}
})()
proxyImage.setSrc('avatar.jpg')

单一职责原则指的是,就一个类(通常也包括对象和函数等)而言,应该仅有一个引起它变化的原因。如果一个对象承担了多项职责,就意味着这个对象将变得巨大,引起它变化的原因可能会有多个。面向对象设计鼓励将行为分布到细粒度的对象之中,如果一个对象承担的职责过多,等于把这些职责耦合到了一起,这种耦合会导致脆弱和低内聚的设计。当变化发生时,设计可能会遭到意外的破坏。

同时在大多数情况下,若违反其他任何原则,同时将违反开放—封闭原则。

上面的预加载代码中,给 img 节点设置 src 和图片预加载这两个功能,被隔离在两个对象里,它们可以各自变化而不影响对方。何况就算有一天不再需要预加载,那么只需要改成请求本体而不是请求代理对象即可。其中关键是代理对象和本体都对外提供了 setSrc 方法,在客户看来,代理对象和本体是一致的,代理接手请求的过程对于用户来说是透明的,用户并不清楚代理和本体的区别,这样做有两个好处:

  • 用户可以放心地请求代理,他只关心是否能得到想要的结果。

  • 在任何使用本体的地方都可以替换成使用代理。

合并 HTTP 请求

频繁触发的 HTTP 请求可以缓存起来一并发送,能大大减轻服务器的压力,通过代理模式实现如下:

var synchronousFile = function(id) {
console.log('开始同步文件,id为: ' + id)
}
var proxySynchronousFile = (function() {
var cache = [], // 保存一段时间内需要同步的ID
timer // 定时器
return function(id) {
cache.push(id)
if (timer) {
// 保证不会覆盖已经启动的定时器
return
}
timer = setTimeout(function() {
synchronousFile(cache.join(',')) // 2秒后向本体发送需要同步的ID集合
clearTimeout(timer) // 清空定时器
timer = null
cache.length = 0 // 清空ID集合
}, 2000)
}
})()
var checkbox = document.getElementsByTagName('input')
for (var i = 0, c; (c = checkbox[i++]); ) {
c.onclick = function() {
if (this.checked === true) {
proxySynchronousFile(this.id)
}
}
}

缓存代理

缓存代理可以为一些开销大的运算结果提供暂时的存储,在下次运算时,如果传递进来的参数跟之前一致,则可以直接返回前面存储的运算结果。

var mult = function() {
var a = 1
for (var i = 0, l = arguments.length; i < l; i++) {
a = a * arguments[i]
}
return a
}
var plus = function() {
var a = 0
for (var i = 0, l = arguments.length; i < l; i++) {
a = a + arguments[i]
}
return a
}
// 创建缓存代理的工厂
var createProxyFactory = function(fn) {
var cache = {}
return function() {
var args = Array.prototype.join.call(arguments, ',')
if (args in cache) {
return cache[args]
}
return (cache[args] = fn.apply(this, arguments))
}
}
var proxyMult = createProxyFactory(mult),
proxyPlus = createProxyFactory(plus)
alert(proxyMult(1, 2, 3, 4)) // 输出:24
alert(proxyPlus(1, 2, 3, 4)) // 输出:10

迭代器模式

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

迭代器可以分为内部迭代器和外部迭代器。

内部迭代器调用方便,外界不用关心迭代器内部的实现,跟迭代器的交互也仅仅是一次初始调用,但这也刚好是内部迭代器的缺点,内部迭代器的迭代规则已经被提前规定。

外部迭代器必须显式地请求迭代下一个元素。外部迭代器增加了一些调用的复杂度,但相对也增强了迭代器的灵活性,可以手工控制迭代的过程或者顺序。

下面这个外部迭代器的实现来自《松本行弘的程序世界》第 4 章,原例用 Ruby 写成,这里翻译成 JavaScript:

var Iterator = function(obj) {
var current = 0
var next = function() {
current += 1
}
var isDone = function() {
return current >= obj.length
}
var getCurrItem = function() {
return obj[current]
}
return {
next: next,
isDone: isDone,
getCurrItem: getCurrItem
}
}

迭代器模式是一种相对简单的模式,简单到很多时候都不认为它是一种设计模式,目前的绝大部分语言都内置了迭代器。

迭代器模式的应用

这里已文件上传为例,在不同的浏览器环境下,选择的上传方式是不一样的:

var getActiveUploadObj = function() {
try {
return new ActiveXObject('TXFTNActiveX.FTNUpload') // IE上传控件
} catch (e) {
return false
}
}
var getFlashUploadObj = function() {
if (supportFlash()) {
// supportFlash 函数未提供
var str = '<object type="application/x-shockwave-flash"></object>'
return $(str).appendTo($('body'))
}
return false
}
var getFormUpladObj = function() {
var str = '<input name="file" type="file" class="ui-file"/>' // 表单上传
return $(str).appendTo($('body'))
}
var iteratorUploadObj = function() {
for (var i = 0, fn; (fn = arguments[i++]); ) {
var uploadObj = fn()
if (uploadObj !== false) {
return uploadObj
}
}
}
var uploadObj = iteratorUploadObj(getActiveUploadObj, getFlashUploadObj, getFormUpladObj)

在 getActiveUploadObj、getFlashUploadObj、getFormUpladObj 这 3 个函数中都有同一个约定:如果该函数里面的 upload 对象是可用的,则让函数返回该对象,反之返回 false,提示迭代器继续往后面进行迭代。

发布-订阅模式

发布—订阅模式又叫观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知。在 JavaScript 开发中,一般用事件模型来替代传统的发布—订阅模式。

全局订阅与通信

发布—订阅模式可以用一个全局的 Event 对象来实现,订阅者不需要了解消息来自哪个发布者,发布者也不知道消息会推送给哪些订阅者,Event 作为一个类似“中介者”的角色,把订阅者和发布者联系起来。

利用基于一个全局的 Event 对象,可以在两个封装良好的模块中进行通信,这两个模块可以完全不知道对方的存在。但模块之间如果用了太多的全局发布—订阅模式来通信,那么模块与模块之间的联系就被隐藏到了背后,无法分辨消息的来源与流向。

var Event = (function() {
var global = this,
Event,
_default = 'default'
Event = (function() {
var _listen,
_trigger,
_remove,
_slice = Array.prototype.slice,
_shift = Array.prototype.shift,
_unshift = Array.prototype.unshift,
namespaceCache = {},
_create,
find,
each = function(ary, fn) {
var ret
for (var i = 0, l = ary.length; i < l; i++) {
var n = ary[i]
ret = fn.call(n, i, n)
}
return ret
}
_listen = function(key, fn, cache) {
if (!cache[key]) {
cache[key] = []
}
cache[key].push(fn)
}
_remove = function(key, cache, fn) {
if (cache[key]) {
if (fn) {
for (var i = cache[key].length; i >= 0; i--) {
if (cache[key][i] === fn) {
cache[key].splice(i, 1)
}
}
} else {
cache[key] = []
}
}
}
_trigger = function() {
var cache = _shift.call(arguments),
key = _shift.call(arguments),
args = arguments,
_self = this,
ret,
stack = cache[key]
if (!stack || !stack.length) {
return
}
return each(stack, function() {
return this.apply(_self, args)
})
}
_create = function(namespace) {
var namespace = namespace || _default
var cache = {},
offlineStack = [], // 离线事件
ret = {
listen: function(key, fn, last) {
_listen(key, fn, cache)
if (offlineStack === null) {
return
}
if (last === 'last') {
offlineStack.length && offlineStack.pop()()
} else {
each(offlineStack, function() {
this()
})
}
offlineStack = null
},
one: function(key, fn, last) {
_remove(key, cache)
this.listen(key, fn, last)
},
remove: function(key, fn) {
_remove(key, cache, fn)
},
trigger: function() {
var fn,
args,
_self = this
_unshift.call(arguments, cache)
args = arguments
fn = function() {
return _trigger.apply(_self, args)
}
if (offlineStack) {
return offlineStack.push(fn)
}
return fn()
}
}
return namespace
? namespaceCache[namespace]
? namespaceCache[namespace]
: (namespaceCache[namespace] = ret)
: ret
}
return {
create: _create,
one: function(key, fn, last) {
var event = this.create()
event.one(key, fn, last)
},
remove: function(key, fn) {
var event = this.create()
event.remove(key, fn)
},
listen: function(key, fn, last) {
var event = this.create()
event.listen(key, fn, last)
},
trigger: function() {
var event = this.create()
event.trigger.apply(this, arguments)
}
}
})()
return Event
})()
/************** 先发布后订阅 ********************/
Event.trigger('click', 1)
Event.listen('click', function(a) {
console.log(a) // 输出:1
})
/************** 使用命名空间 ********************/
Event.create('namespace1').listen('click', function(a) {
console.log(a) // 输出:1
})
Event.create('namespace1').trigger('click', 1)
Event.create('namespace2').listen('click', function(a) {
console.log(a) // 输出:2
})

JavaScript 中的发布—订阅模式,跟一些别的语言(比如 Java)中的实现还是有区别的。在 Java 中通常会把订阅者对象自身当成引用传入发布者对象中,同时订阅者对象还需提供一个名为诸如 update 的方法,供发布者对象在适合的时候调用。而在 JavaScript 中,用注册回调函数的形式来代替传统的发布—订阅模式,显得更加优雅和简单。

另外,在 JavaScript 中,无需去选择使用推模型还是拉模型。推模型是指在事件发生时,发布者一次性把所有更改的状态和数据都推送给订阅者。拉模型不同的地方是,发布者仅仅通知订阅者事件已经发生了,此外发布者要提供一些公开的接口供订阅者来主动拉取数据。

拉模型的好处是可以让订阅者“按需获取”,但同时有可能让发布者变成一个“门户大开”的对象,同时增加了代码量和复杂度。刚好在 JavaScript 中,arguments 可以很方便地表示参数列表,所以一般都会选择推模型,使用 Function.prototype.apply 方法把所有参数都推送给订阅者。

发布—订阅模式的优点非常明显,一为时间上的解耦,二为对象之间的解耦。发布—订阅模式还可以用来帮助实现一些别的设计模式,比如中介者模式。从架构上来看,无论是 MVC 还是 MVVM,都少不了发布—订阅模式的参与,而且 JavaScript 本身也是一门基于事件驱动的语言

当然,发布—订阅模式也不是完全没有缺点。创建订阅者本身要消耗一定的时间和内存,而且订阅一个消息后,也许此消息最后都未发生,但这个订阅者会始终存在于内存中。另外发布—订阅模式虽然可以弱化对象之间的联系,但如果过度使用的话,对象和对象之间的必要联系也将被深埋在背后,会导致程序难以跟踪维护和理解。特别是有多个发布者和订阅者嵌套到一起的时候,要跟踪一个 bug 不是件轻松的事情。

命令模式

命令模式是最简单和优雅的模式之一,命令模式中的命令(command)指的是一个执行某些特定事情的指令。

命令模式最常见的应用场景是:有时候需要向某些对象发送请求,但是并不知道请求的接收者是谁,也不知道被请求的操作是什么。此时希望用一种松耦合的方式来设计程序,使得请求发送者和请求接收者能够消除彼此之间的耦合关系。

另外,相对于过程化的请求调用,command 对象拥有更长的生命周期。对象的生命周期是跟初始请求无关的,因为这个请求已经被封装在了 command 对象的方法中,成为了这个对象的行为,可以随时调用。

除了这两点之外,命令模式还支持撤销、排队等操作。

JavaScript 中的命令模式

一个最简单的命令模式代码如下:

var MenuBar = {
refresh: function() {
console.log('刷新菜单目录')
}
}
// ① 面向对象实现
var RefreshMenuBarCommand = function(receiver) {
this.receiver = receiver
}
RefreshMenuBarCommand.prototype.execute = function() {
this.receiver.refresh()
}
var refreshMenuBarCommand = new RefreshMenuBarCommand(MenuBar)
var button = document.getElementById('button')
var setCommand = function(button, command) {
button.onclick = function() {
command.execute()
}
}
setCommand(button, refreshMenuBarCommand)

上面示例代码是模拟传统面向对象语言的命令模式实现。命令模式将过程式的请求调用封装在 command 对象的 execute 方法里,通过封装方法调用,可以把运算块包装成形。command 对象可以被四处传递,所以在调用命令的时候,不需要关心事情是如何进行的。

命令模式的由来,其实是回调(callback)函数的一个面向对象的替代品

JavaScript 作为将函数作为一等对象的语言,跟策略模式一样,命令模式也早已融入到了 JavaScript 语言之中。运算块不一定要封装在 command.execute 方法中,也可以封装在普通函数中。函数作为一等对象,本身就可以被四处传递。即使依然需要请求“接收者”,那也未必使用面向对象的方式,闭包可以完成同样的功能。

在面向对象设计中,命令模式的接收者被当成 command 对象的属性保存起来,同时约定执行命令的操作调用 command.execute 方法。在使用闭包的命令模式实现中,接收者被封闭在闭包产生的环境中,执行命令的操作可以更加简单,仅仅执行回调函数即可。无论接收者被保存为对象的属性,还是被封闭在闭包产生的环境中,在将来执行命令的时候,接收者都能被顺利访问。

使用闭包方式重写上面命令封装:

// ② 闭包实现
var RefreshMenuBarCommand = function(receiver) {
return {
execute: function() {
receiver.refresh()
}
}
}

宏命令

宏命令是一组命令的集合,通过执行宏命令的方式,可以一次执行一批命令。宏命令是命令模式与组合模式的联用产物。

var openPcCommand = {
execute: function() {
console.log('开电脑')
}
}
var openQQCommand = {
execute: function() {
console.log('登录QQ')
}
}
var MacroCommand = function() {
return {
commandsList: [],
add: function(command) {
this.commandsList.push(command)
},
execute: function() {
for (var i = 0, command; (command = this.commandsList[i++]); ) {
command.execute()
}
}
}
}
var macroCommand = MacroCommand()
macroCommand.add(openPcCommand)
macroCommand.add(openQQCommand)
macroCommand.execute()

智能命令和傻瓜命令

回顾上面的命令代码:

var openPcCommand = {
execute: function() {
console.log('开电脑')
}
}

命令中没有接收者 receiver,本身就包揽了执行请求的行为。一般来说,命令模式都会在 command 对象中保存一个接收者来负责真正执行客户的请求,这种情况下命令对象是“傻瓜式”的,它只负责把客户的请求转交给接收者来执行,这种模式的好处是请求发起者和请求接收者之间尽可能地得到了解耦。

但是也可以定义一些更“聪明”的命令对象叫做智能命令,智能命令可以直接实现请求,不再需要接收者的存在。没有接收者的智能命令和策略模式非常相近,从代码结构上已经无法分辨它们,它们只有意图的不同。策略模式指向的问题域更小,所有策略对象的目标总是一致的,它们只是达到这个目标的不同手段,它们的内部实现是针对“算法”而言的。而智能命令模式指向的问题域更广,command 对象解决的目标更具发散性。

组合模式

组合模式将对象组合成树形结构,以表示“部分-整体”的层次结构。除了用来表示树形结构之外,组合模式的另一个好处是通过对象的多态性表现,使得用户对单个对象和组合对象的使用具有一致性。

以宏命令为例,请求从树最顶端的对象往下传递,如果子节点是叶对象,叶对象自身会处理这个请求,而如果子节点还是组合对象,请求会继续往下传递。

组合模式最大的优点在于可以一致地对待组合对象和基本对象。客户不需要知道当前处理的是宏命令还是普通命令,只要它是一个命令,并且有 execute 方法,这个命令就可以被添加到树中。这种透明性带来的便利,在静态类型语言中体现得尤为明显。比如在 Java 中,实现组合模式的关键是 Composite 类和 Leaf 类都必须继承自一个 Compenent 抽象类。这个 Compenent 抽象类既代表组合对象,又代表叶对象,它也能够保证组合对象和叶对象拥有同样名字的方法,从而可以对同一消息都做出反馈。组合对象和叶对象的具体类型被隐藏在 Compenent 抽象类身后。

然而在 JavaScript 这种动态类型语言中,对象的多态性是与生俱来的,JavaScript 中实现组合模式的难点在于要保证组合对象和叶对象对象拥有同样的方法,这通常需要用鸭子类型的思想对它们进行接口检查。

扫描文件夹

以下以文件复制和文件夹扫描为例:

/******** Folder **********/
var Folder = function(name) {
this.name = name
this.files = []
this.parent = null // 增加 this.parent 属性
}
Folder.prototype.add = function(file) {
file.parent = this // 设置父对象
this.files.push(file)
}
Folder.prototype.scan = function() {
for (var i = 0, file, files = this.files; (file = files[i++]); ) {
file.scan()
}
}
Folder.prototype.remove = function() {
if (!this.parent) {
// 根节点或者树外的游离节点
return
}
for (var files = this.parent.files, l = files.length - 1; l >= 0; l--) {
var file = files[l]
if (file === this) {
files.splice(l, 1)
}
}
}
/************ File **************/
var File = function(name) {
this.name = name
this.parent = null
}
File.prototype.add = function() {
throw new Error('文件下面不能再添加文件')
}
File.prototype.scan = function() {
console.log('开始扫描文件: ' + this.name)
}
File.prototype.remove = function() {
if (!this.parent) {
// 根节点或者树外的游离节点
return
}
for (var files = this.parent.files, l = files.length - 1; l >= 0; l--) {
var file = files[l]
if (file === this) {
files.splice(l, 1)
}
}
}
var folder = new Folder('学习资料')
var folder1 = new Folder('JavaScript')
var file1 = new File('JavaScript设计模式与开发实践')
var file2 = new File('重构与模式')
folder1.add(file1)
folder.add(folder1)
folder.add(file2)
folder1.remove()
folder.scan()

组合模式适用的两大场景:

  • 表示对象的部分-整体层次结构

  • 客户希望统一对待树中的所有对象

然而组合模式并不是完美的,它可能会产生一个这样的系统:系统中的每个对象看起来都与其他对象差不多。它们的区别只有在运行的时候会才会显现出来,这会使代码难以理解。此外,如果通过组合模式创建了太多的对象,那么这些对象可能会让系统负担不起。