通用设计模式

设计模式(design pattern)

本质上是把经常用到的代码套路,归纳总结后系统的表达出来。

学习设计模式好处有以下几点:

  1. 为开发人员对代码的阅读和编写提供一种指导思想
  2. 提高开发人员自身的代码质量,提高对其他框架和库源码阅读的能力;
  3. 良好的设计能力是一个中高级工程师的必备技能,对开发人员在职场上的晋升有很大帮助;
  4. 在团队协作中用更少的词汇做更充分的沟通;

网络上流行的”23种设计模式”是静态语言在生产过程中的经验总结,由于语言的特性,其中有些设计模式在 Javascript代码的编写过程中,有的可能应用场景很少,有的语言本身的特性就已经实现。因此,本课程不会直接生搬硬套“23种设计模式”,而是基于 Javascript 这门语言的特性和应用场景,针对性的进行学习。

工厂模式

工厂模式(Factory)是用来创建对象的一种最常用的设计模式。我们不暴露创建对象的具体逻辑,而是将逻辑封装在一个函数中,那么这个函数就可以被视为一个工厂。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function createUser(role) {
function User(options) {
this.name = options.name
this.viewPage = options.viewPage
}
switch(role){
case 'superAdmin':
return new User({name:'超级管理员',viewPage:['首页','通讯录','发现页','应用数据','权限管理']});
break;
case 'admin':
return new User({name:'管理员',viewPage:['首页','通讯录','发现页','应用数据']});
break;
case 'user':
return new User({name:'普通用户',viewPage:['首页','通讯录','发现页']});
break;
default:
throw new Error('参数错误')
}
}
createUser('admin')

上面的代码就是一种最常见的工厂模式。

如果要把多个构造函数生成实例的逻辑封装到某个工厂中,也可以将构造函数挂载到工厂函数的原型链上,或者工厂函数的静态方法中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function createUser(role) {
return new this[role]
}
createUser.prototype.superAdmin = function () {
this.name = '超级管理员'
this.viewPage = ['首页','通讯录','发现页','应用数据','权限管理']
}
createUser.prototype.admin = function () {
this.name = '管理员'
this.viewPage = ['首页','通讯录','发现页','应用数据']
}
createUser.prototype.user = function () {
this.name = '普通用户'
this.viewPage = ['首页','通讯录','发现页']
}

new createUser('admin')

以下几种情景下,开发者应该考虑使用工厂模式:

  • 对象的构建十分复杂
  • 需要依赖具体环境创建不同实例
  • 处理大量具有相同属性的小对象

示例:

Vue源码

单例模式

单例模式(Singleton)思想在于保证一个特定类最多只能有一个实例,意味着当你第二次使用同一个类创建信对象时,应得到和第一次创建对象完全相同。

在 JS 中实现单例模式的通常思路是:将已经生成的对象通过闭包进行维护,下次再次生成新对象时,就直接返回老对象。

1
2
3
4
5
6
7
8
9
let SingleUser1 = (function () {
let instance = null
return function User() {
if(instance){
return instance
}
return instance = this
}
})()

将构造函数User和判断实例是否存在的逻辑解耦:

1
2
3
4
5
6
7
8
9
10
11
12
let SingleUser2 = (function () {
let instance = null
function User(name) {
this.name = name
}
return function (name) {
if(instance){
return instance
}
return instance = new User(name)
}
})()

对将一个构造函数单例化的逻辑可以进一步封装:

1
2
3
4
5
6
7
8
9
let singleton = function (fn) {
let instance = null
return function (args) {
if(instance){
return instance
}
return instance = new fn(args)
}
}

凡是使用唯一对象的场景,都适用于单例模式,例如登录框,弹窗,遮罩。另外ES6和CommonJS模块化语法中导出的对象也是单例

1
export default new Vuex.Store({/**/})

示例:

日常工作中,我们经常需要实现一个遮罩层,来防止用户中断页面操作。所谓的遮罩层,就是一个大小跟窗口一致的半透明div层。我们要求页面最多只能存在一个遮罩层,此时就非常适合使用单例模式:

1
2
3
4
5
6
7
8
9
10
11
//例子:生成遮罩

let createMask = singleton( function(){
let mask = document.createElement('div')
mask.style.background = 'red'
mask.style.width = '100%'
mask.style.height = '100%'
mask.style.position = 'fixed'
document.body.appendChild( mask );
return mask
})

适配器模式

适配器模式(Adapter)是将一个类(对象)的接口(方法或属性)转化成用户希望的另外一个接口(方法或属性),适配器模式使得原本由于接口不兼容而不能一起工作的那些类(对象)可以正常工作。

适配器模式在代码功能上相当于日常生活中的适配器或者转接插头。当一个插头和一个插孔不匹配时,可以在中间加一个适配器来起到兼容的效果。

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
43
class Power{
constructor() {
this.serveVoltage = 110
this.serveShape = 'triangle'
}
}
class Adaptor{
constructor(){
this.consumeVoltage = 110
this.consumeShape = 'triangle'
}
usePower(power){
if(!power){
throw new Error('请接入电源')
}
if(power.serveVoltage !== this.consumeVoltage || power.serveShape !== this.consumeShape){
throw new Error('电源规格不对')
}
this.serveVoltage = 220
this.serveShape = 'double'
}
}

class User{
constructor(){
this.consumeVoltage = 220
this.consumeShape = 'double'
}
usePower(power){
if(!power){
throw new Error('请接入电源')
}
if(power.serveVoltage !== this.consumeVoltage || power.serveShape !== this.consumeShape){
throw new Error('电源规格不对')
}
console.log('电源已接入');
}
}
let power = new Power()
let user = new User()
let adaptor = new Adaptor()
adaptor.usePower(power)
user.usePower(adaptor)

正如适配器模式的定义,开发中凡是需要对接口的提供者和消费者进行兼容时,适配器模式就可以派上用场,最常见的就是需要对旧代码的渐进式地改造,或者是对某些已有的老接口进行兼容。

示例:

对旧的ajax方法进行迁移改造,由于历史原因,无法一次性移除所有的旧代码,因此需要使用适配模式对原代码进行兼容:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ = {
ajax(options) {
let {method, url} = options
let axiosOptions = {method, url}
let dataProp = method === 'get' ? 'params' : 'data'
axiosOptions[dataProp] = options.data
return axios(axiosOptions).then((res) => {
options.success && options.success(res.data,res.status,res.request)
}).catch((err) => {
options.error && options.error(err)
})
}
}

装饰器模式

装饰器模式(Decorator)是指允许向一个现有的对象添加新的功能,同时又不改变其结构。使得多个不同类或者对象之间共享或者扩展一些方法或者行为的时候,更加优雅。

装饰器模式在生活中也可以很容易找到相关的例子:例如手机壳,他并没有改变我们手机原有的功能,比如打电话,听音乐什么的。但却为手机提供了新的功能:防磨防摔等。

以下是装饰器模式在js中实现的简单实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Phone() {

}
Phone.prototype.makeCall = function () {
console.log('拨通电话');
}

function decorate(target) {
target.prototype.code = function () {
console.log('写代码');
}
return target
}

Phone = decorate(Phone)

const phone = new Phone()

上例,通过decorate函数来装饰构造函数Phone,使得Phone的实例既可以打电话,也可以写代码。如此,我们将功能单独抽离出来,依次得到复用,例如再次使用decorate函数去装饰构造函数Pad等等

示例:

许多语言中包含装饰器语法来让编码者更方便地实现装饰器模式,例如Python等。ES7的语言标准中提出了装饰器语法,但一直处于Stage-2状态,没有正式通过。目前Node环境与所有浏览器都尚未支持装饰器语法,如果想使用装饰器语法,可以通过babel来转译:

安装babel:

npm install -D @babel/core babel-cli babel-preset-es2015 --registry https://registry.npm.taobao.org

安装babel插件来识别装饰器语法:

npm install -D babel-plugin-transform-decorators-legacy --registry https://registry.npm.taobao.org

新建Babel配置文件.babelrc

1
2
3
4
{
"presets":["es2015"],
"plugins":["transform-decorators-legacy"]
}

使用装饰器语法改造上面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function code (target) {
target.prototype.code = function () {
console.log('写代码');
}
}

@code
class Phone {
makeCall () {
console.log('打电话');
}
}

const phone = new Phone();

运行 npm script: babel index.js -o bulid.js

装饰器不光可以装饰类,还可以装饰方法

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
class Math {
@log
add(a, b) {
return a + b;
}
}

function log(target, name, descriptor) {
// 此时target是 Math.prototype , name是方法名,即'add'
// descriptor对象原来的值如下
// {
// value: specifiedFunction,
// enumerable: false,
// configurable: true,
// writable: true
// };
var oldValue = descriptor.value;
descriptor.value = function() {
console.log(`Calling ${name} with`, arguments);
return oldValue.apply(this, arguments);
};

return descriptor;
}

const math = new Math();
//现在调用add方法,则会触发log功能
math.add(2, 4);

npm包core-decorators将常用的装饰器工具进行了封装

安装:

npm install core-decorators -D --registry https://registry.npm.taobao.org

使用:

1
2
3
4
5
6
7
8
9
10
11
12
import { readonly,autobind, deprecate} from 'core-decorators';

class Phone {
@autobind
makeCall () {
console.log(this);
console.log('打电话');
}
}

const phone = new Phone();
window.phone = phone

以上代码使用了ES6模块化语法,若想在浏览器环境中运行,请使用webpack编译后引入

npm install webpack webpack-cli -D --registry https://registry.npm.taobao.org

npm script: babel index3.js -o build.js && webpack build.js

合理利用装饰器可以极大的提高开发效率,对一些非逻辑相关的代码进行封装提炼能够帮助我们快速完成重复性的工作,节省时间。例如React中的高阶组件以及使用TypeScript开发Vue等等。

同样的,滥用装饰器也会使代码本身逻辑变得扑朔迷离,如果确定一段代码不会在其他地方用到,或者一个函数的核心逻辑就是这些代码,那么就没有必要将它取出来作为一个装饰器来存在。

代理模式

代理模式(Proxy)为对象提供另一个代理对象以控制对这个对象的访问。

使用代理的原因是我们不想对原对象进行直接操作,而是通过一个“中间人”来传达操作。生活中有许多代理的例子,比如访问某个网站,不想直接访问,通过中间的一台服务器来转发请求,这台服务器就是代理服务器。又比如明星,普通人无法直接联系他们,而是通过经纪人进行联系。

使用ES6的Proxy语法实现对代理模式的简单实现:

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
43
44
45
46
47
let star = {
name:'zs',
age:21,
height:170,
bottomPrice:100000,
announcements:[],
}
let proxy = new Proxy(star,{
get:function (target,key) {
if(key === 'height'){
return target.height + 10
}else if(key === 'announcements'){
return new Proxy(target.announcements,{
set:function (target,key,value) {
if(key !== 'length' && target.length === 3){
console.log('不好意思,今年通告满了')
return true
}
target[key] = value
return true
}
})
}else{
return target[key]
}
},
set:function (target, key, value,) {
if(key === 'price'){
if(value > target.bottomPrice * 1.5){
console.log('成交');
target.price = value
}else if(value > target.bottomPrice){
console.log('咱们再商量商量');
}else {
throw new Error('下次说吧')
}
}
}
})

proxy.announcements.push('爸爸去哪儿')
proxy.announcements.push('中国好声音')
proxy.announcements.push('奇葩说')
proxy.announcements.push('快乐大本营')
proxy.price = 160000
proxy.price = 120000
proxy.price = 9000

代理模式能将代理对象与被调用对象分离,降低了系统的耦合度。代理模式在客户端和目标对象之间起到一个中介作用,这样可以起到保护目标对象的作用。代理对象也可以对目标对象调用之前进行其他操作。

示例:dom事件代理 。Vue源码。

注意区分适配器模式(Adapter),装饰器模式(Decorator),代理模式(Proxy):

适配器模式提供不同的新接口,通常用作接口转换的兼容处理

代理模式提供一模一样的新接口,对行为进行拦截

装饰器模式,直接访问原接口,直接对原接口进行功能上的增强

外观模式

外观模式(Facade),是指为一组复杂的子系统接口提供一个更高级的统一接口,通过这个接口使得对子系统接口的访问更容易。
以下是外观模式在JS中的简单实现:

1
2
3
4
5
6
7
8
9
10
11
12
function addEvent(dom, type, fn) {
if (dom.addEventListener) { // 支持DOM2级事件处理方法的浏览器
dom.addEventListener(type, fn, false)
} else if (dom.attachEvent) { // 不支持DOM2级但支持attachEvent
dom.attachEvent('on' + type, fn)
} else {
dom['on' + type] = fn // 都不支持的浏览器
}
}

const myInput = document.getElementById('myinput')
addEvent(myInput, 'click', function() {console.log('绑定 click 事件')})

外观模式核心在于对其他接口的封装,是一种开发中非常常见的设计模式,框架或者库中的工具函数遵循的模式就是外观模式。

注意区分工厂模式外观模式

工厂模式核心是对创建对象的逻辑进行封装。

外观模式核心是对不同的接口进行封装。

示例:

Vue源码。

观察者模式

通常又被称为 发布-订阅者模式 (Publisher/Subscribers):它定义了对象和对象间的一种依赖关系,只要当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新,解决了主体对象与观察者之间功能的耦合。

生活中观察者模式也是非常常见的,比如订阅公众号,订阅报纸,订阅各种媒体等:当被订阅的主体的状态发生改变,比如有新的消息,就通知订阅者。

以下是观察者模式在JS中的简单实现:

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
class Publisher{
_state = 0;
subscribers = []

get state(){
return this._state
}
set state(value){
this._state = value
this.notify(value)
}
notify(value){
this.subscribers.forEach(subscriber => subscriber.update(value))
}
collect(subscriber){
this.subscribers.push(subscriber)
}
}
let subId = 1
class Subscriber{
publisher = null;
id = subId++;
subscribe(publisher){
this.publisher = publisher
publisher.collect(this)
}
update(value){
console.log(`我是${this.id}号订阅者,收到发布者信息:${value}`);
}
}
let publisher = new Publisher()
let subscriber1 = new Subscriber()
let subscriber2 = new Subscriber()
subscriber1.subscribe(publisher)
subscriber2.subscribe(publisher)
publisher.state = 2

观察者的使用场合就是:当一个对象的改变需要同时改变其它对象,并且它不知道具体有多少对象需要改变的时候,就应该考虑使用观察者模式。

示例:各种原生事件,自定义事件。vue源码。

迭代器模式

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

迭代器模式分为 内部迭代器外部迭代器,内部迭代器就是在函数内部定义好迭代的规则,它完全接手整个迭代的过程,外部只需一次初始调用。

1
2
3
4
5
6
7
8
9
10
11
let arr = [1, 2, 3, 4, 5, 6, 7, 8]

let each = function(arr, callback){
for(let i=0; i<arr.length; i++){
callback.call(null, i, arr[i]) //把下标和元素当作参数传递给callback参数
}
}

each(arr, function(i, value){
console.log(i, value);
})

内部迭代器在调用时非常方便,但使用回调自由度有限,例如用户想在迭代途中暂停,或者迭代两个数组需要使用each嵌套。

外部迭代器是指调用者显式地请求迭代下一个元素,虽然这样做会增加调用的复杂度,但也会增强迭代的操作灵活性:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Iterator{
constructor(list){
this.list = list
this.index = 0
}
next(){
return {
value:this.list[this.index++],
done:this.index > this.list.length
}
}
}
let it = new Iterator([1,2,3])

迭代器模式可以为不同的数据结构提供一个统一的、简便的接口,从而支持同样的算法在不同的数据结构上进行迭代操作。

示例:

ES6语法中提出了迭代器的概念,以下对象默认实现了ES6规定的迭代器接口

  • Array
  • Map
  • Set
  • String
  • TypedArray
  • 函数的 arguments 对象
  • NodeList 对象

提供了调用之后能返回迭代器的接口的对象被称为可迭代对象。ES6中可以用for...of扩展运算符(...),数组解构,Array.from等语法迭代。

ES6提出的Generator函数运行后的结果就是一个可迭代对象,结合例如co的自动执行器,可以达到用同步代码写异步逻辑的目的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const co = require('co')
const {readFile} = require('fs')
const { promisify } = require("util");
const path = require('path')
const file1 = path.join(__dirname, './text/1.txt')
const file2 = path.join(__dirname, './text/2.txt')
const readFileP = promisify(readFile)

function* f() {
let data1 = yield readFileP(file1)
console.log('耶,完成了1,数据是' + data1);
let data2 = yield readFileP(file2)
console.log('耶,完成了2,数据是' + data2);
}

co(f)

npm install co -D --registry https://registry.npm.taobao.org

状态模式

状态模式(State)的定义:

通常谈到封装,一般都会优先封装对象的行为。但在状态模式中是把对象的每种状态都进行封装。

如果按照传统面向对象语言的角度来实现状态模式,第一,我们需要将状态封装成独立的类,并将请求委托给当前的状态对象,当对象的内部状态改变时,会带来不同的行为变化。第二,使用的对象,在不同的状态下具有截然不同的行为,这个对象看起来是从不同的类中实例化而来的。

状态模式在生活中很常见:十字路口的交通信号灯,一通电话有拨通,通话,挂断各个状态等等。

假设有一个台灯,一开始是关闭状态,按下开关后会变成弱光状态,再次按下按钮会变成强光状态,再次按下按钮会变成关闭状态。如果不使用状态模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Lamp {
constructor() {
this.state = 'off'
}

pressButton() {
if (this.state === 'off') {
console.log('弱光');
this.state = 'weakLight';
} else if (this.state === 'weakLight') {
console.log('强光');
this.state = 'strongLight';
} else if (this.state === 'strongLight') {
console.log('关灯');
this.state = 'off';
}
};
}

这样做存在有如下问题:

  • 每次新增或者修改状态名,都需要改动pressButton方法中的代码,这使得pressButton成为了一个非常不稳定的方法。并且这个方法的代码量会迅速膨胀,因为状态改变后的逻辑在实际开发中不仅仅只是例子里的打印
  • 状态之间的切换关系,不过是往pressButton方法里堆砌if、else语句,增加或者修改一个状态可能需要改变若干个操作,这使pressButton更加难以阅读和维护

如果使用状态模式,将状态封装成类,在JS中可以这样实现:

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
class Lamp {
constructor() {
this.offLightState = new OffLightState()
this.weakLightState = new WeakLightState()
this.strongLightState = new StrongLightState()
this.state = this.offLightState
}
pressButton(){
this.state.trigger.call(this)
}
}
class OffLightState {
trigger (){
console.log( '弱光' );
this.state = this.weakLightState
};
}
class WeakLightState {
trigger (){
console.log( '强光' );
this.state = this.strongLightState
};
}
class StrongLightState {
trigger (){
console.log( '关灯' );
this.state = this.offLightState;
};
}

如果状态不需要封装成类,可以用普通对象代替:

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
class Lamp {
constructor() {
this.state = FSM.offLightState
}
pressButton(){
this.state.trigger.call(this)
}
}
//有限状态机
//1. 状态总数是有限的
//2. 任一时刻,只处在一种状态之中
//3. 某种条件下,会从一种状态转变到另外一种状态
const FSM = {
offLightState: {
trigger() {
console.log('弱光');
this.state = FSM.weakLightState
}
},
weakLightState: {
trigger(){
console.log('强光');
this.state = FSM.strongLightState
}
},
strongLightState:{
trigger(){
console.log('关灯');
this.state = FSM.offLightState
}
}
};

以下这些情况中,我们应该考虑状态模式:

  1. 一个对象的行为取决于它的状态,并且它必须在运行时刻根据状态改变它的行为。
  2. 一个操作中含有大量的分支语句,而且这些分支语句依赖于该对象的状态。

示例:

业务代码:tab栏,货物物流状态,音乐播放器的循环模式,游戏中各种状态等

其他设计模式

桥接模式

将抽象与实现分离

1
2
3
4
5
6
let each = function (arr,callback) {
for (let i = 0; i < arr.length; i++) {
callback.call(null,i,arr[i])
}
}

1
2
3
4
5
6
7
8
9
10
let singleton = function (fn) {
let instance = null
return function (args) {
if(instance){
return instance
}
return instance = new fn(args)
}
}
let singleUser = singleton(User)

组合模式

又称整体-部分模式。将所有对象组成树形结构,只要操作某一层,下面所有的对象都执行相同的操作。

验证表单下所有字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
let form = {
fields:[]
}
form.validate = function () {
let isFormLegal = true
this.fields.forEach(field => {
let isLegal = field.validate()
if(!isLegal){
isFormLegal = false
}
})
return isFormLegal
}

享元模式

将对象的公共部分抽离并且缓存,如此一来在创建大量对象时可以节省内存开销。

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
function Iphone(model, screen, memory, SN) {
this.flyweight = flyweightFactory.get(model, screen, memory);
this.SN = SN;
}
function IphoneFlyweight(model, screen, memory) {
this.model = model;
this.screen = screen;
this.memory = memory;
}
let flyweightFactory = (function () {
let iphones = {};
return {
get: function (model, screen, memory) {
let key = model + screen + memory;
if (!iphones[key]) {
iphones[key] = new IphoneFlyweight(model, screen, memory);
}
return iphones[key];
}
};
})();

let phones = [];
for (let i = 0; i < 1000000; i++) {
let memory = i % 2 == 0 ? 16 : 32;
phones.push(new Iphone("iphone6s", 5.0, memory, i));
}
console.log(phones);

享元模式的应用:线上表格,可以将dom抽离出来,看上去是在滚动,实际只是改变当前单元格的内容。省去了大量创建dom的开销。

策略模式

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let obj = {
"A": function(salary) {
return salary * 4;
},
"B" : function(salary) {
return salary * 3;
},
"C" : function(salary) {
return salary * 2;
}
};
let calculateBonus =function(level,salary) {
return obj[level](salary);
};
console.log(calculateBonus('A',10000)); // 40000

有点像状态模式,但是状态模式是把状态提取成对象,策略模式是把策略提取成函数

备忘录模式

撤销到上次的状态。把原先的数据保存下来,一旦需要撤销到上次状态就把上次状态的数据覆盖回来。

vuex时光旅行

中介者模式

使用一层中介者,来处理多对多的逻辑关系。

mvc模式中的controller