设计模式的定义:在面向对象软件设计过程中针对特定问题的简洁而优雅的解决方案。
设计模式的原则:找出程序中变化的地方,并将变化封装起来。
设计模式可以分为3种类型:
- 创建型模式:创建型模式(Creational Pattern)对类的实例化过程进行了抽象,能够将软件模块中对象的创建和对象的使用分离。
- 简单工厂模式
- 工厂方法模式
- 抽象工厂模式
- 建造者模式
- 单例模式
- 原型模式
- 结构型模式:结构型模式(Structural Pattern)描述如何将类或者对 象结合在一起形成更大的结构,就像搭积木,可以通过 简单积木的组合形成复杂的、功能更为强大的结构。
- 适配器模式
- 桥接模式
- 装饰模式
- 外观模式
- 享元模式
- 代理模式
- 行为型模式:行为型模式(Behavioral Pattern)是对在不同的对象之间划分责任和算法的抽象化。
- 命令模式
- 中介者模式
- 观察者模式
- 状态模式
- 策略模式
- 职责链模式
- 备忘录模式
- 模板方法模式
- 解释器模式
- 迭代器模式
单例模式
定义:保证一个类仅有一个实例,并提供一个访问它的全局访问点。
单例模式的要点在于是否有一个变量来判断该类已经生成实例,在获取实例时如果有实例就直接返回该实例,不需要重新创建。
let Singleton = function (name) {
this.name = name;
};
Singleton.prototype.getName = function () {
return this.name;
};
Singleton.getInstance = (function () {
let instance = null;
return function (name) {
if (!instance) {
instance = new Singleton(name)
}
return instance;
}
})();
let s1 = Singleton.getInstance('Yuki');
let s2 = Singleton.getInstance('Yuki');
console.log(s1 === s2);
上述代码是简单的单例类的实现,getInstance
接口中的 instance
变量用来判断是否存在实例,从结果中可以发现实例 s1
与实例 s2
是同一个对象。不过通过 getInstance
接口获取实例不太符合 JavaScript 中的创建对象的使用方式(一般用 new 进行创建),在此基础上可以改写为透明的单例模式
let Singleton = (function () {
let instance = null;
return function () {
this.name = 'Yuki';
if (!instance) {
instance = this;
}
return instance;
}
})();
Singleton.prototype.getName = function () {
return this.name;
};
let s1 = new Singleton();
let s2 = new Singleton();
console.log(s1 === s2);
console.log(s2.getName());
上述代码是传统面向对象语言的单例实现思路,由于 JavaScript 中没有类的概念,所以也就关于单例的实现思路也不太一样,在单例模式中有个核心概念是确保只有一个实例,并提供全局访问,那么 JavaScript 中的全局变量就是一个单例,比如 var a = {}
。
不过 JavaScript 中的全局变量存在许多问题,并且容易造成变量污染,如果在项目中使用类似方法作为单例最佳方案是使用命名空间或者闭包形成私有变量来使用。
惰性单例指的是在需要时才创建对象实例,之前的实例中也是满足惰性单例的条件的。
以下是全局变量和惰性单例在 JavaScript 中的实际应用场景:在网页中创建唯一的登录窗口。
let createLoginLayer = (function () {
let div = null;
return function () {
if (!div) {
div = document.createElement('div');
div.innerHTML = '登录窗口';
document.body.appendChild(div);
}
return div;
}
})();
// 创建3次,只会渲染一个DOM。
createLoginLayer();
createLoginLayer();
createLoginLayer();
上述代码完成了一个可用的惰性单例,不过存在一些问题,首先是创建DOM与创建单例的逻辑在都放在 createLoginLayer
函数中,违反了单一职责原则,其次,如果有类似的需求比如创建唯一的聊天窗口等,需要重写一次单例逻辑,所以应该将创建DOM的逻辑和创建单例的逻辑放置在不同的方法中,使其相互独立。
let createLoginLayer = function () {
let div = document.createElement('div');
div.innerHTML = '登录窗口';
document.body.appendChild(div);
return div;
};
let createChatLayer = function () {
let div = document.createElement('div');
div.innerHTML = '聊天窗口';
document.body.appendChild(div);
return div;
};
let getSingleton = function (fn) {
let instance = null;
return function () {
if (!instance) {
instance = fn.apply(this, arguments);
}
return instance;
}
};
let createSingleLoginLayer = getSingleton(createLoginLayer);
let createSingleChatLayer = getSingleton(createChatLayer);
createSingleLoginLayer();
createSingleLoginLayer();
createSingleLoginLayer();
createSingleChatLayer();
createSingleChatLayer();
createSingleChatLayer();
上述代码中将单例的创建逻辑放在 getSingleton
中,然后各自的需要单例的内部逻辑放在自身中,然后通过组合实现具体的单例需求。
JavaScript 中对于单例模式的使用不仅仅在于创建具体的某个对象上,还可以应用在只需要进行一次的行为上,比如在界面渲染后需要对某个元素绑定事件,但是不能每次渲染都进行绑定。
let bindEvent = getSingleton(function () {
console.log('bind event');
document.body.addEventListener('click', function () {
console.log('click body');
});
return true;
});
let render = function () {
console.log('渲染界面');
bindEvent();
};
// 渲染3次只会进行1次事件绑定
render();
render();
render();
策略模式
定义:定义一系列的算法,把它们一个个封装起来,并且使他们可以相互替换。
策略模式的目的是将算法的使用与算法的实现分离开来。
一个基于策略模式的程序至少由两部分组成:
- 策略类。策略类封装了具体的算法,并且负责具体的计算过程;
- 环境类。环境类维护对某个策略类的引用,在接受到客户请求后将请求委托给该策略类;
假设有一个需要计算奖金的需求,公司会根据员工的绩效等级和基本薪资进行奖金发放:
let calculateBonus = function (level, salary) {
let bonus = 0;
switch (level) {
case 'S':
bonus = salary * 4;
break;
case 'A':
bonus = salary * 3;
break;
case 'B':
bonus = salary * 2;
break;
default:
}
return bonus;
};
console.log(calculateBonus('S', 2000));
console.log(calculateBonus('A', 3000));
上述代码完成了根据不同的绩效和基本薪资计算奖金的任务,但是有许多问题:
calculateBonus
接口过于臃肿,所有的算法都写在其内部;- 可扩展性差,如果要调整算法或者是增加/删除算法只能到
calculateBonus
内部进行实现; - 可移植性差,如果其他地方需要里面的算法只能复制/粘贴代码;
使用策略模式进行重构:
// 定义策略类
let LevelS = function () {};
LevelS.prototype.calculate = function (salary) {
return salary * 4;
};
let LevelA = function () {};
LevelA.prototype.calculate = function (salary) {
return salary * 3;
};
let LevelB = function () {};
LevelB.prototype.calculate = function (salary) {
return salary * 2;
};
// 定义环境类
let Bonus = function () {
this.level = null;
this.salary = null;
};
Bonus.prototype.setLevel = function (level) {
this.level = level;
};
Bonus.prototype.setSalary = function (salary) {
this.salary = salary;
};
Bonus.prototype.getBonus = function () {
return this.level.calculate(this.salary);
};
let bonus = new Bonus();
bonus.setLevel(new LevelS());
bonus.setSalary(2000);
console.log(bonus.getBonus());
bonus.setLevel(new LevelA());
bonus.setSalary(3000);
console.log(bonus.getBonus());
在上述代码中,LevelS
、LevelA
等是封装了具体算法的策略类,而 Bonus
是环境类,内部通过 level
属性维持对当前策略类的引用,通过 setLevel
接口切换不同策略类。
在 JavaScript 中,函数是一等公民,可以当做参数传递,也可以当做值返回。所以在 JavaScript 中的策略模式其实并不需要集体的策略类和环境类,只需要提供策略和环境即可。
let strategy = {
levelS: function (salary) {
return salary * 4;
},
levelA: function (salary) {
return salary * 3;
},
levelB: function (salary) {
return salary * 2;
},
};
let getBonus = function (level, salary) {
return level(salary);
};
console.log(getBonus(strategy.levelS, 2000));
console.log(getBonus(strategy.levelA, 3000));
上述代码中,策略类的实体由不同的策略函数代替,环境类的实体由 getBonus
函数代替,getBonus
函数接受当前的策略函数和基本值,然后计算出不同绩效和基本薪资的奖金。
在实际的开发过程中,算法的定义可以广泛一点,将某些业务规则作为策略类封装起来,然后再使用。比如说有多种验证规则(邮箱验证、数字验证)的表单验证需求。
值得注意的是,JavaScript中高阶函数就是策略模式的隐式实现。
优点:
- 策略模式利用组合、委托和多态等技术和思想,可以有效地避免多重条件选择语句;
- 策略模式将算法封装在独立的strategy中,使得它们易于切换,易于理解,易于扩展,符合开放-封闭原则;
- 策略模式的算法易于移植;
- 在策略模式中利用组合和委托来让Context拥有执行算法的能力,这也是继承的一种更轻便的替代方案;
缺点:
- 在程序中额外增加许多策略类或者策略对象;
- strategy要向外部暴露它的所有实现,违反最少知识原则;
代理模式
定义:代理模式是为一个对象提供一个代用品或占位符,以便控制对它的访问。
代理模式的关键是,当客户不方便直接访问一个对象或者不满足需求的时候,提供一个替身对象来控制这个对象的访问,客户实际上访问的是替身对象。
现在有个需求是顾客去商店买东西。
let customer = {
buySth: function (store) {
store.sellSth();
}
};
let store = {
sellSth: function () {
console.log('success');
}
};
customer.buySth(store);
上述代码无代理,customer
直接访问 store
。
let proxyStore = {
sellSth: function () {
store.sellSth();
},
};
customer.buySth(proxyStore);
上述代码有代理,customer
访问 proxyStore
,proxyStore
访问 store
。
上面的例子看起来除了增加了代码量之外没有其他用处,但是再更为复杂的需求中代理就会变得更加重要。例如,商店可能当时没货,需要过一段时间才会补货,在没有代理的情况下,顾客只能多跑几次才可能买到想要的东西,而在有代理的情况下,顾客只需告诉代理他要买东西,然后代理再根据具体情况去商店进行购买。
无代理:
let customer = {
buySth: function (store) {
return store.sellSth();
}
};
let store = {
hasSth: false,
sellSth: function () {
if (this.hasSth) {
console.log('success');
} else {
console.log('fail');
}
return this.hasSth;
},
};
setTimeout(() => {
store.hasSth = true;
}, 2000);
let timer = setInterval(() => {
let result = customer.buySth(store);
if (result) {
clearInterval(timer);
}
}, 500);
有代理:
let customer = {
buySth: function (store) {
return store.sellSth();
}
};
let store = {
hasSth: false,
sellSth: function () {
if (this.hasSth) {
console.log('success');
} else {
console.log('fail');
}
return this.hasSth;
},
};
let proxyStore = {
sellSth: function () {
let timer = setInterval(() => {
let result = store.sellSth();
if (result) {
clearInterval(timer);
}
}, 500);
}
};
setTimeout(() => {
store.hasSth = true;
}, 2000);
customer.buySth(proxyStore);
代理模式有很多小分类,包括但不限于:
- 虚拟代理:把一些开销很大的对象延迟到真正需要它的时候才去创建;
- 保护代理:用于对象应该有不同访问权限的情况;
- 缓存代理:为一些开销很大的运算结果提供暂时的存储,在下次运算时,如果传递进来的参数跟之前的一致,则可以直接返回前面存储的运算结果。
下面这个示例是使用虚拟代理实现图片预加载:
let myImage = (function () {
let imageNode = document.createElement('img');
document.body.appendChild(imageNode);
return {
setSrc: function (src) {
imageNode.src = src;
}
}
})();
let proxyImage = (function () {
let imageNode = document.createElement('img');
imageNode.onload = function () {
myImage.setSrc(this.src);
};
return {
setSrc: function (src) {
myImage.setSrc('./resource/02.gif');
setTimeout(function () {
imageNode.src = src;
}, 1000);
}
}
})();
proxyImage.setSrc('./resource/01.jpg');
发布-订阅模式
定义:发布-订阅模式又叫观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象的状态发生变化时,所有依赖于它的对象都将得到通知。
发布-订阅模式广泛应用于异步编程中,这是一种代替传统回调函数的方案;
发布-订阅模式可以取代对象之间硬编码的通知机制,一个对象不用再显示地调用另一个对象的某个接口;
最为常见的发布-订阅模式是DOM的事件系统
document.body.addEventListener('click', function () {
console.log('body click');
});
如何一步步实现发布-订阅模式:
- 指定发布者;
- 给发布者添加一个缓存列表,用于存放回调函数以便通知订阅者;
- 发布消息时,发布者会遍历这个缓存列表,依次触发里面存放的订阅者回调函数;
接下来为一个商店设置发布-订阅系统,商店进货后会通知订阅了商品信息的顾客:
let store = {
listeners: [],
listen: function (fn) {
this.listeners.push(fn);
},
trigger: function () {
let that = this;
let args = arguments;
this.listeners.forEach( function (listener) {
listener.apply(that, args);
} )
},
};
store.listen(function (goods, number) {
console.log(goods, number);
});
store.trigger('cola', 10);
store.trigger('butter', 20);
上述代码就是发布-订阅模式的简单实现,不过有个缺陷在于不管是什么商品补货都会通知顾客,但是顾客只想订阅部分商品的信息该怎么改呢?并且顾客还有可能订阅了一段时间后希望能取消订阅。实现如下:
let store = {
listeners: {},
listen: function (key, fn) {
this.listeners[key] = this.listeners[key] || [];
this.listeners[key].push(fn);
},
remove: function (key, fn) {
let listenerList = this.listeners[key] || [];
let index = listenerList.indexOf(fn);
if (index !== -1) {
this.listeners[key] = listenerList.slice(0, index).concat(listenerList.slice(index + 1));
}
},
trigger: function () {
let that = this;
let args = arguments;
let key = Array.prototype.shift.apply(args);
(this.listeners[key] || []).forEach( function (listener) {
listener.apply(that, args);
} )
},
};
let func = function (number) {
console.log('cola', number);
};
store.listen('cola', func);
store.trigger('cola', 10);
store.trigger('butter', 20);
store.remove('cola', func);
store.trigger('cola', 10);
上述代码实现了一个商店的发布-订阅系统,但是另一个商店也需要这样的功能怎么办呢?只能将发布-订阅的逻辑抽离出来,对每个需要此项功能的商店或者其他地方进行包装,使其具有这样的功能。
let event = {
listeners: {},
listen: function (key, fn) {
this.listeners[key] = this.listeners[key] || [];
this.listeners[key].push(fn);
},
remove: function (key, fn) {
let listenerList = this.listeners[key] || [];
let index = listenerList.indexOf(fn);
if (index !== -1) {
this.listeners[key] = listenerList.slice(0, index).concat(listenerList.slice(index + 1));
}
},
trigger: function () {
let that = this;
let args = arguments;
let key = Array.prototype.shift.apply(args);
(this.listeners[key] || []).forEach( function (listener) {
listener.apply(that, args);
} )
},
};
let installEvent = function (target) {
let newEvent = {...event, listeners: {}};
for (key in newEvent) {
target[key] = newEvent[key];
}
};
let bigStore = {};
let smallStore = {};
installEvent(bigStore);
installEvent(smallStore);
let func = function (number) {
console.log('cola', number);
};
bigStore.listen('cola', func);
smallStore.listen('cola', func);
bigStore.trigger('cola', 10);
smallStore.trigger('cola', 20);
不同模块之间的通信也可以使用发布-订阅模式,各个模块间相互独立,通过一个全局的发布-订阅模块进行事件的订阅及回调的调用。
一般来说,在使用发布-订阅模式时都是先订阅一个消息,然后才能接收到发布者发布的消息。但是有没有可能先分发消息,然后再进行订阅呢?就像前面说的不同模块间是相互独立的,那么其加载和执行的顺序也是不一定的,那么就有可能模块A通过全局的事件对象发布了消息,模块B需要对此消息进行接收,但是模块B还没有加载好,就需要对已发布但是无人订阅的消息进行缓存,在有对象订阅该消息时再出发其回调。
let event = {
cacheListeners: {},
listeners: {},
listen: function (key, fn) {
this.listeners[key] = this.listeners[key] || [];
this.listeners[key].push(fn);
if (this.cacheListeners[key]) {
fn.apply(this, this.cacheListeners[key]);
delete this.cacheListeners[key];
}
},
remove: function (key, fn) {
let listenerList = this.listeners[key] || [];
let index = listenerList.indexOf(fn);
if (index !== -1) {
this.listeners[key] = listenerList.slice(0, index).concat(listenerList.slice(index + 1));
}
},
trigger: function () {
let that = this;
let args = arguments;
let key = Array.prototype.shift.apply(args);
let listeners = this.listeners[key] || [];
if (listeners.length === 0) {
this.cacheListeners[key] = [...args];
} else {
listeners.forEach( function (listener) {
listener.apply(that, args);
} );
}
},
};
event.trigger('cola', 10);
event.listen('cola', function (number) {
console.log('cola', number);
});
event.trigger('cola', 20);
优点:
- 时间上的解耦;
- 对象之间的解耦;
缺点:
- 创建订阅者者本身要消耗一定时间和内存,可能订阅后从未被触发过;
- 当多个发布者和订阅者嵌套在一起后难以对程序进行维护;