最近观看了《Javascript设计模式系统讲解与应用》教程,对设计模式有了新的认识,特在此做些记录。
一、UML
文中会涉及众多的UML类图,在开篇需要做点基础概念的认识。以下面的图为例,图片和说明均来源于《大话设计模式》一书。
(1)矩形框,它代表一个类。类图分三层,第一层显示类的名称,如果是抽象类,则用斜体显示。第二层是类的特性,通常就是字段和属性。第三层是类的操作,通常是方法或行为。前面的符号,+ 表示public,- 表示private,# 表示protected。
(2)矩形框的顶端包含<
(3)继承关系用空心三角形 + 实线来表示的。
(4)实现接口用空心三角形 + 虚线来表示。
(5)当一个类知道另一个类时,可以用关联(Association),关联关系用实线箭头来表示。
(6)聚合(Aggregation)表示一种弱的拥有关系,体现的是A对象可以包含B对象,但B对象不是A对象的一部分。聚合关系用空心的菱形 + 实线箭头来表示。
(7)组合(Composition)是一种强的拥有关系,体现了严格的部分和整体的关系,组合关系用实心的菱形 + 实线箭头来表示。
(8)依赖(Dependency)关系,用虚线箭头来表示。
二、五大原则(SOLID)
作者认为设计模式得分成两部分,第一是设计(即设计原则),第二才是模式。五大原则是设计模式的基础,SOLID是五大原则的首字母简写,作者在介绍每个模式的时候,都会提到是否符合这五大原则。
(1)S-单一职责原则,一个程序只做一件事,如果功能过于复杂,那么就拆分,保持独立。
(2)O-开放封闭原则,对扩展开发,对修改封闭,增加需求就扩展新代码,而非修改已有代码。修改已有代码不但需要测试,成本高昂,而且多人开发很容易发生冲突。
(3)L-里氏替换原则,子类能覆盖父类,父类能出现的地方子类就能出现。
(4)I-接口分离原则,保持接口的单一独立,避免胖接口。
(5)D-依赖倒置原则,面向接口编程,依赖于抽象而不依赖于具体,使用方只关注接口而不关注具体类的实现。
在JavaScript中,SO体现较多;而LID体现较少,因为JavaScript的语法限制,例如弱类型、没有接口等。如果用Promise来说明SO,那么就是:
(1)S:每个then中的逻辑只做好一件事。
(2)O:如果新增需求,那么就扩展then。
说句题外话,TypeScript是一门强类型面向对象语言,在它问世后,就可以完完整整的应用这五大原则,不会有什么限制。前段时间写过几篇TypeScript的简单教程,有兴趣的可以参考。
三、21种设计模式
1)工厂模式
将new操作单独封装。遇到new时,就要考虑是否该使用工厂模式。例如购买汉堡直接点餐,不用自己制作,而商店要封装做汉堡的操作,然后卖给客户。下面是一个简单示例。
class Creator { create(name) { return new Product(name); } } let creator = new Creator(); let p = creator.create("p");
使用场景包括jQuery的$("div")、React.createElement()和Vue的异步组件。
2)单例模式
系统中被唯一使用,一个类只有一个实例,例如登录框、购物车。单例模式需要用到private修饰符,但ES6中没有,可用闭包模拟,如下所示。
class SingleObject { login() { console.log("login..."); } } SingleObject.getInstance = (function() { let instance; return function() { if (!instance) { instance = new SingleObject(); } return instance; }; })(); let obj1 = SingleObject.getInstance(); obj1.login();
使用场景包括jQuery中的$(只有一个)、模拟登录框和Redux中的Store。
3)适配器模式
旧接口格式和使用者不兼容,中间加一个适配转换接口。以转换插头为例,如下所示。
class Adaptee { specificRequest() { return "德国标准"; } } class Target { constructor() { this.adaptee = new Adaptee(); } request() { let info = this.adaptee.specificRequest(); return `${info}--转换--中国标准`; } } let target = new Target(); target.request();
使用场景包括封装旧接口、Vue的计算属性。
4)装饰器模式
为对象添加新功能,不改变其原有的结构和功能。与适配器模式不同,之前的接口仍然可以使用。装饰器模式类似于生活中的手机壳,不仅保护了手机,还不影响音量键、扬声器等部分。
使用场景包括ES7装饰器、core-decorator,下面通过装饰器语法演示调用方法时自动打日志。
function log(target, name, descriptor) { var oldValue = descriptor.value; descriptor.value = function() { console.log(`Calling ${name} with`, arguments); return oldValue.apply(this, arguments); }; return descriptor; } class Math { @log add(a, b) { return a + b; } } const math = new Math(); math.add(2, 4);
装饰器的原理如下所示。
@decorator class A {} //等同于 class A {} A = decorator(A) || A;
5)代理模式
当使用者无权访问目标对象时,可在中间加代理,通过代理做授权和控制,类似于明星的经纪人。
使用场景包括DOM的事件委托、jQuery的$.proxy、ES6的proxy。下面通过代理语法模拟明星和经纪人。
let star = { name: "张XX", // 明星 age: 25, phone: "13800138000" }; let agent = new Proxy(star, { // 经纪人 get: function(target, key) { if (key === "phone") { // 返回经纪人自己的手机号 return "18012345678"; } if (key === "price") { // 明星不报价,经纪人报价 return 120000; } return target[key]; }, set: function(target, key, val) { if (key === "customPrice") { if (val < 100000) { throw new Error("价格太低"); } else { target[key] = val; return true; } } } });
代理模式提供一模一样的接口,而适配器模式提供不同的接口。代理模式直接针对原有功能,不过需要经过限制或阉割;装饰器模式能扩展功能,而原有功能不变且可直接使用。
6)外观模式
为子系统中的一组接口提供一个高层接口,使用者使用这个高层接口。下面是外观模式的示意图,去医院看病通常是左边的情况,但如果有接待员,那么就会是右边的情况,由他去挂号、门诊、取药等。
外观模式的UML类图是下面这样。
使用场景包括同一个函数可接收不同组合的参数,如下所示,在jQuery中提供了很多这类方法。
function bindEvent(elem, type, selector, fn) { if (fn == null) { fn = selector; selector = null; } //... } //调用可以有两种 bindEvent(elem, "click", "#div", fn); bindEvent(elem, "click", fn);
注意,外观模式虽然好用,但是不符合单一职责和开放封闭原则,需要谨慎使用。
7)观察者模式
观察者模式是一种发布订阅机制,当对象间存在一对多(也可以一对一)的关系时,可使用观察者模式。在使用观察者模式后,当一个对象被修改时,就会自动通知它的依赖对象。
生活中的点咖啡也是一种观察者模式,当点好后,就找位子坐下,等叫号或者等服务员送过来。下面是个简单的示例。
class Subject { // 主题,接收状态变化,触发每个观察者 constructor() { this.state = 0; this.observers = []; } getState() { //获取状态 return this.state; } setState(state) { //修改状态 this.state = state; this.notifyAllObservers(); } attach(observer) { //添加观察者 this.observers.push(observer); } notifyAllObservers() { //通知所有观察者 this.observers.forEach(observer => { observer.update(); }); } } 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("o2", s); s.setState(1); s.setState(2);
使用场景包括DOM事件绑定、Promise、jQuery callbacks和Node.js自定义事件。
8)迭代器模式
访问一个有序集合(不包括对象),使用者无需知道集合的内部结构。下面是一个模拟的迭代器(Iterator),包含next()和hasNext()方法。
class Iterator { constructor(conatiner) { this.list = conatiner.list; this.index = 0; } next() { if (this.hasNext()) { return this.list[this.index++]; } return null; } hasNext() { if (this.index >= this.list.length) { return false; } return true; } } class Container { constructor(list) { this.list = list; } getIterator() { return new Iterator(this); } } let container = new Container([1, 2, 3, 4, 5]); let iterator = container.getIterator(); while (iterator.hasNext()) { console.log(iterator.next()); }
使用场景包括jQuery的each、ES6的Iterator。ES6之所以引入Iterator有以下三个原因:
(1)实现了迭代器模式。
(2)ES6语法中,有序集合的数据类型已经有很多,例如Array、Map、Set、String、TypedArray、arguments和NodeList。
(3)需要有一个统一的接口来遍历所有数据类型。
注意,Object不是有序集合,可用Map替代。
9)状态模式
一个对象会有状态变化,每次状态变化都会触发一个逻辑,而这个逻辑不能总是用if...else语句来控制。生活中的交通信号灯是一种状态模式,当颜色变化时,会有不同的结果,例如红灯禁止、绿灯通行。
使用场景包括Promise原理、有限状态机。下面的示例使用了开源的javascript-state-machine。
var fsm = new StateMachine({ init: "solid", transitions: [ { name: "melt", from: "solid", to: "liquid" }, { name: "freeze", from: "liquid", to: "solid" }, { name: "vaporize", from: "liquid", to: "gas" }, { name: "condense", from: "gas", to: "liquid" } ], methods: { onMelt: function() { console.log("I melted"); }, onFreeze: function() { console.log("I froze"); }, onVaporize: function() { console.log("I vaporized"); }, onCondense: function() { console.log("I condensed"); } } });
10)原型模式
克隆自己,生成一个新对象。Java默认有clone接口,不用自定义。而JavaScript中的Object.create()采用了原型模式的思想,如下所示。
const person = { isHuman: false, printIntroduction: function () { console.log(`My name is ${this.name}`); } }; const me = Object.create(person); me.name = "Matthew"; me.isHuman = true;
11)桥接模式
将抽象化与实现化两者解耦,使得它们可以独立变化。
下图和分析均来源于《C#设计模式(10)——桥接模式》,在用桥接模式优化后,就能将形状和颜色通过继承生产的强耦合关系改成弱耦合的关联关系,这里采用了组合大于继承的思想。
如果想添加一个五角星,只需要添加一个形状类的子类五角星接即可,不需要再去添加各种颜色的具体五角星了。如果想要一个蓝色五角星就将五角星和蓝色进行组合来获取。这样设计降低了形状和颜色的耦合,减少了具体子类的种类。
12)组合模式
生成树形结构,表示“整体-部分”关系。让整体和部分都具有一致的操作方式。虚拟DOM中的vnode是这种形式(如下所示),但数据类型比较简单。
{ tag: "div", attr: { className: "container" }, children: [ { tag: "p", attr: {}, children: ["123"] }, { tag: "p", attr: {}, children: ["456"] } ] }
13)享元模式
享元是个组合词,分为共享和元数据。享元模式关注共享内存,考虑内存而非效率。
在JavaScript中不用刻意的考虑内存问题,因此没有享元模式的直接场景,但可以找到意义相关(即共享数据开销思想)的场景,例如事件委托。
14)策略模式
不同策略分开处理,避免出现大量if...else或者switch语句,下面是个示例。
class User { constructor(type) { this.type = type; } buy() { if (this.type == "ordinary") { console.log("普通用户购买"); } else if (this.type == "member") { console.log("会员用户购买"); } else if (this.type == "vip") { console.log("VIP用户购买"); } } } //策略模式生成三个类,各自实现buy()方法 class UserOrdinary { buy() { console.log("普通用户购买"); } } class UserMember { buy() { console.log("会员用户购买"); } } class UserVip { buy() { console.log("VIP用户购买"); } }
15)模板方法模式
如果代码中有几步处理,可以用一个方法将它们封装在一个方法中(如下所示),在方法中可对顺序或特殊逻辑进行处理,对外统一输出,有效降低了耦合度。
class Action {
handle() {
handle1();
handle2();
handle3();
}
handle1() { }
handle2() { }
handle3() { }
}
16)职责链模式
一步操作可能分为多个职责角色来完成,然后把这些角色都分开,再用一个链串起来,并且将发起者和各个处理者进行隔离。例如请假审批,需要逐级审批,如下所示。
class Action { constructor(name) { this.name = name; this.nextAction = null; } setNextAction(action) { this.nextAction = action; } handle() { console.log(`${this.name} 审批`); if (this.nextAction != null) { this.nextAction.handle(); } } } let a1 = new Action("组长"); let a2 = new Action("经理"); let a3 = new Action("总监"); a1.setNextAction(a2); a2.setNextAction(a3); a1.handle();
职责链模式和业务结合较多,能联想到链式操作,例如jQuery的链式操作、Promise.then的链式操作。
17)命令模式
执行命令时,发布者和执行者分开。中间加入命令对象,作为中转站。发布者相当于将军,他会让旗手或号手将命令传达给普通士兵,如下所示。
class Receiver { exec() { console.log("执行"); } } class Command { constructor(receiver) { this.receiver = receiver; } cmd() { console.log("触发命令"); this.receiver.exec(); } } class Invoker { constructor(command) { this.command = command; } invoke() { console.log("开始"); this.command.cmd(); } } let solider = new Receiver(); //士兵 let trumpeter = new Command(solider); //小号手 let general = new Invoker(trumpeter); //将军 general.invoke();
使用场景包括网页富文本编辑器,由浏览器封装一个命令对象,如下所示。
document.execCommand("bold");
document.execCommand("undo");
18)备忘录模式
随时记录一个对象的状态变化,随时可以恢复之前的某个状态,例如网页编辑器中的撤销功能。
19)中介者模式
当有许多对象,并且对象之间会频繁的相互访问(如左图)时,就会变得很混乱,而对象之间的访问都由中介者代转(如右图),就会变得很有条理,不会出现牵一发动全身的情况。
生活中的房屋中介就采用了这种模式,如下所示。
class Mediator { //中介者 constructor(a, b) { this.a = a; this.b = b; } setA() { let number = this.b.number; this.a.setNumber(number * 100); } setB() { let number = this.a.number; this.b.setNumber(number / 100); } } class A { constructor() { this.number = 0; } setNumber(num, m) { this.number = num; if (m) { m.setB(); //通过中介者修改 } } } class B { constructor() { this.number = 0; } setNumber(num, m) { this.number = num; if (m) { m.setA(); //通过中介者修改 } } }
20)访问者模式
将数据操作和数据结构进行分离。
21)解释器模式
描述语言语法如何定义,如何解释和编译,例如Babel。