├── .project
├── README.md
├── demo
└── FSM.html
└── src
└── FSM.js
/.project:
--------------------------------------------------------------------------------
1 |
2 |
3 | FSMWidget
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 基于FSM的组件设计
2 |
3 | 有限状态机(FSM)([维基百科](http://zh.wikipedia.org/wiki/%E6%9C%89%E9%99%90%E7%8A%B6%E6%80%81%E6%9C%BA))是设计和实现事件驱动程序内复杂行为组织原则的有力工具
4 |
5 | 早在2007年,IBM的工程师就提出在在JAVASCRIPT中使用有限状态机来实现组件的方法,原文地址如下:
6 |
7 | 《JavaScript 中的有限状态机》http://www.ibm.com/developerworks/cn/web/wa-finitemach/
8 |
9 | 现在结合KISSY等现代JS库和框架提供的强大的自定义事件的功能,我们可以利用有限状态机设计出代码层次清晰,结构优雅的前端交互组件。
10 |
11 | 今天,我们会通过设计并实现一个下拉选择(模拟select)组件来一步步说明如何利用FSM和KISSY来设计和实现一个有复杂行为的交互组件。
12 |
13 | 我们的工作会分成三个步骤来进行:
14 |
15 | * 第一步:设计组件状态,用户行为和组件行为
16 | * 第二步:通过代码来描述设计出来的内容
17 | * 第三步:实现一个有限状态机让组件工作起来
18 |
19 |
20 | ## 第一步:设计阶段
21 |
22 | 首先,我们需要确定组件的状态和状态间的转换关系
23 |
24 | 通过对组件可能会发生的行为进行研究,我们为组件设计了以下三个状态:
25 |
26 | 1.收起状态(fold):
27 |
28 | 
29 |
30 | 组件的初始状态,用户可能会进行以下操作:
31 |
32 | 展开下拉框(unfoldmenu)转移到展开状态(unfold)
33 |
34 |
35 | 2.展开状态(unfold):
36 |
37 | 
38 |
39 | 用户展开下拉框的状态,用户可能会进行以下操作:
40 |
41 | 收起下拉框(foldmenu)转移到收起状态(fold)
42 | 鼠标经过选项(overitem)转移到高亮状态(highlight)
43 |
44 |
45 | 3.高亮状态(highlight):
46 |
47 | 
48 |
49 | 鼠标经过选项时,高亮经过的选项,用户可能会进行以下操作:
50 |
51 | 收起下拉框(foldmenu)转移到收起状态(fold)
52 | 点击选项(clickitem)转移到收起状态(fold)
53 | 鼠标经过选项(overitem)转移到高亮状态(highlight)
54 |
55 |
56 | 以上就是这个小组件可能会有的三种状态,用一个状态转换图来表示如下:
57 |
58 | 
59 |
60 | * 在状态描述中包含了触发状态发生转移的动作(事件)
61 | * 可以很明显的看出这些事件并不是浏览器中原生的事件。
62 | * 这里,我们使用自定义事件来描述用户的行为,这样我们可以使得用户行为和组件行为的逻辑完全分离,代码将会更容易理解和维护。
63 |
64 |
65 | 定义用户行为:
66 |
67 | 在这个组件里,我们有以下四种用户行为:
68 |
69 | 展开下拉框(unfoldmenu):鼠标点击橙色区域时触发
70 | 收起下拉框(foldmenu):鼠标离开组件区域达到2秒,点击橙色区域,点击组件外部区域
71 | 点击选项(clickitem):点击下拉框中的某个选项
72 | 鼠标经过选项(overitem):鼠标经过下拉框中的某个选项
73 |
74 | 定义组件行为:
75 |
76 | 在状态转移的过程中,组件本身会有很多动作,如显示下拉框等,我们接下来在上面的状态图中加入转移过程中组件的动作
77 |
78 | 
79 |
80 | fold():收起下拉框
81 | unfold():展开下拉框
82 | highlightItem():高亮某个选项
83 | selectItem():选中某个选项,并把值填充到橘黄色区域
84 |
85 | ## 第二步:实现阶段(基于KISSY实现)
86 |
87 | 全局变量:S=KISSY, D=S.DOM, E=S.Event
88 |
89 | 1.描述状态
90 |
91 | 跟设计过程一样,我们需要用一个结构来描述状态的转移以及转移过程中的动作
92 |
93 | 我们在这里使用对象来描述:
94 |
95 | "fold":{
96 | unfoldmenu:function(event){
97 | _this.unfold();
98 | return "unfold";
99 | }
100 | }
101 |
102 | 如上面这段代码就描述了在fold状态下,可以触发unfoldmenu这个用户行为来转移到unfold状态,
103 |
104 | 我们通过函数返回值的形式来通知FSM下一步的状态。
105 |
106 | 这样,我们就可以通过这种形式描述所有的状态,结构如下:
107 |
108 | states:{
109 | //收起(初始状态)
110 | "fold":{
111 | unfoldmenu:function(event){
112 | _this.unfold();
113 | return "unfold";
114 | }
115 | },
116 |
117 | //展开状态
118 | "unfold":{
119 | foldmenu:function(event){
120 | _this.fold();
121 | return "fold";
122 | },
123 | overitem:function(event){
124 | _this.highlightItem(event.currentItem);
125 | return "highlight";
126 | }
127 |
128 | },
129 |
130 | //高亮状态
131 | "highlight":{
132 | foldmenu:function(event){
133 | _this.fold();
134 | return "fold";
135 | },
136 |
137 | //选中条目
138 | clickitem:function(event){
139 | _this.selectItem(event.currentItem);
140 | return "fold";
141 | },
142 |
143 | overitem:function(event){
144 | _this.highlightItem(event.currentItem);
145 | return "highlight";
146 | }
147 |
148 | }
149 | }
150 |
151 | 在定义好状态后,我们还需要设定一个初始状态:
152 |
153 | initState:"fold"
154 |
155 | 2.描述用户行为
156 |
157 | 我们使用一个方法来描述用户行为,即驱动FSM发生状态转移的事件:
158 |
159 | "foldmenu":function(fn){
160 | var timeout;
161 | E.on(_this.container,"mouseleave",function(e){
162 | if(timeout)clearTimeout(timeout);
163 | timeout = setTimeout(function(){
164 | fn();
165 | },1000);
166 | });
167 | E.on([_this.container,_this.slideBox],"mouseenter",function(e){
168 | if(timeout)clearTimeout(timeout);
169 | });
170 | E.on("body","click",function(e){
171 | var target = e.target;
172 | if(!D.get(target,_this.container)){
173 | if(timeout)clearTimeout(timeout);
174 | fn();
175 | }
176 | });
177 | }
178 |
179 | 如上面这个代码就定义了foldmenu这个用户行为,同时,FSM会自动将它定义为一个自定义事件,我们通过传入的回调函数fn来通知FSM触发这个事件的时机。
180 |
181 | 通过上边的例子可以看出,我们可以将一个很复杂的动作定义为一个用户行为,也可以将几个不同的动作定义为一个用户行为,将用户行为和组件的动作彻底分开。
182 |
183 | 与状态相同,我们也将所有的用户行为放在一个对象中。
184 |
185 | events:{
186 |
187 | "unfoldmenu":function(fn){
188 |
189 | },
190 |
191 | "foldmenu":function(fn){
192 |
193 | },
194 |
195 | "overitem":function(fn){
196 |
197 | },
198 |
199 | "clickitem":function(fn){
200 |
201 | }
202 | }
203 |
204 | 3.描述组件行为
205 | 由于组件行为一般都包含对组件本身的一些直接操作,可以作为API开放给用户使用,因此我们把描述组件行为的方法放在组件的prototype上,这部分代码如下:
206 |
207 | S.augment(SlideMenu,S.EventTarget,{
208 |
209 | setText:function(){
210 | var _this = this,
211 | select = _this.select;
212 | D.html(select,_this.text);
213 | },
214 |
215 | unfold:function(){
216 | var _this = this,
217 | slideBox = _this.slideBox;
218 | if(!_this.isFold)return;
219 | _this.isFold = false;
220 | D.show(slideBox);
221 | },
222 |
223 | fold:function(){
224 | var _this = this,
225 | options = _this.options,
226 | slideBox = _this.slideBox;
227 | if(_this.isFold)return;
228 | D.removeClass(options,"hover");
229 | _this.isFold = true;
230 | D.hide(slideBox);
231 | },
232 |
233 | highlightItem:function(curItem){
234 | var _this = this,
235 | options = _this.options;
236 | D.removeClass(options,"hover");
237 | D.addClass(curItem,"hover");
238 | },
239 |
240 | selectItem:function(curItem){
241 | var _this = this,
242 | value = D.attr(curItem,"data-value"),
243 | text = D.attr(curItem,"data-text");
244 | _this.value = value;
245 | _this.text = text;
246 | _this.setText()
247 | _this.fold();
248 | _this.fire("select",{
249 | value:value,
250 | text:text
251 | });
252 | }
253 | });
254 |
255 | ## 第三步:实现有限状态机(基于KISSY实现)
256 |
257 | 前面我们定义了组件的状态,用户行为,以及组件本身的动作,
258 |
259 | 接下来我们来实现一个有限状态机(FSM),让整个组件工作起来。
260 |
261 | 通过上面实现的代码,我们可以看出FSM的输入有以下三个:
262 |
263 | 1. 初始状态
264 | 2. 状态描述对象
265 | 3. 用户行为描述对象
266 |
267 | 代码结构如下:
268 |
269 | initState:"fold",
270 | states:{
271 | //收起(初始状态)
272 | "fold":{
273 | },
274 | //展开状态
275 | "unfold":{
276 | },
277 | //高亮状态
278 | "highlight":{
279 | }
280 | },
281 |
282 | events:{
283 | "unfoldmenu":function(fn){
284 | },
285 | "foldmenu":function(fn){
286 | },
287 | "overitem":function(fn){
288 | },
289 | "clickitem":function(fn){
290 | }
291 | }
292 |
293 | FSM需要2个功能:
294 |
295 | 1. 将用户行为与自定义事件相关联(defineEvents)
296 | 2. 在用户行为发生时(即触发自定义事件时),根据状态描述对象来转移状态(handleEvents)
297 |
298 | 代码如下:
299 |
300 | function FSM(config){
301 | this.config = config;
302 | this.currentState = this.config.initState;
303 | this.nextState = null;
304 | this.states = this.config.states;
305 | this.events = this.config.events;
306 | this.defineEvents();
307 | }
308 |
309 |
310 |
311 | var proto = {
312 | //事件驱动状态转换(表现层)
313 | handleEvents:function(event){
314 | if(!this.currentState)return;
315 | var actionTransitionFunction = this.states[this.currentState][event.type];
316 | if(!actionTransitionFunction)return;
317 | var nextState = actionTransitionFunction.call(this,event);
318 | this.currentState = nextState;
319 | },
320 |
321 | //定义事件 (行为层)
322 | defineEvents:function(){
323 | var _this = this,
324 | events = this.events;
325 | for(k in events){
326 | (function(k){
327 | var fn = events[k];
328 | fn.call(_this,function(event){
329 | _this.fire(k,event);
330 | });
331 | _this.on(k,_this.handleEvents);
332 | })(k)
333 | }
334 | }
335 |
336 | }
337 | S.augment(FSM, S.EventTarget, proto);
338 |
339 | 然后,只需要实例化一个FSM即可
340 |
341 | new FSM({
342 | initState:"fold",
343 | states:{...},
344 | events:{...}
345 | });
346 |
347 | ## 最后,总结一下。
348 |
349 | 使用FSM模式设计和实现交互组件,可以获得以下特性:
350 |
351 | 1. 交互逻辑清晰
352 | 2. 用户行为和组件行为完全分离,代码具有良好的分层结构
353 | 3. 对设计具有良好的纠错特性,当设计上对状态和状态的转移有遗漏时,在实现阶段很容易流程出现走不通的情况,可以促进交互设计对细节的补充。
354 |
355 |
356 |
357 |
358 |
--------------------------------------------------------------------------------
/demo/FSM.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | jQuery Mobile: Demos and Documentation
7 |
8 |
11 |
12 |
13 |
14 |
15 |
56 |
66 |
72 |
73 |
74 |
--------------------------------------------------------------------------------
/src/FSM.js:
--------------------------------------------------------------------------------
1 | var FSMWidgets, SlideMenuGlobal;
2 | (function(){
3 | var S = KISSY, D = S.DOM, E = S.Event;
4 |
5 | function FSM(config){
6 | this.config = config;
7 | this.currentState = this.config.initState;
8 | this.nextState = null;
9 | this.states = this.config.states;
10 | this.events = this.config.events;
11 |
12 | this.defineEvents();
13 | }
14 |
15 |
16 | var proto = {
17 | //事件驱动状态转换(表现层)
18 | handleEvents:function(event){
19 | if(!this.currentState)return;
20 |
21 | var actionTransitionFunction = this.states[this.currentState][event.type];
22 | if(!actionTransitionFunction)return;
23 | var nextState = actionTransitionFunction.call(this,event);
24 | this.currentState = nextState;
25 | },
26 | //直接触发一个状态转换
27 | doTransition:function(state,type,event){
28 | var actionTransitionFunction = this.states[state][type];
29 | if(!actionTransitionFunction)return;
30 | var nextState = actionTransitionFunction.call(this,event);
31 | this.currentState = nextState;
32 | },
33 | //定义事件 (行为层)
34 | defineEvents:function(){
35 | var _this = this,
36 | events = this.events;
37 | S.each(events,function(fn,k){
38 | fn.call(_this,function(event){
39 | _this.fire(k,event);
40 | });
41 | _this.on(k,_this.handleEvents);
42 | });
43 | }
44 | }
45 | S.augment(FSM, S.EventTarget, proto);
46 | /**
47 | *
48 | * @param {Object} config
49 | * config = {
50 | * selectCls:"",
51 | * boxCls:"",
52 | * optionCls:""
53 | * }
54 | */
55 | function SlideMenu(container,config){
56 | var _this = this;
57 | _this.config = S.mix({
58 | selectCls:"k-select",
59 | boxCls:"k-box",
60 | optionCls:"k-option"
61 | },config);
62 |
63 | //获取组件DOM节点
64 | _this.container = D.get(container);
65 |
66 | //组件当前值的文字容器
67 | var select = _this.select = D.get("."+_this.config.selectCls,_this.container);
68 | var options = _this.options = D.query("."+_this.config.optionCls,_this.container);
69 | var slideBox = _this.slideBox = D.get("."+_this.config.boxCls);
70 |
71 | //记录当前值,值写在option的data-value属性上
72 | _this.value = D.attr(select,"data-value")||D.attr(options[0],"data-value");
73 | _this.text = D.attr(select,"data-text")||D.attr(options[0],"data-text");;
74 | _this.setText();
75 |
76 | //标志位
77 | _this.isFold = true;
78 |
79 |
80 | //FSM配置参数
81 | var stateConfig = {
82 | initState:"fold",
83 | states:{
84 | //收起(初始状态)
85 | "fold":{
86 | unfoldmenu:function(event){
87 | _this.unfold();
88 | return "unfold";
89 | }
90 | },
91 | //展开状态
92 | "unfold":{
93 | foldmenu:function(event){
94 | _this.fold();
95 | return "fold";
96 | },
97 | overitem:function(event){
98 | _this.highlightItem(event.currentItem);
99 | return "highlight";
100 | }
101 | },
102 | //高亮状态
103 | "highlight":{
104 | foldmenu:function(event){
105 | _this.fold();
106 | return "fold";
107 | },
108 | //选中条目
109 | clickitem:function(event){
110 | _this.selectItem(event.currentItem);
111 | return "fold";
112 | },
113 | overitem:function(event){
114 | _this.highlightItem(event.currentItem);
115 | return "highlight";
116 | }
117 | }
118 | },
119 | //定义用户行为
120 | events:{
121 | "unfoldmenu":function(fn){
122 | E.on(_this.container,"click",function(e){
123 | if(_this.isFold==true)fn();
124 | });
125 | },
126 | "foldmenu":function(fn){
127 | var timeout;
128 | E.on(_this.container,"mouseleave",function(e){
129 | if(timeout)clearTimeout(timeout);
130 | timeout = setTimeout(function(){
131 | fn();
132 | },1000);
133 | });
134 | E.on([_this.container,_this.slideBox],"mouseenter",function(e){
135 | if(timeout)clearTimeout(timeout);
136 | });
137 | E.on("body","click",function(e){
138 | var target = e.target;
139 | if(!D.get(target,_this.container)){
140 | if(timeout)clearTimeout(timeout);
141 | fn();
142 | }
143 | });
144 | E.on(_this.select,"click",function(e){
145 | if(_this.isFold==false)fn();
146 | });
147 | },
148 | "overitem":function(fn){
149 | S.each(options,function(op){
150 | E.on(op,"mouseenter",function(e){
151 | var curItem = e.currentTarget;
152 | fn({
153 | currentItem:curItem
154 | });
155 | });
156 | });
157 | },
158 | "clickitem":function(fn){
159 | E.on(options,"click",function(e){
160 | e.halt();
161 | var curItem = e.currentTarget;
162 | fn({
163 | currentItem:curItem
164 | });
165 | });
166 | }
167 | }
168 | }
169 | //启动有限状态机
170 | _this.FSM = new FSM(stateConfig);
171 | }
172 | S.augment(SlideMenu,S.EventTarget,{
173 | setText:function(){
174 | var _this = this,
175 | select = _this.select;
176 |
177 | D.html(select,_this.text);
178 | },
179 | unfold:function(){
180 | var _this = this,
181 | slideBox = _this.slideBox;
182 | if(!_this.isFold)return;
183 |
184 | S.one(slideBox).fadeIn(0.3,function(){
185 | _this.isFold = false;
186 | });
187 | },
188 | fold:function(){
189 | var _this = this,
190 | options = _this.options,
191 | slideBox = _this.slideBox;
192 | if(_this.isFold)return;
193 | D.removeClass(options,"hover");
194 |
195 | S.one(slideBox).slideUp(0.2,function(){
196 | _this.isFold = true;
197 | });
198 | },
199 | highlightItem:function(curItem){
200 | var _this = this,
201 | options = _this.options;
202 | D.removeClass(options,"hover");
203 | D.addClass(curItem,"hover");
204 | },
205 | selectItem:function(curItem){
206 | var _this = this,
207 | value = D.attr(curItem,"data-value"),
208 | text = D.attr(curItem,"data-text");
209 | _this.value = value;
210 | _this.text = text;
211 | _this.setText()
212 | _this.fold();
213 | _this.fire("select",{
214 | value:value,
215 | text:text
216 | });
217 | }
218 | });
219 | SlideMenuGlobal = SlideMenu;
220 | })()
221 |
--------------------------------------------------------------------------------