├── .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 | ![](http://img01.taobaocdn.com/tps/i1/T15gPTXoVfXXb_CEfo-229-64.png) 29 | 30 | 组件的初始状态,用户可能会进行以下操作: 31 | 32 | 展开下拉框(unfoldmenu)转移到展开状态(unfold) 33 | 34 | 35 | 2.展开状态(unfold): 36 | 37 | ![](http://img01.taobaocdn.com/tps/i1/T1UZvTXcRiXXb1j7My-217-170.png) 38 | 39 | 用户展开下拉框的状态,用户可能会进行以下操作: 40 | 41 | 收起下拉框(foldmenu)转移到收起状态(fold) 42 | 鼠标经过选项(overitem)转移到高亮状态(highlight) 43 | 44 | 45 | 3.高亮状态(highlight): 46 | 47 | ![](http://img02.taobaocdn.com/tps/i2/T1_.rTXeNcXXbFCsHX-242-186.png) 48 | 49 | 鼠标经过选项时,高亮经过的选项,用户可能会进行以下操作: 50 | 51 | 收起下拉框(foldmenu)转移到收起状态(fold) 52 | 点击选项(clickitem)转移到收起状态(fold) 53 | 鼠标经过选项(overitem)转移到高亮状态(highlight) 54 | 55 | 56 | 以上就是这个小组件可能会有的三种状态,用一个状态转换图来表示如下: 57 | 58 | ![](http://img03.taobaocdn.com/tps/i3/T1xFDUXoXaXXavm5fP-510-412.png) 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 | ![](http://img04.taobaocdn.com/tps/i4/T1J7nSXfxlXXavm5fP-510-412.png) 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 | --------------------------------------------------------------------------------