试试用有限状态机的思路来定义javascript组件
本文是一篇学习性的文章,学习利用有限状态机的思想来定义javascript组件的方法,欢迎阅读,后续计划会写几篇专门介绍自己利用有限状态机帮助自己编写组件的博客,证明这种思路对于编程实现的价值,目前正在积极构思中。本文代码下载
1. 有限状态机概述
简单说,有限状态机是一种模型,模型都用来模拟事物,能够被有限状态机这种模型模拟的事物,一般都有以下特点:
1)可以用状态来描述事物,并且任一时刻,事物总是处于一种状态;
2)事物拥有的状态总数是有限的;
3)通过触发事物的某些行为,可以导致事物从一种状态过渡到另一种状态;
4)事物状态变化是有规则的,A状态可以变换到B,B可以变换到C,A却不一定能变换到C;
5)同一种行为,可以将事物从多种状态变成同种状态,但是不能从同种状态变成多种状态。
比如一个模拟复选按钮的开关组件可以用状态机这样描述:
var Switch = function ($elem) { var log = function (fsm, previousState) { console.log('currentState is : ' + fsm.currentState + ((previousState || '') && (' , and previous state is : ' + previousState))); }; return { currentState: 'off', states: { 'on': { to: 'off', action: 'turnOff' }, 'off': { to: 'on', action: 'turnOn' } }, init: function () { var self = this; $elem.on('click', (function () { var args = arguments; return function () { self.transition(args); } })()); log(this); }, transition: function (e) { var old = this.currentState; this.currentState = this.states[old].to; var action = this.states[old].action; (action in this) && this[action](old); }, turnOn: function (fromState) { $elem.addClass('on'); log(this, fromState); }, turnOff: function (fromState) { $elem.removeClass('on'); log(this, fromState); } } };
在这个简单示例中,Switch组件共有2种状态,分别是on和off,它要么处于on状态,要么处于off状态,初始状态为off,它有2例行为:turnOff和turnOn,前者能使组件从on状态变化到off状态,后者能使组件从off状态变为on状态,它的行为绑定到了某个DOM元素的点击事件上,以下是我用这段js(switch.js)结合jquery运行,点击按钮三次之后的结果(对应源码中的switch.html):
可以看到当调用s.init()之后打印的是这个组件的初始状态,当点击一次之后,组件从off状态转换到了on状态,点击第二次之后从on状态转换到了off状态,点击第三次又恢复到了on状态。这个例子虽然是一个极其简单的状态机实现,但还是能够比较恰当地说明状态机的思想以及它的优点(逻辑思维清晰, 表达能力强)。在实际工作中,我们可以借助javascript-state-machine来实现基于状态机的组件,它是有限状态机这种模型的一个js的实现库,利用它可以快速定义一个状态机对象,相比我前面举例写出的那种实现,这个库虽然源码只有200多行,但是功能非常完整,API简单好用,值得学习跟实践。
2. 使用javascript-state-machine库实现状态机
只要引入该库的js之后就能通过该库提供的一个全局对象StateMachine,并使用该对象的create方法,生成有限状态机的实例(引自该库官方文档的交通灯例子):
例1(对应demo1.html):
在这个例子中:initial选项用来表示fsm对象的初始状态,events选项用来描述fsm对象所有状态的变化规则,每一种变化规则对应一种行为(不过有可能多个规则会对应同一个行为,在后面你会看到这样的例子)。create方法为实例的每一种行为都添加了一个方法,调用这个方法就相当于触发对象的某种行为,当对象行为发生时,对象的状态就可以发生变化。如以上例子创建的实例将拥有如下行为方法:
fsm.warn() - 调用该方法,实例状态将从'green'变为'yellow' fsm.panic() - 调用该方法,实例状态将从'yellow'变为'red' fsm.calm() - 调用该方法,实例状态将从'red'变为'yellow' fsm.clear() - 调用该方法,实例状态将从'yellow'变为'green'
这些方法是StateMachine根据create时配置的events规则自动创建的,方法名跟events规则里面的name属性对应,events规则里面有几个不重复的name,就会添加几个行为方法。同时为了方便使用,它还添加了如下成员来判断和控制实例的状态和行为:
fsm.current - 返回实例当前的状态 fsm.is(state) - 如果传入的state是实例当前状态就返回true fsm.can(eventName) - 如果传入的eventName在实例当前状态能够被触发就返回true fsm.cannot(eventName) - 如果传入的eventName在实例当前状态不能被触发就返回true fsm.transitions() - 以数组的形式返回实例当前状态下能够被触发的行为列表
在控制台打印这个对象,就可以看到这个对象的所有成员:
还记得前面列出的可以用有限状态机模型的事物特点吧,接下来就用例1来说明javascript-state-machine创建的对象是如何满足状态机模型的要求的:
1)可以用状态来描述事物,并且任一时刻,事物总是处于一种状态
这个例子中创建的交通灯实例,要么处于yellow状态,要么处于red状态,要么处于green状态,所以它是满足第1点的。
2)事物拥有的状态总数是有限的
这个实例最多只有三个状态。
3)通过触发事物的某些行为,可以导致事物从一种状态过渡到另一种状态
fsm.warn,fsm.panic,fsm.cal,fsm.clear这几个行为方法都能改变实例的状态。
4)事物状态变化是有规则的,A状态可以变换到B,B可以变换到C,A却不一定能变换到C
这个实例的初始状态为green,根据events配置的状态变化规则,green可以变换到yellow, yellow可以变换到red,但是实例初始化之后,却不能调用fsm.panic这个行为方法,因为这个方法只有实例状态为yellow的时候才能调用,而初始化时实例的状态为green,所以一开始只能调用warn方法:
当调用warn方法,导致对象的状态由green变成yellow之后,panic方法就能调用了。
5)同一种行为,可以将事物从多种状态变成同种状态,但是不能从同种状态变成多种状态
这个例子不能很好的说明这一点,因为它的状态变化规则里面没有那种同一个行为,从多种状态变换到某种状态的规则,但是这个例子是肯定满足这一点要求的,因为它的变化规则配置里面,一共定义了4种行为,每种行为都只能从一种状态变换到另外一种状态,变换前后都没有多种状态的情况。另外从理论上也很好理解这一点,为什么不能从同种状态变成多种状态,因为第一点说了事物任一时刻只能处于一种状态,如果某一个行为使得事物的状态变成了多种,事物的状态机制就有问题了。
下面用另外一个官方的例子来说明同一个行为,可以从多种状态变换到一种状态的场景:
例2(对应demo2.html):
这个例子感觉模拟的是一个人,它的意思表达地很清楚:它模拟的这个人有四个状态hungry, satisfied, full ,sick,分别代表饿了,高兴,饱了,病了,初始状态为hungry,这个人有2种行为eat和rest,分别代表吃和休息,只要这个人一开始吃,它的状态就由饿了变成高兴(人饿的时候有东西吃可不得高兴),再吃的话,状态就由高兴变为饱了,要是吃多了的话,这个人就会生病;不管这个人是饿是饱,是高兴还是得病,只要是在那躺着不动休息,最终都会饿。跟例1不同的是,这个例子:
1)虽然它配置了多个变化规则,但是它只有2个行为(events配置中有多少个不重复的name(值),就表示这个状态机有多少个行为);
2)它的eat行为发生后的状态跟当前状态有关系,当前状态不同,行为发生后的状态也不同,所以eat行为对应了多条配置规则;
3)它的rest行为发生后的状态跟当前状态没关系,只要当前状态在rest行为的状态条件范围内,行为发生后的结果都是一样的,所以rest行为用一个from数组配置了该行为发生的当前状态的条件范围,整个行为仅定义了一条配置规则。
在实际使用状态机实例的过程中,我们通过调用实例的行为方法来触发实例状态的改变,比如例1中: fsm.warn(),这样fsm的状态就会由green变为yellow,像这种简单的状态机实例,这个程度的使用也许就足够了,但是对于实际项目而言,我们定义的组件,往往要用它们生成的实例来完成很多复杂的逻辑功能,如果用状态机来定义组件,那么这些逻辑代码该写在哪里?因为javascript-state-machine创建的状态机实例,它的行为方法都是自动添加的,你不可能去重写这些行为方法,否则就失去状态机的意义了(将状态变化的逻辑与业务逻辑拆分)。答案是回调。javascript-state-machine为每个实例的每种状态的变换前后和每种行为的变换前后都定义了相关的回调,你的逻辑都可以写在这些回调里面,这样就达到了状态逻辑与业务逻辑拆分的目的。下面先看看这些回调的用法,接着我会用javascript-state-machine改写一下前面那个模拟复选框的开关组件的例子。
javascript-state-machine根据events的配置,可以为实例定义4种类型的回调:
onbeforeEVENT_NAME - 在EVENT_NAME对应的行为发生之前触发 onleaveSTATE - 在要改变STATE对应的状态时触发 onenterSTATE - 在把当前状态设置为了STATE对应的状态时触发 onafterEVENT_NAME - 在EVENT_NAME对应的行为发生之后触发
其中,EVENT_NAME都跟据events配置规则里面name,from, to包含的名称来指定,每个回调都能接收三个参数:
event - 行为名称 from - 行为发生前的状态 to - 行为发生后的状态
状态机每一个行为触发后,一定会触发onbeforeEVENT_NAME和onafterEVENT_NAME这两个回调,同时行为发生前的状态对应的onleaveSTATE和行为发生后的状态对应的onenterSTATE回调也一定会被触发(只要这些回调都有定义的话),并且回调顺序跟前面列出的顺序一致。
在例2中我们可以通过下面的方式来定义这四个类型的回调:
在浏览器中打开页面,在控制台调用一下fsm.eat,可以看到如下的打印结果:
根据打印的顺序也能看到回调的顺序:
onbeforeeat
onleavehungry
onentersatisfied
onaftereat
前面这四个回调对应的是四个类型,state不一样,或者是event不一样,需要定义的回调就不同,前面针对的是hungry,satisfied和eat行为定义的回调,还可以针对full,sick和rest行为定义回调,还可以再定义onenterhungry和onleavesatisfied的回调,实际应用里面要定义哪些回调来编写逻辑代码,得根据需求而定,javascript-state-machine会根据events规则在相应行为发生时触发这四类回调。
另外javascript-state-machine还定义了四个通用回调,这四个回调跟event,state没有关系,在任何行为触发,任何状态变化的时候,相关的回调都会触发,这四个回调是:
onbeforeevent - 在任何行为发生之前触发 onleavestate - 在要改变对象状态时触发 onenterstate - 在把当前状态设置为新状态时触发 onafterevent - 在任何行为发生之后触发
这四个回调名称,是固定的,跟触发的行为和要改变的状态没有关系,相当于是全局回调。也就是说,如果某个状态变化规则相关的四个类型的回调有定义并且这四个全局回调也有定义的话,并且这四个全局回调也有定义的话,那么触发该规则对应的行为,就一共会触发8个回调,这8个回调的顺序是(以例2中这条规则来说明{name: 'eat', from: 'hungry', to: 'satisfied'}):
onbeforeeat
onbeforeevent
onleavehungry
onleavestate
onentersatisfied
onenterstate
onaftereat
onafterevent
这些回调可以在初始化的时候,通过callbacks选项传给create来初始化,也能通过直接修改实例的属性来增加或修改(对应demo3.html):
fsm.onentersatisfied = null; fsm.onleavestate = function(event, from, to) { console.log('状态变了!,变之前:' + from + ',变之后:' + to); }
运行结果:
可以在控制台看看这个实例的成员:
相比例1打印的交通灯实例的成员,例2实例的成员除了行为方法与例1不同以外,还多出了以on开头的这些回调成员,例1之所以没有,那是因为例1没有用callbacks去配置。
了解到前面这些内容,就可以用javascript-state-machine来改写前面的开关组件了(对应switch2.html):
var Switch = function ($elem) { var log = function (from, to) { console.log('currentState is : ' + to + ((from || '') && (' , and previous state is : ' + from))); }, fsm = StateMachine.create({ initial: 'off', events: [ {name: 'turnOn', from: 'off', to: 'on'}, {name: 'turnOff', from: 'on', to: 'off'} ], callbacks: { onafterturnOn: function(event, from ,to){ $elem.addClass('on'); log(from, to); }, onafterturnOff: function(event, from, to) { $elem.removeClass('on'); log(from, to); } } } ); ; $elem.on('click', function(){ fsm[fsm.transitions()[0]](); }); log(undefined, fsm.current); return fsm; };
使用方式:
<script src="js/jquery.js"></script> <script src="lib/javascript-state-machine-master/state-machine.js"></script> <script src="js/switch2.js"></script> <script> var s = new Switch($('#btn-switch')); </script>
运行效果还跟之前的一样:
在实际工作中,肯定会碰到在行为触发期间,因为某些条件不允许需要取消该行为的情况, 以免对象状态被错误的更改,javascript-state-machine提供了3种方式来取消行为:
在onbeforeEVENT_NAME回调中return false可以取消当前触发的行为
在onleaveSTATE回调中return false也可以取消当前触发的行为
在onleaveSTATE回调中return StateMachine.ASYNC来执行异步的行为
前两种方法,在指定的回调中return false即可取消行为,第三个方法返回的仅是一个异步标识,是否取消行为需要在异步任务的回调里面进一步指定。这个方法适用于那些带有异步任务的行为,就是说在这种行为触发的时候,并不是同时就触发对象状态的改变,而是要等到异步任务执行完成之后再改变状态,引用官方的例子来说明这种异步任务的场景:
var fsm = StateMachine.create({ initial: 'menu', events: [ { name: 'play', from: 'menu', to: 'game' }, { name: 'quit', from: 'game', to: 'menu' } ], callbacks: { onentermenu: function() { $('#menu').show(); }, onentergame: function() { $('#game').show(); }, onleavemenu: function() { $('#menu').fadeOut('fast', function() { fsm.transition(); }); return StateMachine.ASYNC; // tell StateMachine to defer next state until we call transition (in fadeOut callback above) }, onleavegame: function() { $('#game').slideDown('slow', function() { fsm.transition(); }; return StateMachine.ASYNC; // tell StateMachine to defer next state until we call transition (in slideDown callback above) } } });
这个例子中创建的实例,包含play和quit两个行为,这两个行为触发之后,不会立即去更改对象的状态,而是开启一个异步的动画任务,然后在动画结束之后,通过调用实例的transition方法:fsm.transition(),通知实例去改变自己的状态。为了告诉fsm,当前执行的是一个带异步的行为,需要在onleaveSTATE回调中,如onleavemenu, onleavegame,通过return StateMachine.ASYNC来处理。另外,在异步任务结束的回调里面,如果想要fsm更改状态,就通过fsm.transition()去通知它;但是如果在异步任务结束之后,由于有些条件不允许,还是想取消这个行为的话,可以改成调用fsm.cancel()来通知它,这样fsm就会取消当前的异步行为,对象状态也不会改变。
这种异步编程的方式跟jquery的延迟对象的做法是类似的:
function foo(url){ var defer = $.Deferred(); $.ajax({ url: url }).done(function(res){ defer[res.code == 200 ? 'resolve' : 'reject'](); }).fail(function(){ defer.reject(); }); return $.when(defer) }
最后关于javascript-state-machine还可以在本文说明一下的就是error这个选项,在create实例的时候,可以通过这个选项来指定一个回调,这样在触发了在当前状态不该触发的行为时,fsm不会抛出错误,而是把这个错误交给error指定的回调来处理,否则它就会直接把错误抛给浏览器,这肯定会导致组件的功能无法使用,所以如果要用javascript-state-machine,这个回调一定要加上,哪怕只是简单打印一些信息(对应demo4.html):
加error回调:
运行结果:
不加error回调:
运行结果:
有关javascript-state-machine的用法介绍到此结束,在官方文档中还有2个小节也有用得着的场景,对这个库感兴趣的话推荐再去学习官方文档~
3. 小结
1)有限状态机是定义组件的一种好用的模式,能够让组件的代码看起来更加清晰,而且易于理解;
2)javascript-state-machine也是一个优秀的实现库,源码简洁,提供的API用法简单,同时还突出了状态机的特点,值得在定义组件的时候去试一试;
3)有限状态机这种模式适合有明显状态特点的组件;
4)在使用javascript-state-machine的时候,既可以直接在fsm的基础上定义组件,也可以在组件内部通过一个私有成员来保留一个fsm(内部状态机);
5)本文所举的例子不够贴近实际项目,近期会看看自己做过的项目中有哪些适合用状态机模式来重写的模块,到时候再写博客来与大家分享。
谢谢阅读:)