uiswitchbutton 点击不改变状态_JavaScript设计模式之状态模式(State Pattern)

本文详细介绍了JavaScript中的状态模式,通过电灯开关的例子展示了如何使用状态模式来改进代码,避免了条件分支语句,提高了代码的可读性和可维护性。状态模式将每种状态封装为独立的类,通过状态对象来处理不同状态下的行为。文章还讨论了状态模式的优缺点以及在实际开发中的应用,如文件上传程序,强调了状态模式在游戏开发中的重要性。下期将回顾观察者模式。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

b92f3d4aa4a2265eef842626ab002b3a.png

状态模式是一种非同寻常的优秀模式,它也许是解决某些需求场景的最好方法。虽然状态模式并不是一种简单到一目了然的模式(它往往还会带来代码量的增加),但一旦明白了状态模式的精髓,以后一定会感谢它带给你的无与伦比的好处。状态模式的关键是区分事物内部的状态,事物内部状态的改变往往会带来事物的行为改变。

每天一道有趣的智力题

初识状态模式

想象这样一个场景:有一个电灯,电灯上面只有一个开关。当电灯开着的时候,此时按下开关,电灯会切换到关闭状态;再按一次开关,电灯又将被打开。同一个开关按钮,在不同的状态下,表现出来的行为是不一样的

现在用代码来描述这个场景,首先定义一个Light类,可以预见,电灯对象light将从Light类创建而出,light对象将拥有两个属性,用state来记录电灯当前的状态,用button表示具体的开关按钮

初始版本

首先给出不用状态模式的电灯程序实现:

289ef02577aebf58242d1b1cd1b0eb83.png

接下来定义Light.prototype.init方法,该方法负责在页面中创建一个真实的button节点,假设这个button就是电灯的开关按钮,当button的onclick事件被触发时,就是电灯开关被按下的时候,代码如下

cd99b241c19820828479df0491f2b6b3.png

当开关被按下时,程序会调用self.buttonWasPressed方法,开关按下之后的所有行为,都将被封装在这个方法里,代码如下:

ae331f17ada194c1f538e606820c91c1.png

现在已经编写了一个强壮的状态机,这个状态机的逻辑既简单又缜密,看起来这段代码设计得无懈可击,这个程序没有任何bug。实际上这种代码已经编写过无数遍,比如要交替切换一个button的class,跟此例一样,往往先用一个变量state来记录按钮的当前状态,在事件发生时,再根据这个状态来决定下一步的行为

令人遗憾的是,这个世界上的电灯并非只有一种。许多酒店里有另外一种电灯,这种电灯也只有一个开关,但它的表现是:第一次按下打开弱光,第二次按下打开强光,第三次才是关闭电灯。现在必须改造上面的代码来完成这种新型电灯的制造

30eb0a077dfd491ef93765cc6100fea1.png

现在考虑一下上述程序的缺点

1、很明显buttonWasPressed方法是违反开放——封闭原则的,每次新增或者修改light的状态,都需要改动buttonWasPressed方法中的代码,这使得buttonWasPressed成为了一个非常不稳定的方法

2、所有跟状态有关的行为,都被封装在buttonWasPressed方法里,如果以后这个电灯又增加了强强光、超强光和终极强光,那将无法预计这个方法将膨胀到什么地步。当然为了简化示例,此处在状态发生改变的时候,只是简单地打印一条log和改变button的innerHTML。在实际开发中,要处理的事情可能比这多得多,也就是说,buttonWasPressed方法要比现在庞大得多

3、状态的切换非常不明显,仅仅表现为对state变量赋值,比如this.state='weakLight'。在实际开发中,这样的操作很容易被程序员不小心漏掉。也没有办法一目了然地明白电灯一共有多少种状态,除非耐心地读完buttonWasPressed方法里的所有代码。当状态的种类多起来的时候,某一次切换的过程就好像被埋藏在一个巨大方法的某个阴暗角落里

4、状态之间的切换关系,不过是往buttonWasPressed方法里堆砌if、else语句,增加或者修改一个状态可能需要改变若干个操作,这使buttonWasPressed更加难以阅读和维护

状态模式

下面使用状态模式改进电灯的程序。通常谈到封装,一般都会优先封装对象的行为,而不是对象的状态。但在状态模式中刚好相反,状态模式的关键是把事物的每种状态都封装成单独的类,跟此种状态有关的行为都被封装在这个类的内部,所以button被按下的的时候,只需要在上下文中,把这个请求委托给当前的状态对象即可,该状态对象会负责渲染它自身的行为。同时还可以把状态的切换规则事先分布在状态类中,这样就有效地消除了原本存在的大量条件分支语句

下面进入状态模式的代码编写阶段,首先将定义3个状态类,分别是offLightState、WeakLightState、strongLightState。这3个类都有一个原型方法buttonWasPressed,代表在各自状态下,按钮被按下时将发生的行为,代码如下:

3c10ac53984288f044d4ff49d324c80d.png

接下来改写Light类,现在不再使用一个字符串来记录当前的状态,而是使用更加立体化的状态对象。在Light类的构造函数里为每个状态类都创建一个状态对象,这样一来可以很明显地看到电灯一共有多少种状态,代码如下:

439c7d69774d4c322177f402a1843f16.png

在button按钮被按下的事件里,Context也不再直接进行任何实质性的操作,而是通过self.currState.buttonWasPressed()将请求委托给当前持有的状态对象去执行,代码如下:

04cd638ecc3f6b4fb16be8a4b08212cb.png

最后还要提供一个Light.prototype.setState方法,状态对象可以通过这个方法来切换light对象的状态。状态的切换规律事先被完好定义在各个状态类中。在Context中再也找不到任何一个跟状态切换相关的条件分支语句

e2666c9d7dfb61865e545886a3c8c52d.png

执行结果跟之前的代码一致,但是使用状态模式的好处很明显,它可以使每一种状态和它对应的行为之间的关系局部化,这些行为被分散和封装在各自对应的状态类之中,便于阅读和管理代码。另外,状态之间的切换都被分布在状态类内部,这使得无需编写过多的if、else条件分支语言来控制状态之间的转换

当需要为light对象增加一种新的状态时,只需要增加一个新的状态类,再稍稍改变一些现有的代码即可。假设现在light对象多了一种超强光的状态,那就先增加SuperStrongLightState类:

cf442039b9a871d692c1735df9b52783.png

然后在Light构造函数里新增一个superStrongLightState对象:

69ed708511467931b4335ecf881fa7c8.png

最后改变状态类之间的切换规则,从StrongLightState---->OffLightState变为StrongLight-State---->SuperStrongLightState---->OffLightState:

3c9f563b642baa66331de234882b0a7e.png

通用结构

状态模式是指允许一个对象在其内部状态改变时改变它的行为,对象看起来似乎修改了它的类。以逗号分割,把这句话分为两部分来看。第一部分的意思是将状态封装成独立的类,并将请求委托给当前的状态对象,当对象的内部状态改变时,会带来不同的行为变化。第二部分是从客户的角度来看,使用的对象,在不同的状态下具有截然不同的行为,这个对象看起来是从不同的类中实例化而来的,实际上这是使用了委托的效果

在前面的电灯例子中,完成了一个状态模式程序的编写。首先定义了Light类,Light类在这里也被称为上下文(Context)。随后在Light的构造函数中,要创建每一个状态类的实例对象,Context将持有这些状态对象的引用,以便把请求委托给状态对象。用户的请求,即点击button的动作也是实现在Context中的,代码如下

e6b15711986cb304c9328a1610635945.png

接下来要编写各种状态类,light对象被传入状态类的构造函数,状态对象也需要持有light对象的引用,以便调用light中的方法或者直接操作light对象:

81a9d09695385786c5feddb71cd64496.png

在状态类中将定义一些共同的行为方法,Context最终会将请求委托给状态对象的这些方法,在这个例子里,这个方法就是buttonWasPressed。无论增加了多少种状态类,它们都必须实现buttonWasPressed方法

javascript既不支持抽象类,也没有接口的概念。所以在使用状态模式的时候要格外小心,如果编写一个状态子类时,忘记了给这个状态子类实现buttonWasPressed方法,则会在状态切换的时候抛出异常。因为Context总是把请求委托给状态对象的buttonWasPressed方法。所以,要让抽象父类的抽象方法直接抛出一个异常,这个异常至少会在程序运行期间就被发现

b86b65dfc8177a9c867b8c785bb6539e.png

文件上传

不论是文件上传,还是音乐、视频播放器,都可以找到一些明显的状态区分。比如文件上传程序中有扫描、正在上传、暂停、上传成功、上传失败这几种状态,音乐播放器可以分为加载中、正在播放、暂停、播放完毕这几种状态。点击同一个按钮,在上传中和暂停状态下的行为表现是不一样的,同时它们的样式class也不同

相对于电灯的例子,文件上传不同的地方在于,现在将面临更加复杂的条件切换关系。在电灯的例子中,电灯的状态总是从关到开再到关,或者从关到弱光、弱光到强光、强光再到关。看起来总是循规蹈矩的A→B→C→A,所以即使不使用状态模式来编写电灯的程序,而是使用原始的if、else来控制状态切换,也不至于在逻辑编写中迷失自己,因为状态的切换总是遵循一些简单的规律

而文件上传的状态切换相比要复杂得多,控制文件上传的流程需要两个节点按钮,第一个用于暂停和继续上传,第二个用于删除文件

文件在扫描状态中,是不能进行任何操作的,既不能暂停也不能删除文件,只能等待扫描完成。扫描完成之后,根据文件的md5值判断,若确认该文件已经存在于服务器,则直接跳到上传完成状态。如果该文件的大小超过允许上传的最大值,或者该文件已经损坏,则跳往上传失败状态。剩下的情况下才进入上传中状态。上传过程中可以点击暂停按钮来暂停上传,暂停后点击同一个按钮会继续上传。扫描和上传过程中,点击删除按钮无效,只有在暂停、上传完成、上传失败之后,才能删除文件

浏览器插件来帮助完成文件上传。插件类型根据浏览器的不同,有可能是ActiveObject,也有可能是WebkitPlugin。上传是一个异步的过程,所以控件会不停地调用javascript提供的一个全局函数window.external.upload,来通知javascript目前的上传进度,控件会把当前的文件状态作为参数state塞进window.external.upload。在这里无法提供一个完整的上传插件,将简单地用setTimeout来模拟文件的上传进度,window.external.upload函数在此例中也只负责打印一些log:

f54bc2e2c669657d52b3854d5322cdb7.png

另外需要在页面中放置一个用于上传的插件对象:

c390b47a39fad3ed69ce3fdd68c782bc.png

接下来开始完成其他代码的编写,先定义Upload类,控制上传过程的对象将从Upload类中创建而来:

4387018368df2cbfab7f781427c1c3db.png

Upload.prototype.init方法会进行一些初始化工作,包括创建页面中的一些节点。在这些节点里,起主要作用的是两个用于控制上传流程的按钮,第一个按钮用于暂停和继续上传,第二个用于删除文件:

0861f2836d0875dbe8ad07d509744644.png

接下来需要给两个按钮分别绑定点击事件:

0f79f9bed2d9f3eaa68b4fb006f03bac.png

再接下来是Upload.prototype.changeState方法,它负责切换状态之后的具体行为,包括改变按钮的innerHTML,以及调用插件开始一些“真正”的操作:

703add52424377fa7fe2e808754054a3.png

最后来进行一些测试工作:

b95131769c80dd714ee670e6833e62fc.png

至此就完成了一个简单的文件上传程序的编写。当然这仍然是一个反例,这里的缺点跟电灯例子中的第一段代码一样,程序中充斥着if、else条件分支,状态和行为都被耦合在一个巨大的方法里,很难修改和扩展这个状态机。文件状态之间的联系如此复杂,这个问题显得更加严重了

状态模式重构

下面开始一步步地重构它。第一步仍然是提供window.external.upload函数,在页面中模拟创建上传插件,这部分代码没有改变

第二步,改造Upload构造函数,在构造函数中为每种状态子类都创建一个实例对象:

bea3907a07b3dc5d4953d434623841f7.png

第三步,Upload.prototype.init方法无需改变,仍然负责往页面中创建跟上传流程有关的DOM节点,并开始绑定按钮的事件

第四步,负责具体的按钮事件实现,在点击了按钮之后,Context并不做任何具体的操作,而是把请求委托给当前的状态类来执行

58c9e6dc4a9d6b5d0a76b695caa1f5a2.png

第四步中的代码有一些变化,把状态对应的逻辑行为放在Upload类中

3b58d8ccf28f9e4c8447decd79cdbdb4.png

第五步,编写各个状态类的实现。值得注意的是,使用了StateFactory,从而避免因为javascript中没有抽象类所带来的问题

f893b8cfa9eb63a6cd90ee655ced98a1.png
c4d6ef15b7edb27981e880c653e9e61c.png

最后是测试:

e0ba59466496f157961a8b16ab651300.png

优缺点

1、状态模式定义了状态与行为之间的关系,并将它们封装在一个类里。通过增加新的状态类,很容易增加新的状态和转换

2、避免Context无限膨胀,状态切换的逻辑被分布在状态类中,也去掉了Context中原本过多的条件分支

3、用对象代替字符串来记录当前状态,使得状态的切换更加一目了然

4、Context中的请求动作和状态类中封装的行为可以非常容易地独立变化而互不影响

状态模式的缺点是会在系统中定义许多状态类,编写20个状态类是一项枯燥乏味的工作,而且系统中会因此而增加不少对象。另外,由于逻辑分散在状态类中,虽然避开了不受欢迎的条件分支语句,但也造成了逻辑分散的问题,无法在一个地方就看出整个状态机的逻辑

上面的例子中并没有太多地从性能方面考虑问题,实际上,这里有一些比较大的优化点

1、有两种选择来管理state对象的创建和销毁。第一种是仅当state对象被需要时才创建并随后销毁,另一种是一开始就创建好所有的状态对象,并且始终不销毁它们。如果state对象比较庞大,可以用第一种方式来节省内存,这样可以避免创建一些不会用到的对象并及时地回收它们。但如果状态的改变很频繁,最好一开始就把这些state对象都创建出来,也没有必要销毁它们,因为可能很快将再次用到它们

2、上面的例子中,为每个Context对象都创建了一组state对象,实际上这些state对象之间是可以共享的,各Context对象可以共享一个state对象,这也是享元模式的应用场景之一

状态模式和策略模式像一对双胞胎,它们都封装了一系列的算法或者行为,它们的类图看起来几乎一模一样,但在意图上有很大不同,因此它们是两种迥然不同的模式。策略模式和状态模式的相同点是,它们都有一个上下文、一些策略或者状态类,上下文把请求委托给这些类来执行

它们之间的区别是策略模式中的各个策略类之间是平等又平行的,它们之间没有任何联系,所以客户必须熟知这些策略类的作用,以便客户可以随时主动切换算法;而在状态模式中,状态和状态对应的行为是早已被封装好的,状态之间的切换也早被规定完成,“改变行为”这件事情发生在状态模式内部。对客户来说,并不需要了解这些细节。这正是状态模式的作用所在

状态机

状态模式是状态机的实现之一,但在javascript这种“无类”语言中,没有规定让状态对象一定要从类中创建而来。另外一点,javascript可以非常方便地使用委托技术,并不需要事先让一个对象持有另一个对象。下面的状态机选择了通过Function.prototype.call方法直接把请求委托给某个字面量对象来执行

下面改写电灯的例子,来展示这种更加轻巧的做法:

6d579ca05d6a2cf339673984379147e6.png

接下来尝试另外一种方法,即利用下面的delegate函数来完成这个状态机编写。这是面向对象设计和闭包互换的一个例子,前者把变量保存为对象的属性,而后者把变量封闭在闭包形成的环境中:

735bc2835a1f7b2c9df88bf45fe6621e.png

其实还有另外一种实现状态机的方法,这种方法的核心是基于表驱动的。可以在表中很清楚地看到下一个状态是由当前状态和行为共同决定的。这样一来,就可以在表中查找状态,而不必定义很多条件分支

在实际开发中,很多场景都可以用状态机来模拟,比如一个下拉菜单在hover动作下有显示、悬浮、隐藏等状态;一次TCP请求有建立连接、监听、关闭等状态;一个格斗游戏中人物有攻击、防御、跳跃、跌倒等状态

状态机在游戏开发中也有着广泛的用途,特别是游戏AI的逻辑编写。游戏主角有走动、攻击、防御、跌倒、跳跃等多种状态。这些状态之间既互相联系又互相约束。比如在走动的过程中如果被攻击,就会由走动状态切换为跌倒状态。在跌倒状态下,既不能攻击也不能防御。同样,也不能在跳跃的过程中切换到防御状态,但是可以进行攻击。这种场景就很适合用状态机来描述。代码如下

caa262c6dfa3339c0ddd552c0fcd3c7c.png

下期预告:

重温JavaScript设计模式之观察者模式(Observer Pattern)

参考

https://www.cnblogs.com/xiaohuochai/p/8046560.html

https://juejin.im/entry/5ad95572518825671775d902

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值