/*
* 多态
* 一段“多态”的 JavaScript 代码
* 问题:随着动物总类的增加会导致 makeSound 函数臃肿
*/
var makeSound = function (animal) {
if (animal instanceof Duck) {
console.log('嘎嘎嘎')
} else if (animal instanceof Chicken) {
console.log('咯咯咯')
}
}
var Duck = function () {}
var Chicken = function () {}
makeSound(new Duck()) // 嘎嘎嘎
makeSound(new Chicken()) // 咯咯咯
/*
* 对象的多态性
* 改进:将不变的部分抽离出来(所有动画都会叫 makeSound),然后把可变的部分各自封装起来
*/
var makeSound = function (animal) {
animal.sound()
}
var Duck = function () {}
Duck.prototype.sound = function () {
console.log('嘎嘎嘎')
}
var Chicken = function () {}
Chicken.prototype.sound = function () {
console.log('咯咯咯')
}
makeSound(new Duck())
makeSound(new Chicken())
多态在面向对象程序设计中的作用
多态最根本的作用就是通过把过程化的条件分支语句转化为对象的多态性,从而消除这些条件分支语句。
将行为分布在各个对象中,并将这些对象各自负责自己的行为,这正是面向对象设计的优点。
/*
* 优化前
*/
var googleMap = {
show: function () {
console.log('开始渲染谷歌地图')
}
}
var baiduMap = {
show: function () {
console.log('开始渲染百度地图')
}
}
var renderMap = function (type) {
if (type === 'google') {
googleMap.show()
} else if (type === 'baidu') {
baiduMap.show()
}
}
renderMap('google') // 输出:开始渲染谷歌地图
renderMap('baidu') // 输出:开始渲染百度地图
/*
* 优化后
* 对象的多态性提示我们:“做什么”和“怎么去做”是可以分开的
*/
var googleMap = {
show: function () {
console.log('开始渲染谷歌地图')
}
}
var baiduMap = {
show: function () {
console.log('开始渲染百度地图')
}
}
/*
* 后续增加soso地图,renderMap 函数不需要做出任何改变
*/
var sosoMap = {
show: function () {
console.log('开始渲染soso地图')
}
}
var renderMap = function (map) {
if (map.show instanceof Function) {
map.show()
}
}
renderMap(googleMap) // 输出:开始渲染谷歌地图
renderMap(baiduMap) // 输出:开始渲染百度地图
原型模式和基于原型继承的 JavaScript 对象系统
JavaScript 的函数既可以普通函数被调用,也可以作为构造函数被调用。当使用 new 运算符来调用函数时,此时的函数就是一个构造器。用 new 运算符来创建对象的过程,实际上也只是先克隆 Object.prototype 对象,在进行一些其他额外操作的过程。
在 Chrome 和 Firefox 等向外暴露了对象 __proto__ 属性的浏览器下,我们可以通过下面这段代码来理解 new 运算符的过程:
function Person(name) {
this.name = name
}
Person.prototype.getName = function () {
return this.name
}
var objectFactory = function () {
var obj = new Object() // 从 Object.prototype 上克隆一个空的对象
var Constructor = [].shift.call(arguments) // 取得外部传入的构造器,此例是 Person
obj.__proto__ = Constructor.prototype // 指向正确的原型 Person.prototype 而不是原来的 Object.prototype
var ret = Constructor.apply(obj, arguments) // 借用外部传入的构造器给 obj 设置属性
return typeof ret === 'object' ? ret : obj // 确保构造器总是会返回一个对象
}
var a = objectFactory(Person, 'sven')
console.log(a.name) // sven
console.log(a.getName()) // sven
console.log(Object.getPrototypeOf(a) === Person.prototype) // true
// 我们看到,分别调用下面两句代码产生了一样的结果:
var a = objectFactory(A, 'sven')
var a = new Person('sven')
// 通用的惰性单例
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'
}
第五章 策略模式
定义:定义一系列算法,把它们一个个封装起来,并且使它们可以相互替换。
案例:
// 计算奖金
// 最初的代码实现
var calculateBouns = function (performanceLevel, salary) {
if (performanceLevel === 'S') {
return salary * 4
}
if (performanceLevel === 'A') {
return salary * 3
}
if (performanceLevel === 'B') {
return salary * 2
}
}
calculateBouns('B', 2000) // 40000
calculateBouns('S', 6000) // 24000
// JavaScript 版本的策略模式
var strategies = {
S: function (salary) {
return salary * 4
},
A: function (salary) {
return salary * 3
},
B: function (salary) {
return salary * 2
}
}
var calculateBouns = function (level, salary) {
return strategies[level](salary)
}
console.log(calculateBouns('S', 20000)) // 80000
console.log(calculateBouns('A', 30000)) // 30000
/*
* 通过增加缓存代理的方式,mult 函数可以继续专注于自身的职责——计算乘积
* 缓存的功能由代理对象实现
*/
var mult = function () {
console.log('开始计算乘积')
var a = 1
for (var i = 0, l = arguments.length; i < l; i++) {
a *= arguments[i]
}
return a
}
var proxyMult = (function () {
var cache = {}
return function () {
var args = Array.prototype.join.call(arguments, ',')
if (args in cache) {
return cache[args]
}
return cache[args] = mult.apply(this, arguments)
}
})()
proxyMult(1, 2, 3, 4) // 24
proxyMult(1, 2, 3, 4) // 24
var Beverage = function (param) {
var boilWater = function () {
console.log('把水煮沸')
}
var brew = param.brew || function () {
throw new Error('必须传递 brew 方法')
}
var pourInCup = param.pourInCup || function () {
throw new Error('必须传递 pourInCup 方法')
}
var addCondiments = param.addCondiments || function () {
throw new Error('必须传递 addCondiments 方法')
}
var F = function () {}
F.prototype.init = function () {
boilWater()
brew()
pourInCup()
addCondiments()
}
return F
}
var Coffee = Beverage ({
brew: function () {
console.log('用沸水冲泡咖啡')
},
pourInCup: function () {
console.log('把咖啡倒进杯子')
},
addCondiments: function () {
console.log('加糖和咖啡')
}
})
var coffee = new Coffee()
coffee.init()
Function.prototype.after = function (fn) {
var self = this
return function () {
var ret = self.apply(this, arguments)
if (ret === 'nextSuccessor') {
return fn.apply(this, arguments)
}
return ret
}
}
var order = order500.after(order200).after(orderNormal)
order(1, true, 500) // 500定金购买,得到100优惠券
order(2, true, 500) // 200元定金购买,得到50优惠券
order(1, true, 500) // 普通购买,无优惠券
用职责链模式获取文件上传对象
第七章有一个用迭代器获取文件上传对象的例子,其实这里用职责链实现更简单:
var getActiveUploadObj = function () {
try {
return new ActiveXObject('TXFTNActiveX.FTMUpload') // IE 上传控件
} catch (e) {
return 'nextSuccessor'
}
}
var getFlashUploadObj = function () {
if (supportFlash()) {
var str = '<object type="application/x-shockwave-flash"></object>'
return $(str).appendTo($('body'))
}
return 'nextSuccessor'
}
var getFormUploadObj = function () {
return $('<form><input name="file" type="file" /></form>').appendTo($('body'))
}
var getUploadObj = getActiveUploadObj.after(getFlashUploadObj).after(getFormUploadObj)
console.log(getUploadObj())
window.onload = function () {
alert(1)
}
var _onload = window.onload || function () {}
window.load = function () {
_onload()
alert(2)
}
用 AOP 装饰函数
Function.prototype.before = function (beforefn) {
var __self = this // 保存原函数的引用
return function () { // 返回包含了元函数和新函数的“代理”函数
// 执行新函数,且保证 this 不被劫持,新函数接受的参数
// 也会被原封不动地传入原函数,新函数在原函数之前执行
beforefn.apply(this, arguments)
// 执行原函数并返回原函数的执行结果,并保证 this 不被劫持
return __self.apply(this, arguments)
}
}
Function.prototype.after = function (afterfn) {
var __self = this
return function () {
var ret = __self.apply(this, arguments)
afterfn.apply(this, arguments)
return ret
}
}
// 之前的 window.onload 例子
window.onload = function () {
alert(1)
}
window.onload = (window.onload || function () {}).after(function () {
alert(2)
}).after(function () {
alert(3)
}).after(function () {
alert(4)
})
不污染原型的方式:把原函数和新函数都作为参数传入 before 或 after 方法:
var before = function (fn, beforefn) {
return function () {
beforefn.apply(this, arguments)
return fn.apply(this, arguments)
}
}
var a = before(
function () {alert(3)},
function () {alert(2)}
)
a = before(a, function () {alert(1)})
a()
/*
<html>
<button tag="login" id="button">点击打开登录浮层</button>
</html>
*/
Function.prototype.after = function (afterfn) {
var __self = this
return function () {
var ret = __self.apply(__self, arguments)
afterfn.apply(this, arguments)
return ret
}
}
var showLogin = function () {
console.log('打开登录浮层')
}
var log = function () {
console.log('上报标签为:', this.getAttribute('tag'))
}
showLogin = showLogin.after(log) // 打开登录浮层之后上传数据
document.getElementById('button').onclick = showLogin
2. 用 AOP 动态改变函数的参数
观察 Function.prototype.before 方法:
Function.prototype.before = function (beforefn) {
var __self = this
return function () {
beforefn.apply(this, arguments)
return __self.apply(this, arguments)
}
}
var State = function () {}
State.prototype.buttonWasPressed = function () {
throw new Error('父类的 buttonWasPressed 方法必须被重写')
}
var SuperStrongLightState = function (light) {
this.light = light
}
SuperStrongLightState.prototype = new State() // 继承抽象父类
SuperStrongLightState.prototype.buttonWasPressed = function () { // 重写 buttonWasPressed 方法
console.log('关灯')
this.light.setState(this.light.offLightState)
}
过多的条件分支语句是造成程序违反开放-封闭原则的一个常见原因。每当增加一个新的 if 语句时,都要被迫改动原函数。把 if 换成 switch-case 是没用的,这是换汤不换药的做法。实际上,每当我们看到一大片的 if 或者 switch-case 语句时第一时间就应该考虑能否利用对象的多态性来重构它们。
设计模式的定义:在面向对象软件设计过程中针对特定问题的简洁而优雅的解决方案。
第一部分 基础知识
第一章 面向对象的 JavaScript
JavaScript 没有提供传统面向对象语言中的类式继承,而是通过原型委托的方式来实现对象与对象之间的继承。JavaScript 也没有在语言层面提供对抽象类和接口的支持。正因为存在这些跟传统面向对象语言不一致的地方,我们在用设计模式编写代码的时候,更要跟传统面向对象语言加以区别。
多态
“多态”一词源于希腊文 polymorphism,拆开来看是 poly(复数) + morph(形态) + sim,从字面上我们可以理解为复数形式。
多态的实际含义是:同一操作作用于不同的对象上面,可以产生不同的解释和不同的执行结果。换句话说,给不同的对象发送同一个消息时,这些对象会根据这个消息分别给出不同的反馈。
多态在面向对象程序设计中的作用
多态最根本的作用就是通过把过程化的条件分支语句转化为对象的多态性,从而消除这些条件分支语句。
将行为分布在各个对象中,并将这些对象各自负责自己的行为,这正是面向对象设计的优点。
原型模式和基于原型继承的 JavaScript 对象系统
JavaScript 的函数既可以普通函数被调用,也可以作为构造函数被调用。当使用 new 运算符来调用函数时,此时的函数就是一个构造器。用 new 运算符来创建对象的过程,实际上也只是先克隆 Object.prototype 对象,在进行一些其他额外操作的过程。
在 Chrome 和 Firefox 等向外暴露了对象
__proto__
属性的浏览器下,我们可以通过下面这段代码来理解 new 运算符的过程:JavaScript 中的原型继承
JavaScript 给对象提供了一个名为
__proto__
的隐藏属性,对象的__proto__
属性默认会指向它的构造函数的原型对象。实际上,
__proto__
就是对象跟“对象构造函数的原型”联系起来的纽带。虽然 JavaScript 的对象最初都是由 Object.prototype 对象克隆而来,但对象构造函数的原型并不仅限于 Object.prototype 上,而是可以动态指向其他对象。
最常用的原型继承方式:
原型继承的未来
使用 Object.create 来完成原型继承看起来更能体现原型模式的精髓。通过
Object.create(null)
可以创建出没有原型的对象。另外,ECMAScript 6 带来了新的 Class 语法。但其背后仍是通过原型机制来创建对象。
第二章 this、call 和 apply
JavaScript 的 this 总是指向一个对象,而具体指向哪个对象时运行时基于函数的执行环境动态绑定的,而非函数被声明时的环境。
this 的指向
除去不常用的 with 和 eval 的情况,具体到实际应用中,this 的指向大致可以分为以下 4 种。
Math.max.apply(null, [1, 2, 3])
Function.prototype.bind
大部分高级浏览器都实现了内置的 Function.prototype.bind。但我们也可以模拟一个:
第三章 闭包和高阶函数
高阶函数
高阶函数是指至少满足下列条件之一的函数:
高阶函数实现 AOP
AOP(面向切面编程)的主要作用是把一些跟核心业务逻辑模块无关的功能抽离出来,这些跟业务逻辑无关的功能通常包括日记统计、安全控制、异常处理等。把这些功能抽离出来之后,再通过“动态织入”的方式掺入业务逻辑模块中。这样可以保持业务逻辑模块的纯净和高内聚性,其次是方便复用日志统计等功能模块。
通常,在 JavaScript 中实现 AOP 都是指把一个函数“动态织入”到另外一个函数中,具体的实现技术有很多,这里通过扩展 Function.prototype 来做到这一点。
这种使用 AOP 的方式来给函数添加职责,也是 JavaScript 语言中一种非常特别和巧妙的装饰者模式实现。
高阶函数的其他应用
currying
currying 又称部分求值。一个 currying 的函数首先会接受一些参数,接受了这些参数之后,该函数并不会立即求值,而是继续返回另外一个函数,刚才传入的参数在函数形成的闭包中被保存起来。待到函数被真正需要求值的时候,之前传入的所有参数都会被一次性用于求值。
函数节流
分时函数
对于耗时函数的解决方案之一是下面的 timeChunk 函数,timeChunk 函数让创建节点的工作分批进行。
timeChunk 函数接受 3 个函数,第 1 个参数是总数据,第 2 个参数是封装了处理逻辑的函数,第 3 个参数是每一批处理的数量。
惰性加载函数
在 Web 开发中,因为浏览器之间的实现差异,一些嗅探工作总是不可避免。比如我们需要一个在各个浏览器中能够实现通用的事件绑定函数 addEvent。
第二部分 设计模式
本书并没有涵盖 GoF 所提出的 23 种设计模式,而是选择了在 JavaScript 开发中更常见的 14 种设计模式。
第四章 单例模式
定义:保证一个类仅有一个实例,并提供一个访问它的全局访问点。
第五章 策略模式
定义:定义一系列算法,把它们一个个封装起来,并且使它们可以相互替换。
案例:
更广义的“算法”
通常会把算法的含义扩散开来,使用策略模式也可以用来封装一系列的“业务规则”。只要这些业务规则指向的目标一致,并且可以被替换使用,我们就可以用策略模式来封装它们。
表单校验
策略模式的优点
第六章 代理模式
代理模式的关键是,当客户不方便直接访问一个对象或不满足需要的时候,提供一个替身对象来控制对这个对象的访问,客户实际上访问的是替身对象。替身对象对请求做出一些处理之后,再把请求转交给本体对象。
两种代理模式:保护代理和虚拟代理。
保护代理:用于控制不同权限的对象对目标对象的访问,但在 JavaScript 并不容易实现保护代理,因为我们无法判断谁访问了对象。
虚拟代理:把一些开销很大的对象,延迟到真正需要它的时候才创建。
代理的意义
单一职责原则指的是,就一个类(通常也包括对象和函数等)而言,应该仅有一个引起它变化的原因。如果一个对象承担了多项职责,就意味着这个对象将变得巨大,引起它变化的原因可能会有多个。而面向对象设计鼓励奖行为分布到细粒度和低内聚的设计。
在面向对象的程序设计中,大多数情况下,若违反其他任何原则,同时将违反开放-封闭原则。
因此,代理负责预加载图片,预加载的操作完成之后,把请求体重新交给本体 MyImage。
虚拟代理合并 HTTP 请求
缓存代理
缓存代理可以为一些开销大的运算结果提供暂时的存储,在下次运算时,如果传递进来的参数跟之前一直,则可以直接返回前面存储的运算结果。
缓存代理用于 Ajax 异步请求数据:与计算乘积不同的是,请求数据是个异步操作,无法直接把计算结果放到代理对象的缓存中,而是要通过回调的方式。
用高阶函数动态创建代理
通过传入高阶函数这种更加灵活的方式,可以为各种计算方法创建缓存代理。
其他代理模式
防火墙代理、远程代理、保护代理、智能引用代理、写时复制代理
第七章 迭代器模式
迭代器模式是指提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象的内部表示。
迭代器模式是一种相对简单的模式,简单到很多时候我们都不认为它是一种设计模式。目前绝大部分语言都内置了迭代器,如 Array.prototype.forEach。
第八章 发布-订阅模式
又称观察者模式,定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖它的对象都将得到通知。
全局的发布-订阅对象
发布-订阅的优缺点
优点:
缺点:
第九章 命令模式
对于书中解释仍不太理解,后续再补充。
第十章 组合模式
组合模式的用途
更强大的宏命令
一些值得注意的地方
何时使用组合模式
组合模式适用于以下两种情况:
第十一章 模板方法模式
模板方法模式由两部分结构组成,第一部分是抽象父类,第二部分是具体的实现子类。通常在抽象父类中封装了子类的算法框架,包括实现一些公共方法和封装子类中所有方法的执行顺序。子类通过继承这个抽象类,也继承了整个算法结构,并且可以选择重写父类的方法。
用 Java 实现 Coffee or Tea 的例子
我们知道 Beverage.prototype.init 方法作为模板方法,已经规定了子类的算法框架。
然而,JavaScript 没有抽象类,也没 Java 编辑器会保证子类会重写父类中的抽象方法。因此需要其他变通的解决方案。
让 Beverage.prototype.brew 等方法直接抛出一个异常。至少让程序运行时得到一个错误:
钩子方法
钩子方法让子类不受某些约束。放置钩子是隔离变化的一种常见手段。我们在父类中容易变化的地方放置钩子,钩子可以有一个默认的实现,究竟要不要“挂钩”,这由子类自行决定。钩子方法的返回结果决定了模板方法方面部分的执行步骤,也就是程序接下来的走向。这样一来,程序就拥有了变化的可能。
真的需要“继承”吗?
模板方法模式是基于继承的一种设计模式,父类封装了子类的算法框架和方法的执行顺序。子类继承父类之后,父类通知子类执行这些方法,好莱坞原则很好地诠释了这种设计技巧,即高层组件调用底层组件。
然而 JavaScript 语言实际上没有提供真正的类似继承,继承是通过对象与对象之前的委托来实现的。虽然,我们在形式上借鉴了提供类似继承的语言,但本章学习到的模板方法并不十分正宗。在 JavaScript 通过高阶函数也能实现。
小结
模板方法模式是一种典型的通过封装变化提供系统扩展性的设计模式。在传统的面向对象语言中,一个运用了模板方法模式的程序中,子类的方法种类和执行顺序都是不变的,所以我们把这部分逻辑抽象到父类的模板方法里面。而子类的方法具体怎么实现则是可变的,于是我们把这部分的逻辑封装到子类中。通过增加新的子类,我们便能给系统增加新的功能,并需要改动抽象父类及其他子类,这也符合开发-封闭原则。
第十二章 享元模式
享元模式是一种用于性能优化的模式。其核心是用共享技术来支持大量细粒度的对象。
内部状态与外部状态
享元模式要求将对象的属性划分为内部状态和外部状态(通常指属性)。享元模式的目标是尽量减少共享对象的数量。
如何划分内部状态和外部状态,下面的几条经验提供了一些指引:
这样一来,我们便可以把所有内部状态相同的对象都指定为同一个共享对象。而外部状态可以从对象身上剥离出来,并存储在外部。
剥离了外部状态的对象成为共享对象,外部状态在必要时被传入共享对象来组装成一个完整的对象。虽然组装外部状态成为一个完整对象的过程需要花费一定的时间,但却可以大大减少系统中的对象数量。因此,享元模式是一种时间换空间的优化模式。
通常来讲,内部状态有多少种组合,系统中便最多存在多少个对象。
使用享元模式的关键是如何区别内部状态和外部状态。可以被对象共享的属性通常被划分为内部状态。
案例:不管什么样式的衣服,都可以按照性别不同,穿在同一个男模特或者女模特身上,模特的性别就可以作为内部状态存储在共享对象的内部。而外部状态取决于具体的场景,并根据场景而变化,就像例子中每件衣服都是不同的,它们不能被一些对象共享,因此只能被划分为外部状态。
文件上传的例子
对象爆炸
每个上传文件对应一个 upload 对象。对于同时上传多个文件时会创建大量对象。
享元模式重构文件上传
享元模式的适用性
一般来说,以下情况发生时便可以使用享元模式。
没有内部状态的享元
对于无内部状态的情况下,依然使用享元模式时,构造函数 Upload 就变成了无参数的形式:
其他属性如 fileName、fileSize、dom 依然可以做为外部状态保存在共享对象外部。现在已经没有了内部状态,意味着只需要唯一的一个共享对象。现在我们需要改写创建享元对象的工厂:
管理器部分的代码不需要改动,还是负责剥离和组装外部状态。当对象没有内部状态时,生成共享对象的工厂实际上变成了一个单例工厂。
对象池
对象池维护一个装载空闲对象的池子,如果需要对象时,不是直接 new,而是转从对象池里获取。如果对象池里没有空闲对象,则创建一个新的对象,当获取出的对象完成它的职责后,再进入池子等待下次被获取。
对象池技术的应用非常广泛,HTTP 连接池和数据库连接池都是其代表应用。在 Web 前端开发中,对象池使用最多的场景大概就是跟 DOM 有关的操作。
通用对象池实现
对象池是另外一种性能优化方案,它跟享元模式有一些相似,但没有分离内部状态和外部状态这个过程。上述的文件上传案例用对象池+事件委托来代替实现。
第十三章 职责链模式
职责链模式的定义是:使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系,将这些对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。
现实案例:1. 高峰时期递公交卡 2. 作弊传纸条
最大优点:请求发送者只需要直到链中的第一个节点,从而弱化了发送者和一组接收者之间的强联系。如果不使用职责链模式,那么在公交车上,需要先搞清楚谁是售票员后,才能把硬币递给他。同样在考试中,也许就要先了解同学中有哪些可以解答这道题。
实际案例
异步的职责链
职责链模式的优缺点
解耦了请求者和 N 个接收者之间的复杂关系,由于链中的哪个节点可以处理你发出的请求,所以只需把请求传递给第一个节点即可。
其他优点:
缺点:
用 AOP 实现职责链
用职责链模式获取文件上传对象
第七章有一个用迭代器获取文件上传对象的例子,其实这里用职责链实现更简单:
小结
职责链可以帮助我们管理代码,降低发起请求的对象和处理请求对象之间的耦合性。职责链中的节点数量和顺序是可以自由变化的,我们可以在运行时决定链中包含哪些节点。
无论是作用域链、原型链,还是 DOM 节点中的事件冒泡,我们都能从中找到职责链模式的影子。
第十四章 中介者模式
面向对象设计鼓励将行为分布到各个对象中,把对象划分成更小的粒度,有助于增强对象的可复用性,但由于这些细粒度对象之间的联系激增,又有可能会反过来降低它们的可复用性。
中介者模式的作用就是解除对象与对象之间的耦合关系。增加一个中介者对象后,所有的相关对象都通过中介者对象来通信,而不是相互作用,所以当一个对象发生改变时,只需要通知中介者对象即可。中介者使各对象之间耦合松散,而且可以独立地改变它们之间的交互。中介者模式使网状的多对多关系变成了相对简单的一对多关系。
对于前者,如果对象 A 发生了改变,则需要同时通知跟 A 发生引用关系的 B、D、E、F 这 4 个对象;而后者则只需通知中介者对象即可。
泡泡堂游戏
小结
中介者模式是迎合迪米特法则的一种实现。迪米特法则也叫最小知识原则,是指一个对象应该尽可能少地了解另外的对象。如果对象之间的耦合性太高,一个对象发生改变后,难免会影响到其他对象。而在中介者模式里,对象之间几乎不知道彼此的存在,它们只能通过中介者对象来相互影响对方。
因此,中介者模式使各个对象之间得以解耦,以中介者和对象之间的一对多关系取代了对象之间的网状多对多关系。各个对象只需关注自身功能的实现,对象之间的交互关系交给了中介者对象来实现和维护。
中介者模式存在的最大缺点是:新增一个中介者对象,因为对象之间交付的复杂性,转移成了中介者对象的复杂性,使得中介者对象经常是巨大的,中介者对象自身往往就是一个难以维护的对象。
一般来说,如果对象之间的复杂耦合确实导致调用和维护出现了困难,而且这些耦合度随项目的变化呈指数增长,那我们就可以考虑用中介者模式来重构代码。
第十五章 装饰者模式
装饰者模式可以动态地给某个对象添加一些额外的职责,而不会影响从这个类中派生的其他对象。
又称为包装器模式。
装饰函数
想要为函数添加一些功能,最简单粗暴的方式就是直接改写该函数,但这是最差的办法,直接违反了开发-封闭原则:
很多时候我们不想碰原函数。现在需要一个方法,在不改变函数源代码的情况下,给函数增加功能,这正是开发-封闭原则给我们指出的光明道路。
这是实际开发中很常见的一种做法,比如我们想给 window 绑定 onload 事件,但又不确定这个事件是否已经被其他人绑定过,为了避免覆盖之前的 window.onload 函数中的行为,我们一般都会先保存好原先的 window.onload,把它放入新的 window.onload 里执行。
用 AOP 装饰函数
不污染原型的方式:把原函数和新函数都作为参数传入 before 或 after 方法:
AOP 的应用案例
1. 数据统计上报
分离业务代码和数据统计代码,无论在什么语言中,都是 AOP 的经典应用之一。在项目开发的结尾开阶段难免要加上很多统计数据的代码,这些过程可能让我们被迫改动早已封装好的函数。
showLogin 函数里既要负责打开登录图层,又要负责数据上报,这是两个层面的功能,在此处却要被耦合在一个函数里。使用 AOP 分离之后,代码如下:
2. 用 AOP 动态改变函数的参数
观察 Function.prototype.before 方法:
用 AOP 的方式给 ajax 函数动态装饰上 Token 参数,保证了 ajax 函数是一个相对纯净的函数,提高了 ajax 函数的可复用性,它在被迁往其他项目的时候,不需要做任何修改。
3. 插件式的表单验证
formSubmit 函数在此处承担了两个职责,除了提交 ajax 请求之外,还要验证用户输入的合法性。这种代码以来会造成函数臃肿,职责混乱,二来谈不上任何可复用性。代码如下:
现在的代码有一些改进,把校验的逻辑都放到了 validate 函数中,但 formSubmit 函数内部还要计算 validate 函数的返回值,因为返回的结果表明了是否通过校验。代码如下:
校验输入和提交表单的代码完全分离开开来,不再有任何耦合关系。代码如下:
函数通过 Function.prototype.before 或 Function.prototype.after 被装饰之后,返回的实际上是一个新的函数,如果在原函数保存了一些属性,那么这些属性会丢失。另外,装饰方式也叠加了函数的作用域,如果装饰的链条过长,性能上也会受到一些影响。
装饰者模式和代理模式
两者的机构看起来非常相像,都描述了怎样为对象提供一定程度上的间接引用,它们的实现部分都保留了对另外一个对象的引用,并且向那个对象发送请求。
代理模式和装饰者模式最重要的区别在于它们的意图和目的。
代理模式的目的是:当直接访问本体不方便或者不符合需求时,为这个本体提供一个替代者。本体定义了关键功能,而代理提供货拒绝对它的访问,或者在访问本体之前做一些额外的事情。
装饰者模式的作用就是为对象动态加入行为。
换句话说,代理模式强调一种关系(Proxy 与它的实体之间的关系),这种关系可以静态的表达,也就是说,这种关系在一开始就可以被确定。而装饰者模式用于一开始不能确定对象的全部功能时。代理模式通常只有一层代理-本体的引用,而装饰者模式经常会形成一条常常的装饰链。
在虚拟代理实现突破预加载的例子中,本体负责设置 img 节点的 src,代理则提供了预加载的功能,这看起来也是“加入行为”的一种方式,但这种加入行为的方式和装饰者模式的偏重点是不一样的。装饰者模式是实实在在的为对象增加新的职责和行为,而代理做的事情还是跟本体一样,最终都是设置 src。但代理可以加入一些“聪明”的功能,比如在图片真正加载好之前,先使用一张站位的 loading 图片。
小结
除了上述提到的例子,装饰者模式在框架开发中也十分有用。作为框架坐着,我们希望框架里的函数提供的是一些稳定而方便移植的功能,那些个性化的功能可以在框架之外动态装饰上去,这可以避免为了让框架拥有更多功能,而去使用一些 if、else 语句预测用户的实际需要。
第十六章 状态模式
状态模式的关键是区分事物内部的状态,事物内部状态的改变往往会带来事物的行为改变。
初识状态模式
状态模式改进电灯程序
通常我们谈到封装,一般都会优先封装对象的行为,而不是对象的状态。但在状态模式中刚好相反,状态模式的关键是把事物的每种状态都封装成单独的类,跟此种状态有关的行为都被封装在这个类的内部,所以 button 被按下的时候,只需要在上下文中,把这个请求委托给当前的状态对象即可,该状态对象会负责执行它自身的行为,如下图所示:
同时我们还可以把状态的切换规则事先分布在状态类中,这样就有效地消除了原本存在的大量条件分支语句,如下图所示:
状态模式使得每一种状态和它对应的行为之间的关系局部化,这些行为被分散和封装在各自对应的状态类之中,便于阅读和管理代码。
当需要增加一种新状态时,只需增加一个新的状态类,再更改与之相关联的状态即可。
缺少抽象类的变通方式
我们看到,在状态类中将定义一些共同的行为方法,Context 最终会将请求委托给状态对象的这些方法。在上述例子中,这个方法就是 buttonWasPressed。无论增加了多少种状态类,它们都必须实现 buttonWasPressed 方法。
由于 JavaScript 既不支持抽象类,也没有接口的概念,所以难以避免出现状态类未定义 buttonWasPressed 方法,导致在状态切换时抛出异常。
另一个状态模式示例——文件上传
实际上,不论是文件上传,还是音乐,视频播放器,都可以找到一些明显的状态区分。比如文件上传程序中有扫描、正在上传、暂停、上传成功、上传失败这几种状态,音乐播放器可以分为加载中、正在播放、暂停、播放完毕这几种状态。点击同一个按钮,在上传中和暂停状态下的行为表现是不一样的。
相对于电灯的例子,文件上传不同的地方在于,现在我们将面临更加复杂的条件切换关系。电灯的状态总是循规蹈矩的 A->B->C->A,所以即使不使用状态模式来编写电灯的程序,而是使用原始的 if、else 来控制状态切换也能胜任。
而文件上传的状态切换相比要复杂得多,有暂停/继续上传与删除文件两个按钮:
一些准备工作
在本例子中,上传是一个异步的过程,所以控件会不停地调用 JavaScript 提供的一个全局函数 window.external.upload,来通知 JavaScript 目前的上传进度,控件会把当前的文件状态作为参数 state 塞进 window.external.upload。这里我们简单地用 setTimeout 来模拟文件的上传进度,window.external.upload 函数在此例也只负责打印一些 log。
状态模式重构文件上传程序
状态模式的优缺点
优点:
缺点:
状态模式和策略模式的关系
两者像是一对双胞胎,都封装了一系列算法或行为。它们的类图看起来几乎一致,但在意图上有很大不同。
两者相同点是:它们都有一个上下文、一些策略或状态类,上下文把请求委托给这些类来执行。
它们之间的区别是:
JavaScript 版本的状态机
前面两个示例都是模拟传统面向对象语言的状态模式实现,我们为每种状态都定义一个状态之类,然后在 Context 中持有这些状态对象的引用,以便把 currState 设置为当前的状态对象。
状态模式是状态机的实现之一。JavaScript 可以非常方便地使用委托技术,并不需要事先让一个对象持有另一个对象。下面的状态机选择了通过 Function.prototype.call 方法直接把请求委托给某个字面量对象来执行。
表驱动的有限状态机
基于表驱动的是实现状态机的另一种方式。我们可以在表中很清楚地看到下一个状态是由当前状态和行为共同决定。这样一来,我们就可以在表中查找状态,而不必定义很多条件分支。
Github 上有个对应的库实现,通过这个库,可以很方便地创建出 FSM:
库地址:https://github.com/jakesgordon/javascript-state-machine
第十七章 适配器模式
适配器模式的作用是解决两个软件实体间的接口不兼容的问题。使用适配器模式之后,原本由于接口不兼容而不能工作的两个软件实体可以一起工作。
适配器的别名是包装器(wrapper)。
适配器模式的应用
适配器是一种“亡羊补牢”的模式,我们不会在程序设计之初就使用它。
小结
适配器模式是一种相对简单的模式。在本书提及的设计模式中,有一些模式跟适配器模式的结构非常相似,如装饰者模式、代理模式和外观模式(第十九章提及)。这几种模式都属于“包装模式”,都是由一个对象来包装另一个对象。区别它们的关键仍是模式的意图。
第三部分 设计原则和编程技巧
设计原则通常指单一职责原则、里氏替换原则、依赖倒置原则、接口隔离原则、合成复用原则和最少知识原则。
第十八章 单一职责原则 SRP
单一原则的职责被定义为“引起变化的原因”。如果我们有两个动机去改写一个方法,那么这个方法就具有两个职责。职责越多,那么在需求的变迁过程中,需要改写这个方法的可能性就越大。
SRP 的原则体现为:一个对象(方法)只能做一件事。
SRP 原则在很多设计模式中都有着广泛的应用,如代理模式、迭代器模式、单例模式和装饰者模式。
第十九章 最少知识原则 LKP
最少知识原则说的是一个软件实体应当尽可能少地与其他实体发生相互作用。这里的软件实体是一个广义的概念,不仅包括对象,还包括系统、类、模块、函数、变量等。
减少对象之间的联系
单一职责原则指导我们把对象划分成较小的粒度,这样可以提高对象的可复用性。但越来越多的对象之间可能会产生错综复杂的关系,如果修改了其中一个对象,很可能会影响到跟它相互引用的其他对象。
最少知识原则要求我们在设计程序时,应该尽量减少对象之间的交互。如果两个对象之间不必彼此直接通信,那么这两个对象就不要发生直接的互相联系。常见的做法是引入一个第三者对象来承担这些对象之间的通信作用。
设计模式中的最少知识原则
最少知识原则在设计模式中体现得最多的地方是中介者模式和外观模式。
外观模式
外观模式主要是为子系统的一组接口提供一个一致的界面,外观模式定义了一个高层接口,使得子系统更佳容易使用。
外观模式的作用是对客户屏蔽一组子系统的复杂性。外观模式对客户提供了一个简单易用的高层接口,高层接口会把客户的请求转发给子系统来完成具体的功能实现。请求外观并不是强制的,如果外观不能满足客户的个性化需求,那么客户也可以选择越过外观来直接访问子系统。
全自动洗衣机的一键洗衣按钮就是一个外观。
第二十章 开放-封闭原则
定义:软件实体(类、模块、函数)等应该是可以扩展的,但是不可修改。
扩展 window.onload 函数
为 window.onload 新增需求,我们往往是直接在原来函数内进行修改。但修改代码是一种危险的行为。因此,这里我们可以通过装饰者模式在不修改原代码的情况下,新增函数。新增代码和原代码可以进水不犯河水。
开放-封闭原则的思想:当需要改变一个程序的功能或者给这个程序增加新功能时,可以使用增加代码的方式,但是不允许改动程序的源代码。
用对象的多态性消除条件分支
过多的条件分支语句是造成程序违反开放-封闭原则的一个常见原因。每当增加一个新的 if 语句时,都要被迫改动原函数。把 if 换成 switch-case 是没用的,这是换汤不换药的做法。实际上,每当我们看到一大片的 if 或者 switch-case 语句时第一时间就应该考虑能否利用对象的多态性来重构它们。
利用对象的多态性来让程序遵守开放-封闭原则是一个常用的技巧。
找出变化的地方
开放-封闭原则并没有实际的模板教导我们怎样亦步亦趋地实现它。但我们还是能找到一些让程序尽量遵守开放-封闭原则的规律,最明显的就是找出程序中将要发生变化的地方,然后把变化封装起来。
其他可以帮助我们编写遵守开发-封闭原则代码的方式还有:放置挂钩(第十一章——模板方法模式)、使用回调函数。
设计模式中的开放-封闭原则
几乎所有的设计模式都遵守开放-封闭原则。可以这么说,开放-封闭原则是编写一个好程序的目标,其他设计原则都是达到这个目标的过程。
读书笔记至此完毕!