有限状态机(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. 描述状态
跟设计过程一样,我们需要用一个结构来描述状态的转移以及转移过程中的动作。我们在这里使用对象来描述:
- "fold":{
- unfoldmenu:function(event){
- _this.unfold();
- return "unfold";
- }
- }
如上面这段代码就描述了在fold状态下,可以触发unfoldmenu这个用户行为来转移到unfold状态,我们通过函数返回值的形式来通知FSM下一步的状态。这样,我们就可以通过这种形式描述所有的状态,结构如下:
- states:{
- //收起(初始状态)
- "fold":{
- unfoldmenu:function(event){
- _this.unfold();
- return "unfold";
- }
- },
- //展开状态
- "unfold":{
- foldmenu:function(event){
- _this.fold();
- return "fold";
- },
- overitem:function(event){
- _this.highlightItem(event.currentItem);
- return "highlight";
- }
- },
- //高亮状态
- "highlight":{
- foldmenu:function(event){
- _this.fold();
- return "fold";
- },
- //选中条目
- clickitem:function(event){
- _this.selectItem(event.currentItem);
- return "fold";
- },
- overitem:function(event){
- _this.highlightItem(event.currentItem);
- return "highlight";
- }
- }
- }
在定义好状态后,我们还需要设定一个初始状态:
- initState:"fold"
2. 描述用户行为
我们使用一个方法来描述用户行为,即驱动FSM发生状态转移的事件:
- "foldmenu":function(fn){
- var timeout;
- E.on(_this.container,"mouseleave",function(e){
- if(timeout)clearTimeout(timeout);
- timeout = setTimeout(function(){
- fn();
- },1000);
- });
- E.on([_this.container,_this.slideBox],"mouseenter",
- function(e){
- if(timeout)clearTimeout(timeout);
- });
- E.on("body","click",function(e){
- var target = e.target;
- if(!D.get(target,_this.container)){
- if(timeout)clearTimeout(timeout);
- fn();
- }
- });
- }
如上面这个代码就定义了foldmenu这个用户行为,同时,FSM会自动将它定义为一个自定义事件,我们通过传入的回调函数fn来通知FSM触发这个事件的时机。
通过上边的例子可以看出,我们可以将一个很复杂的动作定义为一个用户行为,也可以将几个不同的动作定义为一个用户行为,将用户行为和组件的动作彻底分开。
与状态相同,我们也将所有的用户行为放在一个对象中。
- events:{
- "unfoldmenu":function(fn){
- },
- "foldmenu":function(fn){
- },
- "overitem":function(fn){
- },
- "clickitem":function(fn){
- }
- }
3. 描述组件行为
由于组件行为一般都包含对组件本身的一些直接操作,可以作为API开放给用户使用,因此我们把描述组件行为的方法放在组件的prototype上,这部分代码如下:
- S.augment(SlideMenu,S.EventTarget,{
- setText:function(){
- var _this = this,
- select = _this.select;
- D.html(select,_this.text);
- },
- unfold:function(){
- var _this = this,
- slideBox = _this.slideBox;
- if(!_this.isFold)return;
- _this.isFold = false;
- D.show(slideBox);
- },
- fold:function(){
- var _this = this,
- options = _this.options,
- slideBox = _this.slideBox;
- if(_this.isFold)return;
- D.removeClass(options,"hover");
- _this.isFold = true;
- D.hide(slideBox);
- },
- highlightItem:function(curItem){
- var _this = this,
- options = _this.options;
- D.removeClass(options,"hover");
- D.addClass(curItem,"hover");
- },
- selectItem:function(curItem){
- var _this = this,
- value = D.attr(curItem,"data-value"),
- text = D.attr(curItem,"data-text");
- _this.value = value;
- _this.text = text;
- _this.setText()
- _this.fold();
- _this.fire("select",{
- value:value,
- text:text
- });
- }
- });
第三步:实现有限状态机(基于KISSY实现)
前面我们定义了组件的状态,用户行为,以及组件本身的动作,接下来我们来实现一个有限状态机(FSM),让整个组件工作起来。
通过上面实现的代码,我们可以看出FSM的输入有以下三个:
- 初始状态
- 状态描述对象
- 用户行为描述对象
代码结构如下:
- initState:"fold",
- states:{
- //收起(初始状态)
- "fold":{
- },
- //展开状态
- "unfold":{
- },
- //高亮状态
- "highlight":{
- }
- },
- events:{
- "unfoldmenu":function(fn){
- },
- "foldmenu":function(fn){
- },
- "overitem":function(fn){
- },
- "clickitem":function(fn){
- }
- }
FSM需要2个功能:
- 将用户行为与自定义事件相关联(defineEvents)
- 在用户行为发生时(即触发自定义事件时),根据状态描述对象来转移状态(handleEvents)
代码如下:
- functionFSM(config){
- this.config = config;
- this.currentState = this.config.initState;
- this.nextState = null;
- this.states = this.config.states;
- this.events = this.config.events;
- this.defineEvents();
- }
- var proto = {
- //事件驱动状态转换(表现层)
- handleEvents:function(event){
- if(!this.currentState)return;
- var actionTransitionFunction =
- this.states[this.currentState][event.type];
- if(!actionTransitionFunction)return;
- var nextState = actionTransitionFunction
- .call(this,event);
- this.currentState = nextState;
- },
- //定义事件 (行为层)
- defineEvents:function(){
- var _this = this,
- events = this.events;
- for(k in events){
- (function(k){
- var fn = events[k];
- fn.call(_this,function(event){
- _this.fire(k,event);
- });
- _this.on(k,_this.handleEvents);
- })(k)
- }
- }
- }
- S.augment(FSM, S.EventTarget, proto);
然后,只需要实例化一个FSM即可
- new FSM({
- initState:"fold",
- states:{...},
- events:{...}
- });
总结
使用FSM模式设计和实现交互组件,可以获得以下特性:
- 交互逻辑清晰
- 用户行为和组件行为完全分离,代码具有良好的分层结构
- 对设计具有良好的纠错特性,当设计上对状态和状态的转移有遗漏时,在实现阶段很容易流程出现走不通的情况,可以促进交互设计对细节的补充。