单例模式
首先请思考:如何实现一个构造函数F,使得new F() === new F()成立?可以说从代码角度来讲,单例模式就是要实现这个。
// 单例模式基本思想:
function F() {
this.foo = 'bar';
}
F.prototype.instance = null;
F.getInstance = function () {
if (!this.instance) this.instance = new F();
return this.instance;
};
const a = F.getInstance();
const b = F.getInstance();
console.log(a === b); // true
虽然又简单又好理解,但也很明显,这个构造函数不“透明”,即开发者必须要知道这是个单例构造函数,不应该用new来获取实例,且必须要知道用来获取实例的函数叫getInstance。 进一步:
let instance = null;
function F(params) {
if (instance) return instance;
this.foo = 'bar';
instance = this;
return instance; // 这里不return也可以
}
const a = new F();
const b = new F();
console.log(a === b); // true
可以发现,原理都是将第一次调用new得到的实例存储起来,之后的new操作都是直接返回那个存储起来的实例,再优化一点,将实例变量放在自身的原型对象上:
function F() {
if (this.__proto__.instance) return this.__proto__.instance;
this.foo = 'bar';
this.__proto__.instance = this;
}
var a = new F();
var b = new F();
console.log(a === b); // true
或者,使用闭包+自调用函数:
const F = (function () {
let instance = null;
return function (params) {
if (instance) return instance;
this.foo = 'bar';
instance = this;
return instance; // 这里不return也可以
};
})();
const a = new F();
const b = new F();
console.log(a === b); // true
至此,就已经实现了一个符合new F() === new F()成立的构造函数F。
继续思考,假如F是一个已经存在了的普通构造函数,我们在另一个场景下要实现F的单例模式,那么我们肯定不能修改F,而要通过某种方式将F进行“代理”(代理模式后面再详细介绍):
function F() {
this.foo = 'bar';
}
const SingleF = (function () {
let instance = null;
return function () {
if (!instance) instance = new F();
return instance;
};
})();
const a = new SingleF(); // SingleF实际上已经是普通函数,所以这里不用new也一样
const b = new SingleF(); // SingleF实际上已经是普通函数,所以这里不用new也一样
console.log(a === b); // true
上面这些单例模式的实现,更多的是接近传统面向对象语言的实现,即对象必须由类创建而来,例如Java。但js是无类语言(虽然es6提出了class,但其底层仍是原型链),我们创建对象更多的是使用对象字面量的方式,很少真的在创建对象前先创建一个构造函数。所以更多的,我们是借助单例模式的思想,解决(或优化)一些前端问题,例如使网站的登录弹窗只被创建一次:
const getSingle = function (fn) {
// 通用的创建单例函数
let res = null;
return function () {
return res || (res = fn.apply(this, arguments));
};
};
const createLogin = getSingle(function () {
// 创建登录弹窗
const div = document.createElement('div');
div.className = 'login';
div.innerHTML = 'this is login';
document.body.appendChild(div);
return div;
});
// 只会创建一次div
createLogin();
createLogin();
createLogin();
策略模式
人工智能专家Peter Norvig说:“在函数作为一等对象的语言中,策略模式是隐形的。策略就是值为函数的变量。 ” 确实,若不考虑像Java那样基于类的实现,js的策略模式异常简单:
// 以计算年终奖为例
const strategies = {
S: salary => salary * 4,
A: salary => salary * 3,
B: salary => salary * 2,
};
const calculateBonus = (level, salary) => strategies[level](salary);
console.log(calculateBonus('S', 20000));
console.log(calculateBonus('A', 10000));
代理模式
简单来讲,代理模式就是为对象A创建一个模拟其各项功能的对象B,将原本对A的调用全部换作对B的调用,从而达到对对象A进行保护、缓存、优化的目的。以图片预加载为例:
const myImage = (function () {
const imgNode = document.createElement('img');
document.body.appendChild(imgNode);
return {
setSrc(src) {
imgNode.src = src;
},
};
})();
myImage.setSrc('远程图片A路径');
假如网络环境不好,或图片很大,则能很明显的看到一段空白时间(可以以故意调低网速,或者像我一样直接将后端改为几秒后再返回的方式来测试)。
其实优化方法也很简单,无非是先加载一个本地图片B,等获取到图片A后再加载图片A,可以改成下面这样:
const myImage = (function () {
const imgNode = document.createElement('img');
document.body.appendChild(imgNode);
const img = new Image();
img.onload = function () {
imgNode.src = this.src;
};
return {
setSrc(src) {
imgNode.src ='本地图片B路径';
img.src = src;
},
};
})();
尽管能实现功能,但很明显,这样的改动违反了“单一职责”原则,myImage既要负责真实图片的渲染,也要负责预加载图片。且假如要获取一个能很快获取的远程图片C,很明显现在这样“闪一下”的用户体验并不好(有没有想到加一个标志位,依据其决定是否启用预加载图片?当然可行,但myImage的职责是不是更多了?)。
所以,最佳的解决方法,是创建一个代理对象,在代理对象中实现预加载图片的功能:
// 恢复原本的myImage
const myImage = (function () {
const imgNode = document.createElement('img');
document.body.appendChild(imgNode);
return {
setSrc(src) {
imgNode.src = src;
},
};
})();
const proxyImage = (function () {
const img = new Image();
img.onload = function () {
myImage.setSrc(this.src);
};
return {
setSrc(src) {
myImage.setSrc('本地图片B路径');
img.src = src;
},
};
})();
proxyImage.setSrc('远程图片A路径'); // 改为调用proxyImage的setSrc
像这样,通过添加代理,扩展了“加载图片”这一功能。加载真实图片和预加载图片这两个功能被隔离在了两个模块中,互不影响对方,且若不需要预加载功能,继续调用原本的myImage.setSrc就好。不仅符合“单一职责”原则,也符合“对扩展开放,对修改关闭”。
迭代器模式
迭代器模式是一种相对简单的设计模式,简单到甚至不被认为是一种设计模式。当前大部分编程语言都已内置了迭代器。
需要记住的是,并非只有数组可以被迭代,只要被迭代的对象拥有length且可以通过下标访问,它就可以被迭代,例如arguments、{0: 'foo', 1: 'bar', length: 2}等。
这里实现一个最经典的版本(用法有点类似于es6的Generator函数):
const Iterator = function (obj) {
let current = 0;
const isDone = () => {
return current >= obj.length;
};
const next = () => {
current++;
};
const getCurrentItem = () => {
return obj[current];
};
return {
isDone,
next,
getCurrentItem
};
};
const i1 = Iterator([0, 1, 2, 3, 4]);
while (!i1.isDone()) {
console.log(i1.getCurrentItem());
i1.next();
}
const i2 = Iterator({ 0: 'foo', 1: 'bar', length: 2 });
while (!i2.isDone()) {
console.log(i2.getCurrentItem());
i2.next();
}
发布-订阅模式(观察者模式)
可以说,发布-订阅模式是前端开发最常用的设计模式,以至于开发者根本意识不到使用了该设计模式。实际上,每次在dom节点上绑定事件,就使用了发布-订阅模式。
稍微复杂一点的地方在于,如何实现“接收离线消息”,即发布消息时,若还没有对象订阅,需要先将消息保存下来,等到有对象订阅时,再将消息发布给订阅者。从代码角度来讲,难点在于如何保存一个未来才会注册的函数。
这里给出一个简单实现:
const Event = (function () {
const clientLists = {},
offlineStacks = {}, // 存放发送离线消息的函数
caches = {}; // 存放接收离线消息的函数
const _listen = function (key, fn) {
// 注册接收离线消息的函数至cache
caches[key] = caches[key] || [];
caches[key].push(fn);
};
const _trigger = function (key, res) {
for (const cache of caches[key]) cache.call(this, res);
};
const listen = function (key, fn) {
_listen(key, fn);
clientLists[key] = clientLists[key] || [];
clientLists[key].push(fn);
for (const offline of (offlineStacks[key] || [])) offline();
offlineStacks[key] = []; // 离线消息发送结束后要清空
};
const trigger = function (key, res) {
const self = this;
const args = arguments;
offlineStacks[key] = offlineStacks[key] || [];
offlineStacks[key].push(function () {
_trigger.apply(self, args);
});
for (const client of (clientLists[key] || [])) client.call(this, res);
};
return {
listen,
trigger
};
})();
Event.trigger('click', 12345);
Event.trigger('click', 67890);
setTimeout(() => {
Event.listen('click', function (res) {
console.log('res:', res);
});
}, 1000);
命令模式
与策略模式类似,js中的命令模式也是“隐形”的,可以用高阶函数非常方便的实现。在传统的面向对象设计中,命令模式必须保存command对象,同时约定执行命令的操作是调用command.execute,如下:
const setCommand = function (button, command) {
button.onclick = function () {
command.execute();
};
};
const MenuBar = {
refresh: function () {
console.log('刷新菜单目录');
},
};
class RefreshMenuBarCommand {
constructor(receiver) {
this.receiver = receiver;
}
execute() {
this.receiver.refresh();
}
}
setCommand(myBtn, new RefreshMenuBarCommand(MenuBar));
而在js中,函数是一等对象,本身就可被传递,所以上面代码可以简化为:
const setCommand = function (button, fun) {
button.onclick = fun;
};
const MenuBar = {
refresh: function () {
console.log('刷新菜单目录');
}
};
setCommand(myBtn, MenuBar.refresh);
关于撤销,基本原理并不复杂,即在执行命令时,将执行命令之前的状态保存进栈中,然后在需要撤销时执行undo命令来遍历。需要注意的是,并非所有场景都可以像这样通过undo来撤销,这时候最好的办法是在执行execute时将 execute本身保存进队列中,在需要撤销时先将对象恢复为最初始的状态,再遍历队列,将执行过的命令再全部执行一遍(与其称作撤销,称作重做更合适)。这也是逆转不可逆命令的一个好办法。
组合模式
在命令模式中,可将一组命令整合进一个集合,这个集合被称为宏命令,可通过执行这个宏命令,来一次性执行一组命令,这就是组合模式最简单的体现,如下:
const closeDoorCommand = {
execute: function () {
console.log('关门');
},
};
const openPcCommand = {
execute: function () {
console.log('开电脑');
},
};
const openQQCommand = {
execute: function () {
console.log('登录 QQ');
},
};
const MacroCommand = function () {
return {
commandsList: [],
add: function (command) {
this.commandsList.push(command);
},
execute: function () {
for (let i = 0, command; (command = this.commandsList[i++]); )
command.execute();
},
};
};
var macroCommand = MacroCommand();
macroCommand.add(closeDoorCommand);
macroCommand.add(openPcCommand);
macroCommand.add(openQQCommand);
macroCommand.execute();
不难想象,上面的代码组成了一个树形结构,一个根节点下面包含3个子节点。组合模式最大的好处在于,子节点也可以包含子节点,即一组命令中的每个命令也可以是包含另一组命令的命令集合,只要它包含execute方法,如下:
const closeDoorCommand = {
execute: function () {
console.log('关门');
},
};
const openPcCommand = {
execute: function () {
console.log('开电脑');
},
};
const openQQCommand = {
execute: function () {
console.log('登录 QQ');
},
};
const MacroCommand = function () {
return {
commandsList: [],
add: function (command) {
this.commandsList.push(command);
},
execute: function () {
for (let i = 0, command; (command = this.commandsList[i++]); )
command.execute();
},
};
};
// ------------增加这些----------------
const openTvCommand = {
execute: function () {
console.log('打开电视');
},
};
const openSoundCommand = {
execute: function () {
console.log('打开音响');
},
};
const macroCommand1 = MacroCommand();
macroCommand1.add(openTvCommand);
macroCommand1.add(openSoundCommand);
// -------------------------------
var macroCommand = MacroCommand();
macroCommand.add(closeDoorCommand);
macroCommand.add(macroCommand1); // 增加这里
macroCommand.add(openPcCommand);
macroCommand.add(openQQCommand);
macroCommand.execute();
组合模式可以使开发人员忽略组合对象和叶对象的区别,将二者一致对待(从而不必再用条件分支来分别处理),它们各自会做各自的事情。当然,这并不是在所有场景下都是优点,且若通过组合模式创建了过多的对象,系统能否负担也是一个要考虑的问题。
模板方法模式
模板方法模式是一种基于继承的设计模式。引入《Head First设计模式》中经典的咖啡与茶的例子,用Java来实现模板方法模式,大致如下:
public abstract class Beverage { // 饮料抽象类
final void init(){ // 模板方法
boilWater();
brew();
pourInCup();
addCondiments();
}
void boilWater(){ // 具体方法 boilWater
System.out.println( "把水煮沸" );
}
abstract void brew(); // 抽象方法 brew
abstract void addCondiments(); // 抽象方法 addCondiments
abstract void pourInCup(); // 抽象方法 pourInCup
}
// Coffee 类
public class Coffee extends Beverage{
@Override
void brew() { // 子类中重写 brew 方法
System.out.println( "用沸水冲泡咖啡" );
}
@Override
void pourInCup(){ // 子类中重写 pourInCup 方法
System.out.println( "把咖啡倒进杯子" );
}
@Override
void addCondiments() { // 子类中重写 addCondiments 方法
System.out.println( "加糖和牛奶" );
}
}
// Tea 类
public class Tea extends Beverage{
@Override
void brew() { // 子类中重写 brew 方法
System.out.println( "用沸水浸泡茶叶" );
}
@Override
void pourInCup(){ // 子类中重写 pourInCup 方法
System.out.println( "把茶倒进杯子" );
}
@Override
void addCondiments() { // 子类中重写 addCondiments 方法
System.out.println( "加柠檬" );
}
}
虽然js中没有提供对类的支持(es6中增加的class,其底层仍是原型链),但借助原型链,模拟上面的流程也并非难事。区别在于,在Java中编译器会保证子类一定会重写父类中的抽象方法(若未重写编译会不通过),但js却对这样的情况无能为力。一种好一点的解决方法,是给“父类”的“抽象方法”提供一个抛出错误的具体实现(虽然相比于Java,得到错误信息的时间点已经太靠后了):
const Beverage = function () {};
Object.assign(Beverage.prototype, {
boilWater() {
console.log('把水煮沸');
},
brew() {
throw new Error('必须实现 brew 方法');
},
pourInCup() {
throw new Error('必须实现 pourInCup 方法');
},
addCondiments() {
throw new Error('必须实现 addCondiments 方法');
},
init() {
this.boilWater();
this.brew();
this.pourInCup();
this.addCondiments();
},
});
const Coffee = function () {};
Coffee.prototype = new Beverage();
Object.assign(Coffee.prototype, {
brew() {
console.log('用沸水冲泡咖啡');
},
pourInCup() {
console.log('把咖啡倒进杯子');
},
addCondiments() {
console.log('加糖和牛奶');
},
});
const Tea = function () {};
Tea.prototype = new Beverage();
Object.assign(Tea.prototype, {
brew() {
console.log('用沸水浸泡茶叶');
},
pourInCup() {
console.log('把茶倒进杯子');
},
addCondiments() {
console.log('加柠檬');
},
});
var coffee = new Coffee();
coffee.init();
var tea = new Tea();
tea.init();
简单概括,模板方法模式就是“(抽象)父类负责封装不变,子类负责封装变化”。通过增加新的子类,便可增加新的功能,无需更改父类或其他子类。
职责链模式
一图胜千言:
一个请求先传送给A,若A不能处理则传送给B,B不能处理再传递给C……作用域链、原型链、事件冒泡,都可以看到职责链模式的影子。
这里用一个判断数字范围程序来举例:
const f1 = function (num) {
if (num >= 1000) {
console.log('这个数不小于1000');
} else return false;
};
const f2 = function (num) {
if (num >= 100) {
console.log('这个数不小于100');
} else return false;
};
const f3 = function (num) {
if (num >= 10) {
console.log('这个数不小于10');
} else return false;
};
const f4 = function (num) {
console.log('这个数是' + num);
};
const chainfn = (function (arr) {
return function (num) {
for (let i = 0; i < arr.length; i++) {
const fn = arr[i];
if (fn(num) !== false) break;
}
};
})([f1, f2, f3, f4]); // 若需要扩充,给数组中增加函数即可
chainfn(1000);
chainfn(100);
chainfn(10);
chainfn(1);
还可以采用代理模式,使职责链的创建方式更加方便、优雅:
Function.prototype.setNext = function (fn) {
const self = this;
return function () {
const res = self.apply(this, arguments);
if (res === false) {
return fn && fn.apply(this, arguments);
}
return true;
};
};
const f = f1.setNext(f2).setNext(f3).setNext(f4);
f(1000);
f(100);
f(10);
f(1);
需要注意的是,因为一般情况下,请求只会被一个节点处理,所以大部分节点并没有起到实质作用,只是为了让请求传递下去(甚至请求根本没有传递到就已经被前面的节点处理了)。因此从性能方面考虑,应该避免过长的职责链。
装饰者模式
在传统的面向对象的语言中,装饰者模式是指在不必修改原始类的情况下,动态地给对象添加功能,如下:
class Plane {
fire() {
console.log('发射子弹');
}
}
class MissileDecorator {
constructor(plane) {
this.plane = plane;
}
fire() {
this.plane.fire();
console.log('发射导弹');
}
}
let plane = new Plane();
plane = new MissileDecorator(plane);
plane.fire();
很明显,在js中根本不需要这样做,动态修改对象的属性再简单不过了,不需要再创建一个MissileDecorator,直接修改plane.fire函数就好。虽然这样做改变了对象原本的方法,并不完全符合装饰者模式的定义,但js的特色正是如此:
let plane = new Plane();
plane.fire = function () {
console.log('发射子弹');
console.log('发射导弹');
};
plane.fire();
所以在js中,装饰者模式更多的是指装饰函数(本来函数在js中就是一等对象),即在不改变函数源代码的情况下扩充函数的功能。
不修改函数源代码扩充函数的功能,一般情况下会这么做:
let a = function () {
console.log(1);
};
const _a = a;
a = function (params) {
_a();
console.log(2);
};
a();
但这样做显然不够优雅,更加完美的方法是,用AOP(面向切面编程)来装饰函数:
Function.prototype.before = function (beforeFn) {
const self = this;
return function () {
beforeFn.apply(this, arguments);
return self.apply(this, arguments);
};
};
Function.prototype.after = function (afterFn) {
const self = this;
return function () {
const res = self.apply(this, arguments);
afterFn.apply(this, arguments);
return res;
};
};
// 若不喜欢这种污染原型的方式,也可以这样:
function before(fn, beforeFn) {
return function () {
beforeFn.apply(this, arguments);
return fn.apply(this, arguments);
};
}
function after(fn, afterFn) {
return function () {
const res = fn.apply(this, arguments);
afterFn.apply(this, arguments);
return res;
};
}
这样,扩充函数功能就可以:
let a = function () {
console.log(1);
};
a = a.after(function (params) {
console.log(2);
});
a();
日常的开发中,在C端项目收尾阶段做埋点时,往往将埋点代码和业务代码混在一起,从而被迫改动原本已可以正常运行的业务代码。此时用AOP来实现,无疑会方便许多:
const a = function() {
// 业务代码
}
a = a.after(function() {
// 埋点代码
})
a();
另一个使用场景是表单验证,与其将表单验证和ajax请求放在同一函数中,更好的方式是将表单验证作为ajax请求的“before”,在表单验证通过后,再进行ajax请求(当然,此时要改写一下before函数,依据beforeFn的执行结果来决定是否继续执行原函数)。
状态模式
首先来看一个很经典的开关电灯的例子:
class Light {
constructor() {
this.state = 'off';
}
turnSwitch() {
switch (this.state) {
case 'off':
this.state = 'weaklight';
console.warn('打开弱光');
break;
case 'weaklight':
this.state = 'stronglight';
console.warn('打开强光');
break;
case 'stronglight':
this.state = 'off';
console.warn('关灯');
default:
break;
}
}
}
const light = new Light();
light.turnSwitch();
light.turnSwitch();
light.turnSwitch();
可以发现,每次执行的都是turnSwitch方法,但每次执行的结果都不同,这是因为内部维护了一个状态变量state,turnSwitch函数内部依据state的值来进行不同的操作。虽然没有异常,但缺点也很明显:
- 违反了开放-关闭原则,一旦state多一种状态,都要向turnSwitch中增加一种对应的处理,那么未来无法预计turnSwitch会被增大到什么程度(此处举例仅仅是打印一条日志而已,实际项目中当然不会这么简单);
- 状态的种类、各状态之间的关系都混在turnSwitch中,十分不明显,无法一目了然的看清全貌,在开发过程中很容易漏掉或错误的进行一些操作。
为了解决这样的问题,状态模式应运而生,其关键就是将每种状态都不再是一条简单的数据(字符串、数字),而是一个封装后的类(对象),跟该状态有关的行为也都封装在这个类(对象)内部,将改变状态的请求都委托给这个状态类(对象)。按照这个模式修改上面的开关电灯,如下:
class Light {
constructor() {
const states = {
off: {
change() {
this.state = states.weaklight;
console.warn('打开弱光');
}
},
weaklight: {
change() {
this.state = states.stronglight;
console.warn('打开强光');
}
},
stronglight: {
change() {
this.state = states.off;
console.warn('关灯');
}
}
};
this.state = states.off;
}
turnSwitch() {
this.state.change.call(this);
}
}
const light = new Light();
light.turnSwitch();
light.turnSwitch();
light.turnSwitch();
可以看到,各状态都变成了一个对象,且都有一个改变自身状态的方法,turnSwitch只要调用这个方法就好,即便未来有新状态出现也无需修改。