理解前端设计模式(二)

244 阅读12分钟

一,命令模式

有时候需要向某些对象发送请求,但是并不知道请求的接收者是谁,也不知道被请求的操作是什么,此时希望用一种松耦合的方式来设计软件,使得请求发送者和请求接收者能够消除彼此之间的耦合关系。

生活示例:比如点外卖,我点了一份番茄炒蛋外卖,不需要知道厨师、骑手是谁,是怎么做的、怎么送过来的,订单到厨师手里,厨师可以按顺序完成订单,我可以指定1个小时之后送,也可以取消订单;

开发案例:一个页面,有几十处可以点击交互,可以让一人负责页面开发,一人负责编写点击后具体行为(适合大型项目开发,对于按功能模块进行组件化开发的项目应该不会用到这样的分工方式)

var bindClick = function( button, func ){
 button.onclick = func;
};
// 接收者对象
var MenuBar = {
 refresh: function(){
 console.log( '刷新菜单界面' );
 }
};
var SubMenu = {
 add: function(){
 console.log( '增加子菜单' );
 },
 del: function(){
 console.log( '删除子菜单' );
 }
};
bindClick( button1, MenuBar.refresh ); 
bindClick( button2, SubMenu.add );
bindClick( button3, SubMenu.del ); 

命令模式的由来,其实是回调函数的一个面向对象的替代品。简单说就是根据面向对象的思路,通过回调函数来实现请求发送者和接收者之间的解耦,这种设计模式就叫命令模式。

进阶需求

1. 数据互不干扰

接收者对象里不止有方法,还有数据保存,执行对应方法会修改数据,而方法会在多处被调用,但数据不能相互影响

// 接收者对象增加数据保存
var MenuBar = {
  data: 1,
  refresh: function(){
    MenuBar.data = MenuBar.data + 1;
    alert(MenuBar.data);
  }
};
// 通过闭包每次调用保存的接收者对象互不影响
var RefreshMenuBarCommand = function( receiver ){
  return function(){
    receiver.refresh();
  }
}; 
var refreshMenuBarCommand1 = RefreshMenuBarCommand( MenuBar ); 
var refreshMenuBarCommand2 = RefreshMenuBarCommand( MenuBar ); 
bindClick( button1, refreshMenuBarCommand1 ); 
bindClick( button2, refreshMenuBarCommand2 ); 
2. 撤销操作

命令模式除了执行命令,还可能需要提供撤销命令操作,只需要在接收者对象中增加一个属性保存上一次的数据,再提供一个撤销方法即可

如果需要撤销多步操作,则需要用一个数组保存之前的历史数据,然后多次执行撤销方法

对于难以撤销的操作比如Canvas绘图,可以通过保存每次的执行记录,需要撤销操作时删除画布重新执行之前的操作

3. 排队

接收者完成命令需要时间,比如点一次按钮会有1s的动画,如果用户高频点击按钮时必须在前面的动画完成之后才能开始下一个动画,依次执行。这里我们可以在属性里维护一个队列,就像js的栈队列,在前一个动画完成后取出下一个命令开始执行,直到执行完栈中的所有命令

思考

命令是个很大的概念,那前后端分离是不是也是命令模式的一种体现,前端需要渲染列表的数据,发请求就是命令(给我xxx的数据),类似订单,可以延迟请求,可以排队,可以撤销请求,不需要知道后端是怎么接收的请求,是怎么生成的数据,后端处理数据逻辑,提供数据,不需要知道是谁在请求数据

二,组合模式

现在很盛行的“一键”功能就是组合模式的体现,比如一键激活windows,背后其实是依次执行了一连串的命令,比较直观的,我们在通过应用商店下载软件时只需要点击下载,就会自动完成系列操作(下载-安装-清理安装包)。

组合模式就是将一系列命令按业务要求放在一个组合里,当执行该组合就会遍历依次执行组合里的所有命令,并且组合也可以被当做一个命令放到另一个组合里,形成一个树模型,组合是树枝,命令是叶子,这就要求组合必须保持和命令相同的触发api,使得使用者无需感知这是组合还是单命令,都可以直接使用

组合需要提供add方法来添加命令,但使用者无法感知组合和命令的区别,可能会调用命令的add方法,所以必须给命令的add方法加上异常处理,这样在调试时就能提醒开发者这是个命令,不能用add方法

下面给出示例简单实现组合模式

// 用来生成组合对象-树枝
var MacroCommand = function(){
  return {
    commandsList: [],
    add: function( command ){
      this.commandsList.push( command );
    },
    // 和执行命令相同的api
    execute: function(){
      for ( var i = 0, command; command = this.commandsList[ i++ ]; ){
        command.execute();
      }
    }
  }
};
// 命令-叶子
var openTvCommand = {
  execute: function(){
    console.log( '打开电视' );
  },
  add: function(){ 
    throw new Error( '叶对象不能添加子节点' );
  }
};
var macroCommand = MacroCommand();
macroCommand.add( openTvCommand );
openTvCommand.add( macroCommand ) // Uncaught Error: 叶对象不能添加子节点 

注意点

  • 组合模式不是父子关系,只是一种聚和,经常会说成父子节点只是帮助表达的语义化
  • 双向映射关系,为了避免同一个命令出现在多个组合中导致重复执行,应该在子节点上保存父节点的引用,形成双向映射,不过这样会大大增加节点间的耦合性,增加维护难度
  • 缺点:组合模式的使用要求满足树结构,这就限制了它的使用场景,并且如果通过组合模式创建大量对象,也会增加系统的负担

三,模板方法模式

这个模式很常见,平常使用的框架就是模板方法模式的体现,它的骨架也就是算法架构是固定的,类似模板,另外还有很多的api可以自定义配置,就是在重写内置的默认方法

实现一个简单的模板方法-冲泡饮料

var Beverage = function(){};
Beverage.prototype.boilWater = function(){
 console.log( '把水煮沸' );
};
Beverage.prototype.brew = function(){
 throw new Error( '子类必须重写 brew 方法' );
};
Beverage.prototype.pourInCup = function(){
 throw new Error( '子类必须重写 pourInCup 方法' );
};
Beverage.prototype.addCondiments = function(){
 throw new Error( '子类必须重写 addCondiments 方法' );
};
Beverage.prototype.customerWantsCondiments = function(){
 return true; // 默认需要调料
};
Beverage.prototype.init = function(){
 this.boilWater();
 this.brew();
 this.pourInCup();
 if ( this.customerWantsCondiments() ){ // 如果挂钩返回 true,则需要调料
 this.addCondiments();
 }
}; 

这是书上的模板方法模式案例,这里面有两个地方需要关注:

  • init方法用于执行模板,就像react框架有个npm run service来编译并启动项目
  • 可以使用挂钩来让用户自己决定部分方法要不要调用,从而改变代码执行走向

好莱坞原则

允许底层组件将自己挂钩到高层组件中,而高层组件会决定在什么时候,以何种方式去使用这些底层组件,就像好莱坞的演员投递简历后,只能等演艺公司通知,这就是好莱坞原则

发布-订阅模式也符合好莱坞原则, 订阅者在把自身的更新方法添加到发布者的订阅队列后,就只能等待发布者调用

异步请求中,我们不需要知道数据什么时候返回,只需要把返回后的操作通过回调函数的形式传入异步请求中,数据返回后触发回调

四,享元模式

用于性能优化,通过创建共享对象避免因创建大量类似对象导致内存占用过高,特别是移动端浏览器分配的内存不高

内部状态和外部状态

将对象的属性划分为内部状态和外部状态,以便创建出需要的共享对象

如何划分:

  • 内部状态存储于对象内部
  • 内部状态可以被一些对象共享
  • 内部状态独立于具体的场景,通常不会改变
  • 外部状态取决于具体的场景,并根据场景而变化,外部状态不能被共享 这样我们可以把内部状态相同的对象指定为一个共享的对象,在需要时再传入外部状态组成完整的对象,通过增加微不足道的传入外部状态的时间来大大减少系统中的对象数量,这就是享元模式,用时间换空间的优化

五,职责链模式

将对象连成一条链,请求沿着链传递,直到有一个对象处理它为止

请求者不需要知道应该由谁来处理请求,只需要把请求给第一个节点,节点会判断是否能处理,不能则继续往下一个节点传递这个请求,这样可以避免写很多的if分支语句,方便理解和维护

前面有提到策略模式也可以用来避免写过多的if判断语句,不同在于策略模式通过参数就可以决定使用何种策略,职责链用于更加不确定的场景,因为只有在节点内运行过才知道能不能处理

demo示例

// 场景:通过传入参数orderType(是否定金客户),pay(定金是否已支付),stock(库存数量)决定是否返回返回优惠券
/* ----创建节点start---- */
var order500 = function( orderType, pay, stock ){
 if ( orderType === 1 && pay === true ){
 console.log( '500 元定金预购,得到 100 优惠券' );
 }else{
 return 'nextSuccessor'; // 我不知道下一个节点是谁,反正把请求往后面传递
 }
};
var order200 = function( orderType, pay, stock ){
 if ( orderType === 2 && pay === true ){
 console.log( '200 元定金预购,得到 50 优惠券' );
 }else{
 return 'nextSuccessor'; // 我不知道下一个节点是谁,反正把请求往后面传递
 }
};
var orderNormal = function( orderType, pay, stock ){
 if ( stock > 0 ){
 console.log( '普通购买,无优惠券' );
 }else{
 console.log( '手机库存不足' );
 }
}; 
/* ----创建节点end---- */

// 用AOP(装饰者模式)实现职责链
Function.prototype.after = function( fn ){
 var self = this;
 return function(){
 var ret = self.apply( this, arguments );
 if ( ret === 'nextSuccessor' ){
 return fn.apply( this, arguments );
 }
 return ret;
 }
};
// 创建职责链
var order = order500yuan.after( order200yuan ).after( orderNormal );
// 执行结果
order( 1, true, 500 ); // 输出:500 元定金预购,得到 100 优惠券
order( 2, true, 500 ); // 输出:200 元定金预购,得到 50 优惠券
order( 1, false, 500 ); // 输出:普通购买,无优惠券

注意点

  • 使用AOP创建职责链应避免链条过长,因为函数的作用域叠加在了一起,容易对性能造成较大影响
  • 链条最后一个节点应做保底处理,避免请求直接从链尾离开

六,中介者模式

解除对象和对象之间的紧耦合关系:对象之间通过中介者通信,而不是相互引用

场景:

假设有红蓝两队游戏,各有4个人,若我方全部阵亡,会通知所有队员游戏失败,所有敌人游戏胜利。

分析:

要实现这个需求,每个玩家都需要有所有玩家的引用,在死亡时遍历所有队友,如果队友也全部死亡,则通知所有玩家游戏结果

扩展场景:

游戏玩家可能不止四个,队伍可能不止两个,还有可能换队伍,每个玩家的负担都会很重,引用变得无比复杂

优化:

现实其实就有类似的例子,每时每刻都有几百(可能吧)架飞机在天上飞,还不断有飞机起飞,降落。难道每架飞机都要时刻知道其他所有正在天上飞的飞机位置才能避免撞机吗?

当然不是,因为有机场指挥塔的存在,飞机只需要跟指挥塔联系,由指挥塔协调航线,就算遇到紧急情况,只需要通知指挥塔,再由指挥塔协调通知其他有关的飞机调整航线,这样一来,飞机甚至不需要知道有其他飞机的存在

中介者示例代码:

var playerDirector= ( function(){
  var players = {}, // 保存所有玩家
  operations = {}; // 中介者可以执行的操作
 /****************新增一个玩家***************************/
 operations.addPlayer = function( player ){
  var teamColor = player.teamColor; // 玩家的队伍颜色
  players[ teamColor ] = players[ teamColor ] || []; // 如果该颜色的玩家还没有成立队伍,则新成立一个队伍
  players[ teamColor ].push( player ); // 添加玩家进队伍
 };
 /****************移除一个玩家***************************/
 operations.removePlayer = function( player ){
  var teamColor = player.teamColor, // 玩家的队伍颜色
  teamPlayers = players[ teamColor ] || []; // 该队伍所有成员
  for ( var i = teamPlayers.length - 1; i >= 0; i-- ){ // 遍历删除
   if ( teamPlayers[ i ] === player ){
    teamPlayers.splice( i, 1 );
   }
  }
 };
 /****************玩家换队***************************/
 operations.changeTeam = function( player, newTeamColor ){ // 玩家换队
  operations.removePlayer( player ); // 从原队伍中删除
  player.teamColor = newTeamColor; // 改变队伍颜色
  operations.addPlayer( player ); // 增加到新队伍中
 };
 operations.playerDead = function( player ){ // 玩家死亡
  var teamColor = player.teamColor,
  teamPlayers = players[ teamColor ]; // 玩家所在队伍
  var all_dead = true;
  for ( var i = 0, player; player = teamPlayers[ i++ ]; ){
   if ( player.state !== 'dead' ){
    all_dead = false;
    break;
   }
  }
  if ( all_dead === true ){ // 全部死亡
   for ( var i = 0, player; player = teamPlayers[ i++ ]; ){
    player.lose(); // 本队所有玩家 lose
   }
   for ( var color in players ){
    if ( color !== teamColor ){
     var teamPlayers = players[ color ]; // 其他队伍的玩家
     for ( var i = 0, player; player = teamPlayers[ i++ ]; ){
      player.win(); // 其他队伍所有玩家 win
     }
    }
   }
  }
 };
 var reciveMessage = function(){
  var message = Array.prototype.shift.call( arguments ); // arguments 的第一个参数为消息名称
  operations[ message ].apply( this, arguments );
 };
 return {
  reciveMessage: reciveMessage
 }
})();

最后每个玩家状态发生改变时只需要调用playerDirector.reciveMessage将操作类型和自身引用传给中介者即可

最少知识原则

也叫迪米特法则,指一个对象应尽可能少的了解其他对象。如果对象之间耦合性太高,难免会相互影响。

而中介者模式中,对象不知道其他对象的存在,只能通过中介者间接影响

当然中介者模式也有缺点,系统会增加一个中介者对象,而中介者对象要处理对象之间复杂的引用,往往本身就相当复杂难以维护。就像中间商的存在让货物从工厂到客户手中变得简单,但中间商也会赚差价~!

所以开始搭建项目时对象之间必要的耦合是可以接受的,当确实因为对象之间复杂的耦合导致维护困难,就可以考虑通过中介者模式重构代码