设计模式

简介

本文参考掘金博主九思 文章地址

设计模式代表了最佳的实践,通常被有经验的面向对象的软件开发人员所采用。设计模式是软件开发人员在软件开发过程中面临的一般问题的解决方案。这些解决方案是众多软件开发人员经过相当长的一段时间的试验和错误总结出来的。
设计模式是一套被反复使用的、多数人知晓的、经过分类编目的、代码设计经验的总结。使用设计模式是为了重用代码、让代码更容易被他人理解、保证代码可靠性。 毫无疑问,设计模式于己于他人于系统都是多赢的,设计模式使代码编制真正工程化,设计模式是软件工程的基石,如同大厦的一块块砖石一样。

设计模式原则

S - 单一职责原则

  • 一个程序只做好一件事
  • 功能复杂就拆分,保证每个部分独立
    O - 开闭原则
  • 对外扩展开放,对修改封闭
  • 增加需求时,扩展代码,而非修改已有代码
    L - 李氏替换原则
  • 子类能覆盖父类
  • 父类能出现的地方子类就能出现
    I - 接口隔离
  • 保持接口的单一独立
    D - 依赖倒置
  • 面向接口编程,依赖于抽象而不依赖于具体
  • 使用方法只关注接口,而不关注具体实现

设计模式分类

创建型

  • 单例模式
  • 原型模式
  • 工厂模式
  • 抽象工厂模式
  • 建造者模式

结构型

  • 适配器模式
  • 装饰器模式
  • 代理模式
  • 外观模式
  • 桥接模式
  • 组合模式
  • 享元模式

行为型

  • 观察者模式
  • 迭代器模式
  • 策略模式
  • 模板方法模式
  • 职责链模式
  • 命令模式
  • 备忘录模式
  • 状态模式
  • 访问者模式
  • 中介者模式
  • 解释器模式

工厂模式

工厂模式定义一个用于创建对象的接口,这个接口由子类决定实例化哪一个类。该模式使一个类的实例化延迟到了子类。而子类可以重写接口方法以便创建的时候指定自己的对象类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Product {
constructor(name) {
this.name = name
}
init() {
console.log('init')
}
fun() {
console.log('fun')
}
}

class Factory {
create(name) {
return new Product(name)
}
}

// use
let factory = new Factory()
let p = factory.create('p1')
p.init()
p.fun()

适用场景

如果你不想让某个子系统与较大的那个对象之间形成强耦合,而是想运行时从许多子系统中进行挑选的话,那么工厂模式是一个理想的选择
将new操作简单封装,遇到new的时候就应该考虑是否用工厂模式;
需要依赖具体环境创建不同实例,这些实例都有相同的行为,这时候我们可以使用工厂模式,简化实现的过程,同时也可以减少每种对象所需的代码量,有利于消除对象间的耦合,提供更大的灵活性

优点

创建对象的过程可能很复杂,但我们只需要关心创建结果。
构造函数和创建者分离, 符合“开闭原则”
一个调用者想创建一个对象,只要知道其名称就可以了。
扩展性高,如果想增加一个产品,只要扩展一个工厂类就可以。

缺点

添加新产品时,需要编写新的具体产品类,一定程度上增加了系统的复杂度
考虑到系统的可扩展性,需要引入抽象层,在客户端代码中均使用抽象层进行定义,增加了系统的抽象性和理解难度

什么时候不用
当被应用到错误的问题类型上时,这一模式会给应用程序引入大量不必要的复杂性.除非为创建对象提供一个接口是我们编写的库或者框架的一个设计上目标,否则我会建议使用明确的构造器,以避免不必要的开销。
由于对象的创建过程被高效的抽象在一个接口后面的事实,这也会给依赖于这个过程可能会有多复杂的单元测试带来问题。
例子

曾经我们熟悉的JQuery的$()就是一个工厂函数,它根据传入参数的不同创建元素或者去寻找上下文中的元素,创建成相应的jQuery对象

1
2
3
4
5
6
7
8
9
10
11
12
13
class jQuery {
constructor(selector) {
super(selector)
}
add() {

}
// 此处省略若干API
}

window.$ = function(selector) {
return new jQuery(selector)
}

vue 的异步组件

在大型应用中,我们可能需要将应用分割成小一些的代码块,并且只在需要的时候才从服务器加载一个模块。为了简化,Vue 允许你以一个工厂函数的方式定义你的组件,这个工厂函数会异步解析你的组件定义。Vue 只有在这个组件需要被渲染的时候才会触发该工厂函数,且会把结果缓存起来供未来重渲染。例如:

1
2
3
4
5
6
7
8
Vue.component('async-example', function (resolve, reject) {
setTimeout(function () {
// 向 `resolve` 回调传递组件定义
resolve({
template: '<div>I am async!</div>'
})
}, 1000)
})

单例模式

一个类只有一个实例,并且提供一个访问他的全局访问点。

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
class LoginForm {
constructor() {
this.state = 'hide'
}
show() {
if (this.state === 'show') {
alert('已经显示')
return
}
this.state = 'show'
console.log('登录框显示成功')
}
hide() {
if (this.state === 'hide') {
alert('已经隐藏')
return
}
this.state = 'hide'
console.log('登录框隐藏成功')
}
}
LoginForm.getInstance = (function () {
let instance
return function () {
if (!instance) {
instance = new LoginForm()
}
return instance
}
})()

let obj1 = LoginForm.getInstance()
obj1.show()

let obj2 = LoginForm.getInstance()
obj2.hide()

console.log(obj1 === obj2)

优点

  • 划分命名空间,减少全局变量
  • 增强模块性,把自己的代码组织在一个全局变量名下,放在单一位置,便于维护
  • 且只会实例化一次。简化了代码的调试和维护

缺点

由于单例模式提供的是一种单点访问,所以它有可能导致模块间的强耦合 从而不利于单元测试。无法单独测试一个调用了来自单例的方法的类,而只能把它与那个单例作为一个单元一起测试。

场景例子

  • 定义命名空间和实现分支型方法
  • 登录框
  • vuex 和 redux中的store

适配器模式

将一个类的接口转化为另外一个接口,以满足用户需求,使类接口之间不兼容问题通过适配器得以解决。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Plug {
getName() {
return 'iphone充电头';
}
}

class Target {
constructor() {
this.plug = new Plug();
}
getName() {
return this.plug.getName() + ' 适配器Type-c充电头';
}
}

let target = new Target();
target.getName(); // iphone充电头 适配器转Type-c充电头

优点

  • 可以让任何两个没有关联的类一起运行。
  • 提高了类的复用。
  • 适配对象,适配库,适配数据

缺点

  • 额外对象的创建,非直接调用,存在一定的开销(且不像代理模式在某些功能点上可实现性能优化)
  • 如果没必要使用适配器模式的话,可以考虑重构,如果使用的话,尽量把文档完善

场景

  • 整合第三方SDK
  • 封装旧接口
  • Vue的computed

装饰者模式

  • 动态地给某个对象添加一些额外的职责,是一种实现继承的替代方案。
  • 在不改变原对象的基础上,对其进行包装扩展,使原有对象可以满足用户更复杂的需求,而不会影响从这个类派生的其他对象。

场景例子

  • 比如现在有4 种型号的自行车,我们为每种自行车都定义了一个单独的类。现在要给每种自行车都装上前灯、尾灯和铃铛这3 种配件。如果使用继承的方式来给每种自行车创建子类,则需要 4×3 = 12 个子类。但是如果把前灯、尾灯、铃铛这些对象动态组合到自行车上面,则只需要额外增加3 个类
  • ES7 Decorator 阮一峰
  • core-decorators

优点

  • 装饰类和被装饰类都只关心自身的核心业务,实现了解耦。
  • 方便动态的扩展功能,且提供了比继承更多的灵活性。

缺点

  • 多层装饰比较复杂。
  • 常常会引入许多小对象,看起来比较相似,实际功能大相径庭,从而使得我们的应用程序架构变得复杂起来

代理模式

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

优点

  • 代理模式能将代理对象与被调用对象分离,降低了系统的耦合度。代理模式在客户端和目标对象之间起到一个中介作用,这样可以起到保护目标对象的作用
  • 代理对象可以扩展目标对象的功能;通过修改代理对象就可以了,符合开闭原则;

缺点

  • 处理请求速度可能有差别,非直接访问存在开销

不同点

  • 装饰者模式实现上和代理模式类似
  • 装饰者模式: 扩展功能,原有功能不变且可直接使用
  • 代理模式: 显示原有功能,但是经过限制之后的

观察者模式

定义了一种一对多的关系,让多个观察者对象同时监听某一个主题对象,这个主题对象的状态发生变化时就会通知所有的观察者对象,使它们能够自动更新自己,当一个对象的改变需要同时改变其它对象,并且它不知道具体有多少对象需要改变的时候,就应该考虑使用观察者模式。

  • 发布订阅
  • 一对多
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
40
41
42
// 主题 保存状态,状态变化之后触发所有观察者对象
class Subject {
constructor() {
this.state = 0
this.observers = []
}
getState() {
return this.state
}
setState(state) {
this.state = state
this.notifyAllObservers()
}
notifyAllObservers() {
this.observers.forEach(observer => {
observer.update()
})
}
attach(observer) {
this.observers.push(observer)
}
}

// 观察者
class Observer {
constructor(name, subject) {
this.name = name
this.subject = subject
this.subject.attach(this)
}
update() {
console.log(`${this.name} update, state: ${this.subject.getState()}`)
}
}

// 测试
let s = new Subject()
let o1 = new Observer('o1', s)
let o2 = new Observer('02', s)

s.setState(12)

场景

  • websocket中有玩家进入房间,也就是房间状态改变了,需要告知此房间中的每一位玩家。

优点

  • 支持简单的广播通信,自动通知所有已经订阅过的对象
  • 目标对象与观察者之间的抽象耦合关系能单独扩展以及重用
  • 增加了灵活性
  • 观察者模式所做的工作就是在解耦,让耦合的双方都依赖于抽象,而不是依赖于具体。从而使得各自的变化都不会影响到另一边的变化。

缺点

过度使用会导致对象与对象之间联系弱化,会导致程序难以跟踪维护和理解。

状态模式

允许一个对象在其内部状态改变的时候改变它的行为,对象看起来似乎修改了它的类

持续更新中…