State Machine in React Component
React的工程实践中大多数团队都只关注了state该怎么存放的问题,没有意识到真正导致问题复杂的是组合状态机,后面这句话对于UI而言是放之四海皆准的;
一个React Component对象作为UI层元素,在很多情况下我们并不希望在状态迁移时创建新的实例替代旧的,这直接意味着UI组件和状态机之间是binding关系而不是composition,所以React提供了一个this.state用于解耦,这是它很聪明的一个设计;但是这个this.state只有值成员,没有方法成员;这意味着写在Component上的方法里面要switch/case状态,这非常不方便。
其次React Component的setState方法是merge逻辑而不是replace逻辑,它意味着state下一级props之间必须是平行子状态机而不是单一状态机互斥状态(除非你只有一个状态机,其他状态用值表示);或者换句话说,如果你把不同的互斥状态下的资源和值都放在一个篮子里时,你每次自己去手动倒空旧的,这一点是个坑。
第三,那些early binding语言的状态机Pattern在js和immutable要求下并不适用,他们都是内部值状态的迁移而不是对象本身被替代,而对象本身被替代这个问题制造了一个问题,就是该对象的方法并不能用于UI的行为binding,因为状态迁移后这个旧状态机对象就废弃了,调用它的行为方法当然是不对的;
解决这个问题并不难,行为binding使用Component对象上的方法,它是稳定的,不会因为model的状态机更迭而变化,但它是一个proxy,需要把方法分发到子状态机上;这样我们就得到了状态机Pattern的最大优势:每个状态只关注属于自己的子状态,值,资源,和行为,不用在所有行为处理上都狂写switch/case。
熟悉状态机Pattern的开发者不难想像出满足上述要求的代码结构;Component是稳定的,它即使一个子状态机的容器,又是一个行为的Proxy层,向this.state下的子状态机(例如命名为this.state.stm1)分发行为;逻辑上是下图所示:
React Component this.state { stm1: // --------------------------------> stm1对象 } this.handleToggleButton() { this.state.stm1.handleToggleButton() // -> stm1.handleToggleButton() }
同时分发的行为必须返回一个新的状态机对象用于替代旧的,它可能导致一次状态迁移,例如方法调用之前this.state.stm1是一个ListViewState对象,而调用后变成了ListEditState对象;如果是这样,上述行为方法得加一个逻辑:
this.handleToggleButton() { let newStm1 = this.state.stm1.handleToggleButton() if (newStm1) this.setState({ stm1: newStm1 }) }
这个逻辑会反复使用,我们不妨把它抽象出来
this.dispatch = (name,method,...args) => { if (this.state[name] && typeof this.state[name] === 'object' && typeof this.state[name][method] === 'function') { let next = this.state[name][method](...args) if (next) { let obj = {} obj[name] = next this.setState(obj) } } }
这样在控件的JSX代码中使用时:
onToggle={e => this.dispatch('stm1','handleToggleButton')}
这不是唯一的写法,也许你不喜欢这样把所有的fallback都处理掉连错误通知也没有;你可以自己添加,写成自己喜欢的方式。
Immutable State Machine in JavaScript
剩下的问题回到如何在JS下书写一个immutable的状态机问题,基于Class仍然是直觉的方式,不同之处在于状态迁移时是用旧的Class对象作为参数传递给新的Class对象,新对象的构造函数第一件事情是复制旧对象的全部自有属性,这个行为可以写在原型类的构造函数里。
较为简洁的写法是状态机自己实现一个setState方法(setState是状态机Pattern的iconic方法,其次才是entry/exit);该方法只是用于状态机自己的状态迁移,和它的容器对象(React Component对象)上的setState方法无关;不要搞混了。(当然你应该想想为什么React Component上有这个状态机Pattern里的标志性方法)
简明实现的关键点是setState接受两个参数,第一个是下一状态的Class名(即构造函数),第二个是...args用于传参;所有子状态机的constructor都是(obj,...args)的形式,obj是上一状态机;这样写可以避免实现setState时写switch/case。
它的简单实现可以是:
setState(NextState,...args) { // 当前状态机迁出 this.exit() // 构造新对象,immutable,同时下一状态机迁入, return new NextState(this,...args) }
原型类的构造函数可以看起来这样:
constructor(obj) { Object.assign(this,obj) }
用于复制上一状态的所有属性。
最后这个状态机的基类需要一个exit方法,如果子类不需要实现,这是个fallback。
综上所述这个基类看起来大概是这样:
class STM { constructor(obj) { Object.assign(this,obj) } setState(NextState,...args) { this.exit() return new NextState(this,...args) } exit() {} }
在实际使用的时候你可能需要自己的基类,因为
你需要一些context,对所有状态都需要的值、属性、资源等
你需要一些共同的方法,如果对某个行为的处理大部分状态都是一样的,那么可以写在这个原型类里,具体某个状态的行为不同,它可以去重载;所以一个真正的原型类和继承类可能是这样的:
class MySTM extends STM { constructor(obj) { super(obj) } this.handleToggleButton = () => { // ... } } class MySTMInitState extends MySTM { // ... } class MySTMAnotherState extends MySTM { // ... }
需要注意的是不要在MySTM
的构造函数里写其他逻辑,如果有其他逻辑,写在React Component的constructor里,相当于是这个状态机原型对象的工厂。
在React Component的构造函数里,可以这样使用:
// 如果props和进入时的上下文有关,在这里处理 let props = { ... } // 创建了一个原型 let stm1 = new MySTMInitState(props)
这里有两个问题需要阐述一下。
第一,基于class语法构造对象的本质,其实只是在子类构造函数里把父类构造函数全部调一遍,保证对象属性完整,以及原型链正确;它是用起来最简洁的方式,但不是唯一的方式;
JavaScript提供了另一种方式来构造对象,即Object.create()
方法,两者是有区别的。
基于class语法构造的对象,如果你尝试:
let x = new MySTMInitState({}) let y = new MySTMAnotherState({}) console.log(x.__proto__ === y.__proto__)
你会得到一个false
输出,即这两个状态机的原型对象并非同一个对象,他们只是同一个构造函数(MySTM)构造过,因此具有同样的properties(方法)。
但是如果你使用Object.create()
来自己构造原型链,你可以有一个原型对象和React Component的生命周期一致,所有stm1状态机都以它为原型。这在某些情况下是有益的,例如:
你可以在这个原型上放context,减少迁移时
object.assign()
复制properties的性能负担;
事实上,这个方式更加符合JavaScript的原型化继承的设计初衷,但是语言是这样的一个东西,就是哪个语法简单,那个写法就被最广泛的使用,就像C++/Java里继承是最简单的语法,那么它就被用的最广泛,而写Pattern是复杂实现,他就被用的少,即使很多时候更应该写Pattern。
Anyway,这个区别在实践上的意义很小。
第二,是个对传统OO语言开发者来说比较难接受的地方,就是你可以这样写:
let x = new MySTM() let y = new MySTMInitState(x)
这件事情幽默的地方是你可以用基类对象去构造继承类对象,仿佛Class和Object的区别被抹平的,他们在平行世界之间穿越。
其实这正解释了JavaScript的所谓类,只是构造函数,所谓继承,就是把构造函数和原型对象串起来而已,类似Builder Pattern的思想;所以Build两步还是三步都是可能的。
这样写有一点实践上的意义,你可以先创建一个基类对象初始化所有的上下文,然后根据实际情况用它来构造继承类对象,这样能重用一下继承类对象的enter逻辑(即constructor),不用重写。
OK,这两个都是小问题,细节。move on。
在所有子类中,constructor等价于状态机Pattern的enter,用于创建所有资源,而exit中需要销毁所有资源,尤其是那些出发但尚未完成的请求,以及尚未fire的timer。对付这种问题,状态机是第一首选Pattern,简直太容易写出行为复杂且健壮的代码了。
事实上,任何其他形态的维护态的代码都可以看作是状态机Pattern的退化,所以对那些如果一开始就预见到未来会变得复杂的组件,应该一开始就写状态机;状态机牺牲的是代码量,但是对于行为定义的变化(迁移路径的增加,减少,改变,状态增减),它维护起来是无出其右的,是对付复杂多态行为的首选。
本质上,状态机帮你拿掉在所有方法里的第一层switch/case,代之以dispatch,或者是OO里说的多态;但是如果状态层叠呢?
通常我们不在状态机里套状态机,一般只有在写复杂协议栈的时候这么写;一般而言,状态机两层最多了,内层的状态用值来表示状态,而不是用类来表示状态,足够了。
举个例子看看你理解了没有:
你的UI里有一个行为是操作一个列表中的单一对象;如果有一个对象被选中,然后按钮被点击,这是一种行为,另一种是用户先创建一个新对象,这是另一种行为;那么需要把Editing和EditingNew作为两种互斥状态处理吗?
如果没有UI的颠覆性变化大多数情况不这样做,而是把Editing作为顶层状态机(superstate)处理,而New可以用一个props的值来表示,例如状态机对象里有一个叫做creating的prop,它是boolean类型。即顶层状态机用类对象表示,底层状态机回到土办法,用值表示。
这样设计的好处是:
Editing和EditingNew有大量状态是重用的和persistent的,即从一个迁移到另一个,他们仍然是有效的,不应该被一个exit销毁,另一个enter重建。
他们作为父子状态设计可以共用大量方法,而不是每个都提供自己的副本;
如果从父状态迁出或者从外部状态向父状态迁入,销毁和构建资源的逻辑也大部分是相同的;
实际上的状态图上往往是有superstate(父状态)迁出的事件逻辑;那么执行方式是
如果是外部迁入父状态机,要有一个决策依据决定应该迁向那个子状态机作为初始状态,因为在runtime,组合状态机构成的tree结构,实际的状态机实例只能在leaf node上,superstate节点的存在是为了抽象子节点的共同行为,减少迁移路径和重用行为逻辑;
因此迁入父状态机时(enter)的逻辑和迁出(exit)刚好相反:
在OO领域,很多开发者信奉UML图;UML图对OO语言中最重要的类图,在JavaScript里毛用没有了,但是State Machine图,结合上述状态机设计,绝对是对付复杂UI的利器;尤其是对于初学者而言,在前端的状态逻辑上,你能掌握这一把刀就能砍倒所有的树;如果还不能砍倒,那其实问题本身不是UI构建域的,可能是其他问题,例如调度等等。
很多写JavaScript的朋友,为了向世人证明自己根骨奇佳、习得真传,到处宣扬OO里的种种不是,以各种言辞抨击OO实践的方方面面。
他们不懂OO。
OO里在语言层面可能有一些设计问题,但是OO里的封装思想是绝对正确的;
为什么会有对象这个概念被提出来?就是因为一些态的生命周期超过函数调用的执行时间,你需要一种方式来管理这些态。
封装的本质是:在内部有一个state space,在外部看,只看到内部的state space的superstate。物理学上称之为简并,degeneration。
这是我们对付所有复杂状态的唯一手段,不管态放在花盆里、银行里、还是藏在自己的内裤里,他们都是客观存在,你不可能去消灭态,你只能organize他们;而且你同时需要organize应用在态上过程(function)。
状态机把这个organization完完全全一览无遗的展露出来,无论你用class写,用闭包写,用c语言写,行为和状态的structure都不会变,想成为一个合格的程序员,尤其是写ui的程序员,state machine pattern是必修课。
~~~~~~~~~~~~~~~~~~~
先写这么多,我得按照上述逻辑扣代码去了。
祝大家圣诞节快乐。
欢迎探讨。