基于有限状态机与交互组件设计与实现

 

有限状态机(Finite-state machine)是一个非常有用的模型,可以模拟世界上大部分事物。

简单说,它有三个特征:

  * 状态总数(state)是有限的。
  * 任一时刻,只处在一种状态之中。
  * 某种条件下,会从一种状态转变(transition)到另一种状态。

它对JavaScript的意义在于,很多对象可以写成有限状态机。

举例来说,网页上有一个菜单元素。鼠标悬停的时候,菜单显示;鼠标移开的时候,菜单隐藏。如果使用有限状态机描述,就是这个菜单只有两种状态(显示和隐藏),鼠标会引发状态转变。

代码可以写成下面这样:


  var menu = {
      
    // 当前状态
    currentState: 'hide',
  
    // 绑定事件
    initialize: function() {
      var self = this;
      self.on("hover", self.transition);
    },
  
    // 状态转换
    transition: function(event){
      switch(this.currentState) {
        case "hide":
          this.currentState = 'show';
          doSomething();
          break;
        case "show":
          this.currentState = 'hide';
          doSomething();
          break;
        default:
          console.log('Invalid State!');
          break;
      }
    }
  
  };
  

可以看到,有限状态机的写法,逻辑清晰,表达力强,有利于封装事件。一个对象的状态越多、发生的事件越多,就越适合采用有限状态机的写法。

另外,JavaScript语言是一种异步操作特别多的语言,常用的解决方法是指定回调函数,但这样会造成代码结构混乱、难以测试和除错等问题。有限状态机提供了更好的办法:把异步操作与对象的状态改变挂钩,当异步操作结束的时候,发生相应的状态改变,由此再触发其他操作。这要比回调函数、事件监听、发布/订阅等解决方案,在逻辑上更合理,更易于降低代码的复杂度。

下面介绍一个有限状态机的函数库Javascript Finite State Machine。这个库非常好懂,可以帮助我们加深理解,而且功能一点都不弱。

该库提供一个全局对象StateMachine,使用该对象的create方法,可以生成有限状态机的实例。


  var fsm = StateMachine.create();
  

生成的时候,需要提供一个参数对象,用来描述实例的性质。比如,交通信号灯(红绿灯)可以这样描述:


  var fsm = StateMachine.create({
  
    initial: 'green',
  
    events: [
      { name: 'warn',  from: 'green',  to: 'yellow' },
      { name: 'stop', from: 'yellow', to: 'red' },
      { name: 'ready',  from: 'red',    to: 'yellow' },
      { name: 'go', from: 'yellow', to: 'green' }
    ]
  
  });
  

交通信号灯的初始状态(initial)为green,events属性是触发状态改变的各种事件,比如warn事件使得green状态变成yellow状态,stop事件使得yellow状态变成red状态等等。

生成实例以后,就可以随时查询当前状态。

* fsm.current :返回当前状态。
* fsm.is(s) :返回一个布尔值,表示状态s是否为当前状态。
* fsm.can(e) :返回一个布尔值,表示事件e是否能在当前状态触发。
* fsm.cannot(e) :返回一个布尔值,表示事件e是否不能在当前状态触发。

Javascript Finite State Machine允许为每个事件指定两个回调函数,以warn事件为例:

* onbeforewarn:在warn事件发生之前触发。
* onafterwarn(可简写成onwarn) :在warn事件发生之后触发。

同时,它也允许为每个状态指定两个回调函数,以green状态为例:

* onleavegreen :在离开green状态时触发。
* onentergreen(可简写成ongreen) :在进入green状态时触发。

假定warn事件使得状态从green变为yellow,上面四类回调函数的发生顺序如下:onbeforewarn → onleavegreen → onenteryellow → onafterwarn

除了为每个事件和状态单独指定回调函数,还可以为所有的事件和状态指定通用的回调函数。

* onbeforeevent :任一事件发生之前触发。
* onleavestate :离开任一状态时触发。
* onenterstate :进入任一状态时触发。
* onafterevent :任一事件结束后触发。

如果事件的回调函数里面有异步操作(比如与服务器进行Ajax通信),这时我们可能希望等到异步操作结束,再发生状态改变。这就要用到transition方法。


  fsm.onleavegreen = function(){
    light.fadeOut('slow', function() {
      fsm.transition();
    });
    return StateMachine.ASYNC;
  };
  

上面代码的回调函数里面,有一个异步操作(light.fadeOut)。如果不希望状态立即改变,就要让回调函数返回StateMachine.ASYNC,表示状态暂时不改变;等到异步操作结束,再调用transition方法,使得状态发生改变。

Javascript Finite State Machine还允许指定错误处理函数,当发生了当前状态不可能发生的事件时自动触发。


  var fsm = StateMachine.create({
    // ...
    error: function(eventName, from, to, args, errorCode, errorMessage) {
      return 'event ' + eventName + ': ' + errorMessage;
    },
    // ... 
  });
  

比如,当前状态是green,理论上这时只可能发生warn事件。要是这时发生了stop事件,就会触发上面的错误处理函数。

 

Javascript Finite State Machine的基本用法就是上面这些,更详细的介绍可以参见它的主页

 

有限状态机的实现

有限状态机(Finite State Machine或者Finite State Automata)是软件领域中一种重要的工具,很多东西的模型实际上就是有限状态机。

最近看了一些游戏编程AI的材料,感觉游戏中的AI,第一要说的就是有限状态机来实现精灵的AI,然后才是A*寻路,其他学术界讨论比较多的神经网络、模糊控制等问题还不是很热。

FSM的实现方式:
1) switch/case或者if/else
这无意是最直观的方式,使用一堆条件判断,会编程的人都可以做到,对简单小巧的状态机来说最合适,但是毫无疑问,这样的方式比较原始,对庞大的状态机难以维护。

2) 状态表
维护一个二维状态表,横坐标表示当前状态,纵坐标表示输入,表中一个元素存储下一个状态和对应的操作。这一招易于维护,但是运行时间和存储空间的代价较大。

3) 使用State Pattern
使用State Pattern使得代码的维护比switch/case方式稍好,性能上也不会有很多的影响,但是也不是100%完美。不过Robert C. Martin做了两个自动产生FSM代码的工具,for java和for C++各一个,在http://www.objectmentor.com/resources/index上有免费下载,这个工具的输入是纯文本的状态机描述,自动产生符合State Pattern的代码,这样developer的工作只需要维护状态机的文本描述,每必要冒引入bug的风险去维护code。

4) 使用宏定义描述状态机
一般来说,C++编程中应该避免使用#define,但是这主要是因为如果用宏来定义函数的话,很容易产生这样那样的问题,但是巧妙的使用,还是能够产生奇妙的效果。MFC就是使用宏定义来实现大的架构的。
在实现FSM的时候,可以把一些繁琐无比的if/else还有花括号的组合放在宏中,这样,在代码中可以3)中状态机描述文本一样写,通过编译器的预编译处理产生1)一样的效果,我见过产生C代码的宏,如果要产生C++代码,己软MFC可以,那么理论上也是可行的。

 

 

基于有限状态机的交互组件设计与实现

有限状态机(FSM)(维基百科)是设计和实现事件驱动程序内复杂行为组织原则的有力工具。 

早在2007年,IBM的工程师就提出在JavaScript中使用有限状态机来实现组件的方法,原文地址如下:《JavaScript 中的有限状态机》 

现在结合KISSY等现代JS库和框架提供的强大的自定义事件的功能,我们可以利用有限状态机设计出代码层次清晰,结构优雅的前端交互组件。 

今天,我们会通过设计并实现一个下拉选择(模拟select)组件来一步步说明如何利用FSM和KISSY来设计和实现一个有复杂行为的交互组件。 

我们的工作会分成三个步骤来进行: 

  • 第一步:设计组件状态,用户行为和组件行为
  • 第二步:通过代码来描述设计出来的内容
  • 第三步:实现一个有限状态机让组件工作起来

第一步:设计阶段 

首先,我们需要确定组件的状态和状态间的转换关系。通过对组件可能会发生的行为进行研究,我们为组件设计了以下三个状态: 

1.  收起状态(fold) 


组件的初始状态,用户可能会进行以下操作: 

  • 展开下拉框(unfoldmenu)转移到展开状态(unfold)

2.  展开状态(unfold) 


用户展开下拉框的状态,用户可能会进行以下操作: 

  • 收起下拉框(foldmenu)转移到收起状态(fold)
  • 鼠标经过选项(overitem)转移到高亮状态(highlight)

3.  高亮状态(highlight) 


鼠标经过选项时,高亮经过的选项,用户可能会进行以下操作: 

  • 收起下拉框(foldmenu)转移到收起状态(fold)
  • 点击选项(clickitem)转移到收起状态(fold)
  • 鼠标经过选项(overitem)转移到高亮状态(highlight)

以上就是这个小组件可能会有的三种状态,用一个状态转换图来表示如下: 



  • 在状态描述中包含了触发状态发生转移的动作(事件)
  • 可以很明显的看出这些事件并不是浏览器中原生的事件。
  • 这里,我们使用自定义事件来描述用户的行为,这样我们可以使得用户行为和组件行为的逻辑完全分离,代码将会更容易理解和维护。


定义用户行为: 

在这个组件里,我们有以下四种用户行为: 

  • 展开下拉框(unfoldmenu):鼠标点击橙色区域时触发
  • 收起下拉框(foldmenu):鼠标离开组件区域达到2秒,点击橙色区域,点击组件外部区域
  • 点击选项(clickitem):点击下拉框中的某个选项
  • 鼠标经过选项(overitem):鼠标经过下拉框中的某个选项


定义组件行为: 

在状态转移的过程中,组件本身会有很多动作,如显示下拉框等,我们接下来在上面的状态图中加入转移过程中组件的动作。 

 

  • fold():收起下拉框
  • unfold():展开下拉框
  • highlightItem():高亮某个选项
  • selectItem():选中某个选项,并把值填充到橘黄色区域

第二步:实现阶段(基于KISSY实现) 

全局变量:S=KISSY, D=S.DOM, E=S.Event 

1.  描述状态 

跟设计过程一样,我们需要用一个结构来描述状态的转移以及转移过程中的动作。我们在这里使用对象来描述:

Javascript代码 
  1. "fold":{  
  2.     unfoldmenu:function(event){  
  3.         _this.unfold();  
  4.         return "unfold";  
  5.     }  
  6. }  



如上面这段代码就描述了在fold状态下,可以触发unfoldmenu这个用户行为来转移到unfold状态,我们通过函数返回值的形式来通知FSM下一步的状态。这样,我们就可以通过这种形式描述所有的状态,结构如下: 

Javascript代码 
  1. states:{  
  2.     //收起(初始状态)  
  3.     "fold":{  
  4.         unfoldmenu:function(event){  
  5.             _this.unfold();  
  6.             return "unfold";  
  7.         }  
  8.     },  
  9.     //展开状态  
  10.     "unfold":{  
  11.         foldmenu:function(event){  
  12.             _this.fold();  
  13.             return "fold";  
  14.         },  
  15.         overitem:function(event){  
  16.             _this.highlightItem(event.currentItem);  
  17.             return "highlight";  
  18.         }  
  19.     },  
  20.     //高亮状态  
  21.     "highlight":{  
  22.         foldmenu:function(event){  
  23.             _this.fold();  
  24.             return "fold";  
  25.         },  
  26.         //选中条目  
  27.         clickitem:function(event){  
  28.             _this.selectItem(event.currentItem);  
  29.             return "fold";  
  30.         },  
  31.         overitem:function(event){  
  32.             _this.highlightItem(event.currentItem);  
  33.             return "highlight";  
  34.         }  
  35.     }  
  36. }  



在定义好状态后,我们还需要设定一个初始状态: 

Javascript代码 
  1. initState:"fold"  



2.  描述用户行为 

我们使用一个方法来描述用户行为,即驱动FSM发生状态转移的事件: 

Javascript代码 
  1. "foldmenu":function(fn){  
  2.     var timeout;  
  3.     E.on(_this.container,"mouseleave",function(e){  
  4.         if(timeout)clearTimeout(timeout);  
  5.         timeout = setTimeout(function(){  
  6.             fn();  
  7.         },1000);  
  8.     });  
  9.     E.on([_this.container,_this.slideBox],"mouseenter",  
  10.     function(e){  
  11.         if(timeout)clearTimeout(timeout);  
  12.     });  
  13.     E.on("body","click",function(e){  
  14.         var target = e.target;  
  15.         if(!D.get(target,_this.container)){  
  16.             if(timeout)clearTimeout(timeout);  
  17.             fn();  
  18.         }  
  19.     });  
  20. }  



如上面这个代码就定义了foldmenu这个用户行为,同时,FSM会自动将它定义为一个自定义事件,我们通过传入的回调函数fn来通知FSM触发这个事件的时机。 

通过上边的例子可以看出,我们可以将一个很复杂的动作定义为一个用户行为,也可以将几个不同的动作定义为一个用户行为,将用户行为和组件的动作彻底分开。 

与状态相同,我们也将所有的用户行为放在一个对象中。 

Javascript代码 
  1. events:{  
  2.     "unfoldmenu":function(fn){  
  3.     },  
  4.     "foldmenu":function(fn){  
  5.     },  
  6.     "overitem":function(fn){  
  7.     },  
  8.     "clickitem":function(fn){  
  9.     }  
  10. }  


3.  描述组件行为 

由于组件行为一般都包含对组件本身的一些直接操作,可以作为API开放给用户使用,因此我们把描述组件行为的方法放在组件的prototype上,这部分代码如下: 

Javascript代码 
  1. S.augment(SlideMenu,S.EventTarget,{  
  2.   
  3.     setText:function(){  
  4.         var _this = this,  
  5.         select = _this.select;  
  6.         D.html(select,_this.text);  
  7.     },  
  8.   
  9.     unfold:function(){  
  10.         var _this = this,  
  11.         slideBox = _this.slideBox;  
  12.         if(!_this.isFold)return;  
  13.         _this.isFold = false;  
  14.         D.show(slideBox);  
  15.     },  
  16.   
  17.     fold:function(){  
  18.         var _this = this,  
  19.         options = _this.options,  
  20.         slideBox = _this.slideBox;  
  21.         if(_this.isFold)return;  
  22.         D.removeClass(options,"hover");  
  23.         _this.isFold = true;  
  24.         D.hide(slideBox);  
  25.     },  
  26.   
  27.     highlightItem:function(curItem){  
  28.         var _this = this,  
  29.         options = _this.options;  
  30.         D.removeClass(options,"hover");  
  31.         D.addClass(curItem,"hover");  
  32.     },  
  33.   
  34.     selectItem:function(curItem){  
  35.         var _this = this,  
  36.         value = D.attr(curItem,"data-value"),  
  37.         text = D.attr(curItem,"data-text");  
  38.         _this.value = value;  
  39.         _this.text = text;  
  40.         _this.setText()  
  41.         _this.fold();  
  42.         _this.fire("select",{  
  43.             value:value,  
  44.             text:text  
  45.         });  
  46.     }  
  47. });  



第三步:实现有限状态机(基于KISSY实现) 

前面我们定义了组件的状态,用户行为,以及组件本身的动作,接下来我们来实现一个有限状态机(FSM),让整个组件工作起来。 

通过上面实现的代码,我们可以看出FSM的输入有以下三个: 

  • 初始状态
  • 状态描述对象
  • 用户行为描述对象


代码结构如下: 

Javascript代码 
  1. initState:"fold",  
  2. states:{  
  3.     //收起(初始状态)  
  4.     "fold":{  
  5.     },  
  6.     //展开状态  
  7.     "unfold":{  
  8.     },  
  9.     //高亮状态  
  10.     "highlight":{  
  11.     }  
  12. },  
  13.   
  14. events:{  
  15.     "unfoldmenu":function(fn){  
  16.     },  
  17.     "foldmenu":function(fn){  
  18.     },  
  19.     "overitem":function(fn){  
  20.     },  
  21.     "clickitem":function(fn){  
  22.     }  
  23. }  



FSM需要2个功能: 

  • 将用户行为与自定义事件相关联(defineEvents)
  • 在用户行为发生时(即触发自定义事件时),根据状态描述对象来转移状态(handleEvents)


代码如下: 

Javascript代码 
  1. functionFSM(config){  
  2.     this.config = config;  
  3.     this.currentState = this.config.initState;  
  4.     this.nextState = null;  
  5.     this.states = this.config.states;  
  6.     this.events = this.config.events;  
  7.     this.defineEvents();  
  8. }  
  9.   
  10. var proto = {  
  11.     //事件驱动状态转换(表现层)  
  12.     handleEvents:function(event){  
  13.         if(!this.currentState)return;  
  14.   
  15.     var actionTransitionFunction =  
  16.     this.states[this.currentState][event.type];  
  17.   
  18.         if(!actionTransitionFunction)return;  
  19.           
  20.   
  21.     var nextState = actionTransitionFunction  
  22.     .call(this,event);  
  23.   
  24.         this.currentState = nextState;  
  25.     },  
  26.   
  27.     //定义事件 (行为层)  
  28.     defineEvents:function(){  
  29.         var _this = this,  
  30.         events = this.events;  
  31.         for(k in events){  
  32.             (function(k){  
  33.                 var fn = events[k];  
  34.                 fn.call(_this,function(event){  
  35.                     _this.fire(k,event);  
  36.                 });  
  37.                 _this.on(k,_this.handleEvents);  
  38.             })(k)  
  39.         }  
  40.     }  
  41. }  
  42. S.augment(FSM, S.EventTarget, proto);  



然后,只需要实例化一个FSM即可 

Javascript代码 
  1. new FSM({  
  2.      initState:"fold",  
  3.      states:{...},  
  4.      events:{...}  
  5. });  



总结 

使用FSM模式设计和实现交互组件,可以获得以下特性: 

  • 交互逻辑清晰
  • 用户行为和组件行为完全分离,代码具有良好的分层结构
  • 对设计具有良好的纠错特性,当设计上对状态和状态的转移有遗漏时,在实现阶段很容易流程出现走不通的情况,可以促进交互设计对细节的补充。

源码:https://github.com/yhanwen/fsm

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值