为何要使用设计模式?
- 根本目标: 高内聚,低耦合
- 表现:更方便的代码复用,更高的扩展性,更低的维护成本。
如何选择合适的设计模式?
- 找出程序中的变化部分与不变的部分,将变化的部分封装起来。
多态本质上的优点
- 不必再询问对象的类型,减少冗余的分支判断;依托多态机制,更多地关注对象的行为而非对象本身。
使用全局变量可能存在的问题?
- 全局命名空间的污染
- 可能会无意覆盖变量
- 作用域链的延长,执行效率的下降
- 一定的安全隐患
单例模式
- 透明单例
- 惰性单例
- 产生单例的部分应该与管理单例的部分解耦
举例
- 点击多次只能出现一个登录弹窗
- 在一个ajax请求响应之前,只能发送一次请求。
策略模式
- js中天然的高阶函数语法本身就是策略模式的一种实现:利用回调函数的传递实现不同策略的处理器函数,对不同策略区别化处理。
- 策略模式容易暴露策略的实现,违反了最少只是原则。
举例
- 根据不同的算法执行不同的逻辑,不同算法之间相互独立
- 计算年终奖需要制定的不同策略
代理模式
- 本质上代理和本体是一对一的关系
- 代理和本体的接口需要保持一致
场景
- 利用代理合并http请求
- 命中缓存代理(普通缓存或http缓存)
- 权限控制
迭代器模式
场景
- 对一类对象进行迭代与判断,知道找到合适的对象
举例
- 判断浏览器的类型,命中后做相应的处理。
发布订阅模式
- 发布者与订阅者是一对多关系
- 推模型与拉模型
场景举例
- 当登录后需要在页面的各个位置展示相应的用户信息
- 两者的数据通信,例如 提供全局对象供双方通信。
缺点
- 通常有一定的内存和执行效率的开销
- 异步操作中,不利于debug
命令模式
使用场景
- 命令调用者和命令的接收者进行解耦
- 大多需要抽象命令的各种执行行为,命令调用者发送请求后,命令对象可延迟执行,也可重复执行、撤销执行等
- 也可对命令进行批处理,定义宏命令。
举例
- 下棋中的悔棋
- 游戏中的录像回放功能
组合模式
- 在js中,类似宏命令模式,或子命令模式,根据在树形结构的对象上触发的不同位置,依次执行叶子节点的 execute 方法。
- 对调用者无需关心调用对象时组合对象还是普通叶子对象。
- 例如扫描文件夹
注意
- 组合模式不是父子关系组合模式是一种 HAS-A(聚合)的关系,而不是 IS-A。组合对象包含一组叶对象,但 Leaf并不是 Composite 的子类。组合对象把请求委托给它所包含的所有叶对象,它们能够合作的关键是拥有相同的接口。
- 对叶对象操作的一致性组合模式除了要求组合对象和叶对象拥有相同的接口之外,还有一个必要条件,就是对一组叶对象的操作必须具有一致性。
- 双向映射关系发放过节费的通知步骤是从公司到各个部门,再到各个小组,最后到每个员工的邮箱里。这本身是一个组合模式的好例子,但要考虑的一种情况是,也许某些员工属于多个组织架构。比如某位架构师既隶属于开发组,又隶属于架构组,对象之间的关系并不是严格意义上的层次结构,在这种情况下,是不适合使用组合模式的,该架构师很可能会收到两份过节费。
- 用职责链模式提高组合模式性能在组合模式中,如果树的结构比较复杂,节点数量很多,在遍历树的过程中,性能方面也许表现得不够理想。有时候我们确实可以借助一些技巧,在实际操作中避免遍历整棵树,有一种现成的方案是借助职责链模式。职责链模式一般需要我们手动去设置链条,但在组合模式中,父对象和子对象之间实际上形成了天然的职责链。让请求顺着链条从父对象往子对象传递,或者是反过来从子对象往父对象传递,直到遇到可以处理该请求的对象为止,这也是职责链模式的经典运用场景之一。
场景
- 表示对象的部分-整体层次结构。组合模式可以方便地构造一棵树来表示对象的部分-整体结构。特别是我们在开发期间不确定这棵树到底存在多少层次的时候。在树的构造最终完成之后,只需要通过请求树的最顶层对象,便能对整棵树做统一的操作。在组合模式中增加和删除树的节点非常方便,并且符合开放封闭原则。
- 客户希望统一对待树中的所有对象。组合模式使客户可以忽略组合对象和叶对象的区别,客户在面对这棵树的时候,不用关心当前正在处理的对象是组合对象还是叶对象,也就不用写一堆 if、else 语句来分别处理它们。组合对象和叶对象会各自做自己正确的事情,这是组合模式最重要的能力。
举例
- 文件夹的扫描、操作等功能
- 虚拟dom的操作
模板方法模式
- 依赖于抽象类,根据逻辑逐层将抽象事物具体化。
- js中可依托基于原型链的对象委托实现
js如何实现抽象类
- 在派生类对象初始化时利用鸭子类型进行方法判断。
- 在基类中对抽象函数抛出异常
适合的场景
- 完成一段程序有严格的声明周期
- 每个步骤根据具体场景操作内容可能会不同。
举例:常规的现象对象思维都可以,如咖啡和茶的例子
var Beverage = function(){};Beverage.prototype.boilWater = function(){console.log( \'把水煮沸\' );};Beverage.prototype.brew = function(){}; // 空方法,应该由子类重写Beverage.prototype.pourInCup = function(){}; // 空方法,应该由子类重写Beverage.prototype.addCondiments = function(){}; // 空方法,应该由子类重写Beverage.prototype.init = function(){this.boilWater();this.brew();this.pourInCup();this.addCondiments();};var Coffee = function(){};Coffee.prototype = Object.create(Beverage.prototype);Coffee.prototype.brew = function(){console.log( \'用沸水冲泡咖啡\' );};Coffee.prototype.pourInCup = function(){console.log( \'把咖啡倒进杯子\' );}Coffee.prototype.addCondiments = function(){console.log( \'加糖和牛奶\' );};var Coffee = new Coffee();Coffee.init();
享元模式
- 时间换空间的思路
- 通过将对象的基本属性合并,区分内部状态与外部状态。
划分内部状态与外部状态的方法:
- 内部状态储存于对象内部。
- 内部状态可以被一些对象共享。
- 内部状态独立于具体的场景,通常不会改变。
- 外部状态取决于具体的场景,并根据场景而变化,外部状态不能被共享。
适用场景
- 一个程序中使用了大量的相似对象。
- 由于使用了大量对象,造成很大的内存开销。
- 对象的大多数状态都可以变为外部状态。
- 剥离出对象的外部状态之后,可以用相对较少的共享对象取代大量对象。
相似思想
- 利用对象池进行缓存
职责链模式
- 适合多if else 场景, 即: 当请求可被多个处理器处理,但无法一下子定位到指定的处理器时,可以将请求沿职责链持续传递。
劣势
- 职责链的的传递过程当中需要严格保证数据的只读性,即保证数据传递的真实性。
- 当职责链过长时,末端的逻辑执行效率会下降
举例
- 优惠券的发放逻辑
中介者模式
- 适用场景: 多个对象之间互相通信,并且多对多的关系很复杂,并且未来还可能增加更多的对象
- 多对多的思想改为多对一
- 与代理模式的区别:代理本质上为一对一,中介者模式本质上为多对多
劣势
- 中介者对象可能会越来越庞大
- 中介者对象需要很好地设计维护
举例
- 多游戏玩家的互相通通信
- html中多个UI组件之间的互相通信
装饰器模式
- 多类的实例进行装饰,保证装饰器对派生类无影响。
js中装饰器的实现:
- 利用函数对类的实例进行装饰,而不修改原类。
- 利用对象委托,通过装饰器,动态产出装饰后的对象。
奇淫技巧
- 不改写原函数,并对原函数进行扩充
window.onload = function(){alert (1);}var _onload = window.onload || function(){};window.onload = function(){_onload();alert (2);}
切面装饰器(面向切面编程)
Function.prototype.before = function( beforefn ){var __self = this; // 保存原函数的引用return function(){ // 返回包含了原函数和新函数的"代理"函数beforefn.apply( this, arguments ); // 执行新函数,且保证 this 不被劫持,新函数接受的参数// 也会被原封不动地传入原函数,新函数在原函数之前执行return __self.apply( this, arguments ); // 执行原函数并返回原函数的执行结果,// 并且保证 this 不被劫持}}Function.prototype.after = function( afterfn ){var __self = this;return function(){var ret = __self.apply( this, arguments );afterfn.apply( this, arguments );return ret;}};
装饰器模式与代理模式的区别
- 二者虽然都是一对一, 但目的不同。代理模式的目的是,当直接访问本体不方便或者不符合需要时,为这个本体提供一个替代者。本体定义了关键功能,而代理提供或拒绝对它的访问,或者在访问本体之前做一些额外的事情。装饰者模式的作用就是为对象动态加入行为。换句话说,代理模式强调一种关系(Proxy 与它的实体之间的关系),这种关系可以静态的表达,也就是说,这种关系在一开始就可以被确定。而装饰者模式用于一开始不能确定对象的全部功能时。代理模式通常只有一层代理本体的引用,而装饰者模式经常会形成一条长长的装饰链。
举例
- 对象的动态变化,例如玩家等级的提升,例如大话西游的转生,飞升等
状态模式
- 在不同条件下,类的实例对象的行为会有不同的变化。
优点
- 状态模式定义了状态与行为之间的关系,并将它们封装在一个类里。通过增加新的状态类,很容易增加新的状态和转换。
- 避免 Context 无限膨胀,状态切换的逻辑被分布在状态类中,也去掉了 Context 中原本过多的条件分支。
- 用对象代替字符串来记录当前状态,使得状态的切换更加一目了然。
- Context 中的请求动作和状态类中封装的行为可以非常容易地独立变化而互不影响。
缺点
- 可能需要编写更多的状态类
- 不同的逻辑分散在不同的状态类中,可读性并没有提高很多。
可优化点
- 状态对象的创建可以按需创建,也可以一次性创建后缓存下来。可根据具体的应用场景进行设计。
- 状态类之间可能有很多公共的部分,可根据享元模式进行抽离。
状态模式与策略模式的区别与联系
- 状态模式和策略模式像一对双胞胎,它们都封装了一系列的算法或者行为,它们的类图看起来几乎一模一样,但在意图上有很大不同,因此它们是两种迥然不同的模式。策略模式和状态模式的相同点是,它们都有一个上下文、一些策略或者状态类,上下文把请求委托给这些类来执行。它们之间的区别是策略模式中的各个策略类之间是平等又平行的,它们之间没有任何联系,所以客户必须熟知这些策略类的作用,以便客户可以随时主动切换算法;而在状态模式中,状态和状态对应的行为是早已被封装好的,状态之间的切换也早被规定完成,“改变行为”这件事情发生在状态模式内部。对客户来说,并不需要了解这些细节。这正是状态模式的作用所在。
js 用对象委托实现状态模式,通过call,将请求委托给不同的状态对象:
- 举例:开环的不同状态
var Light = function(){this.currState = FSM.off; // 设置当前状态this.button = null;};Light.prototype.init = function(){var button = document.createElement( \'button\' ),self = this;button.innerHTML = \'已关灯\';this.button = document.body.appendChild( button );this.button.onclick = function(){self.currState.buttonWasPressed.call( self ); // 把请求委托给 FSM 状态机}};var FSM = {off: {buttonWasPressed: function(){console.log( \'关灯\' );this.button.innerHTML = \'下一次按我是开灯\';this.currState = FSM.on;}},on: {buttonWasPressed: function(){console.log( \'开灯\' );this.button.innerHTML = \'下一次按我是关灯\';this.currState = FSM.off;}}};var light = new Light();light.init();
基于表结构的状态机
- 当状态的逻辑比较复杂时,可以依据表结构梳理不同条件下的最终状态。
状态机举例
- 在实际开发中,很多场景都可以用状态机来模拟,比如一个下拉菜单在 hover 动作下有显示、悬浮、隐藏等状态;一次 TCP 请求有建立连接、监听、关闭等状态;一个格斗游戏中人物有攻击、防御、跳跃、跌倒等状态。状态机在游戏开发中也有着广泛的用途,特别是游戏 AI 的逻辑编写。在我曾经开发的HTML5 版街头霸王游戏里,游戏主角 Ryu 有走动、攻击、防御、跌倒、跳跃等多种状态。这些状态之间既互相联系又互相约束。比如 Ryu 在走动的过程中如果被攻击,就会由走动状态切换为跌倒状态。在跌倒状态下,Ryu 既不能攻击也不能防御。同样,Ryu 也不能在跳跃的过程中切换到防御状态,但是可以进行攻击。这种场景就很适合用状态机来描述。代码如下:
var FSM = {walk: {attack: function(){console.log( \'攻击\' );},defense: function(){console.log( \'防御\' );},jump: function(){console.log( \'跳跃\' );}},attack: {walk: function(){console.log( \'攻击的时候不能行走\' );},defense: function(){console.log( \'攻击的时候不能防御\' );},jump: function(){console.log( \'攻击的时候不能跳跃\' );}}}
适配器模式
- 在通信或互相调用时,二者无法匹配时,通过适配器模式进行转接。
场景&举例
- 多API的统一接口封装
- 前端的跨平台组件调用一致性问题
相似模式之间的差别
- 适配器模式主要用来解决两个已有接口之间不匹配的问题,它不考虑这些接口是怎样实现的,也不考虑它们将来可能会如何演化。适配器模式不需要改变已有的接口,就能够使它们协同作用。
- 装饰者模式和代理模式也不会改变原有对象的接口,但装饰者模式的作用是为了给对象增加功能。装饰者模式常常形成一条长的装饰链,而适配器模式通常只包装一次。代理模式是为了控制对对象的访问,通常也只包装一次。
- 外观模式的作用倒是和适配器比较相似,有人把外观模式看成一组对象的适配器,但外观模式最显著的特点是定义了一个新的接口。
设计原则
单一职责原则:
- 对于一个类,对象或方法,只有一种原因会引起其自身的变化
- 优点:方便代码复用,方便单元测试,提高扩展性
- 缺点: 增加代码复杂度,一定程度上降低了代码的可读性。
好莱坞原则
- 底层组件负责注册,高层组件负责调用的具体逻辑。
最少知识原则
- 一个对象应该尽可能少与其他对象有关系(即尽可能少的引用其他对象)
开放-封闭原则
- 软件实体(类、模块、函数)等应该是可以扩展的,但是不可修改。其含义是说一个软件实体应该通过扩展来实现变化,而不是通过修改已有的代码来实现变化。
重构的注意点
- 提炼函数
- 合并重复的条件片段
- 将条件分支提炼成函数
- 合理利用循环
var createXHR = function(){var xhr;try{xhr = new ActiveXObject( \'MSXML2.XMLHttp.6.0\' );}catch(e){try{xhr = new ActiveXObject( \'MSXML2.XMLHttp.3.0\' );}catch(e){xhr = new ActiveXObject( \'MSXML2.XMLHttp\' );}}return xhr;};var xhr = createXHR();
改写后:
var createXHR = function(){var versions= [ \'MSXML2.XMLHttp.6.0ddd\', \'MSXML2.XMLHttp.3.0\', \'MSXML2.XMLHttp\' ];for ( var i = 0, version; version = versions[ i++ ]; ){try{return new ActiveXObject( version );}catch(e){}}};var xhr = createXHR();
- 提前让函数退出循环分支
- 尽量减少参数数量
- 传递对象参数替代过长的参数列表
- 少用三木运算符
- 合理使用链式调用
- 分解大型类