少妇白洁系列之React StateUp模式

“换句话说,StateUp模式把面向对象的设计方法应用到了状态对象的管理上,在遵循React的组件化机制和基于props实现组件通讯方式的前提之下做到了这一点。” —- 少妇白洁

阅读本文之前,请确定你读过React的官方文档中关于Lifting State Up的论述:

https://facebook.github.io/re…

昨天写的雏形经过refine之后,得到了React有史以来最激动人心的代码模式之一。

我们的出发点很简单:

  1. 希望把有态组件的态提升到父组件管理,这个过程应该可以向上递归,即状态可以层层提升;

  2. 在代码形式层面,应该和原生React组件越兼容越好;

现有的React有态组件改成状态提升组件(StateUp)应该很简单,反之亦然;StateUp组件有时可能会需要象普通React组件那样使用;一个最初没有子组件的StateUp组件,可能会加入StateUp子组件;在这些设计变更发生时,组件的修改和组合都应该很简单,且灵活。

子组件

我们首先考虑子组件,子组件的约定是:

1 继承自React.PureComponent

这个新出现不久的便捷组件在scu时自动做shallow equal的比较,省去自己写代码的麻烦;

2 只需修改this.statethis.setStatethis.props.statethis.props.setState

这样切换代码模式时非常简单;this.props.setState的语义实现和React组件原有的this.setState一致,即merge状态对象而不是replace;

3 原来写在this.state内的对象,成为独立的JavaScript类对象;设计为类对象而不是Plain Object的好处是它可以有方法,便于重用;这个类肯定和组件类成对使用,所以不妨把它直接嵌入成为组件类的static成员,统一命名为State

即每个StateUp组件看起来这样:

class MyStateUp extends PureComponent {
  static State = class State {
    constructor() {
      this.something = ...
    }
  }
}

写在class关键字之后的State不是必须的,但是给这个类赋一个类名的好处是在实现类方法时可以直接调用类构造函数创建新对象,否则这个构造函数没有名字。

父组件

父组件需要通过props向子组件传递两个东西,第一个是子组件的state对象,第二个是子组件需要的setState方法;目的是可以大概写成:

<SubComponent state={...} setState={...} />

前者比较容易实现,后者有一点小麻烦;

一般我们向子组件传递方法时都是用bound function,绑定父组件的this;如果直接写在render方法的JSX语法内,每次创建的bound function对象实例是不同的,这导致每次父组件render,子组件都要重新render,这不是我们想要的结果;所以我们需要一个在父组件上持久化的对象成员提供这个bound function。一般这种情况会在类的构造函数内创建一个属性,引用bound function或词法域bind this的arrow function,但后面会看到我们有更好的办法,避免这种手工代码。

确切的说,这里说的父组件或者子组件指的是相对角色(role);角色和组件是否为StateUp组件无关;一个StateUp组件可以是其他StateUp组件的父组件,同时也是另一个StateUp组件的子组件;即StateUp组件是可以组合的。

作为“子”的责任是前面说的提供内嵌static Class用于构造原有this.state对象,以及继承自PureComponent

作为“父”的责任是组合子对象的状态,同时具有一些类方法,可以向子组件提供类对象和类对象更新方法;

如果一个组件兼具两者,它可以继续向上组合;如果对象只具有后者特性,它是一个普通的React组件,但可以内部使用StateUp组件。

StateUp mixin

StateUp mixin可以赋予一个StateUp组件,或者普通React有态组件,成为“父”组件所需的类方法。

StateUp的代码非常简单,实际上它是一个纯函数,向一个React组件内混入(mixin)三个方法:

  1. setSubState 更新某个子组件的状态对象;父组件可以直接调用这个方法;

  2. setSubStateBound 提供一个稳定的bound function,传递给子组件,子组件可以用来更新托管的状态对象;

  3. stateBinding 返回一个props对象,结合spread operator使书写JSX更方便;

StateUp mixin本身并不依赖React。


const StateUp = base => class extends base {

  setSubState(name, nextSubState) {
    let state = this.props.state || this.state
    let subState = state[name]
    let nextSubStateMerged = Object.assign(new subState.constructor(), subState, nextSubState)
    let nextState = { [name]: nextSubStateMerged }
    this.props.setState
      ? this.props.setState(nextState)
      : this.setState(nextState)
  }

  setSubStateBound(name) {
    let obj = this.setSubStateBoundObj || (this.setSubStateBoundObj = {})
    return obj[name] ? obj[name] : (obj[name] = this.setSubState.bind(this, name))
  }

  stateBinding(name) {
    return {
      state: this.props.state ? this.props.state[name] : this.state[name],
      setState: this.setSubStateBound(name)
    }
  }
}

父组件和子组件的状态约定:父组件使用一个object来作为状态容器,其中一个property对应一个子组件,该property引用的对象就是子组件的状态对象;所有状态对象都是class对象;这些对象构成的树,就是StateUp组件通过组合构成的树,不同之处在于对象的生命周期是超过对应组件的生命周期的;在JSX语法中经常根据需要显示或删除某个组件,但该组件对应的状态对象,可以在父组件的状态对象内持久;

setSubState方法是用于更新子组件状态的方法;

setSubState的第一个参数是(property) name,第二个参数是子组件提供的nextState,按照React习惯,它是partial state,应merge到子组件的状态中;

setSubState代码第一句拿到父组件的state,如果父组件是有态的就是this.state,如果父组件的态托管到更高的组件上去,就是this.props.state

第二句拿到子组件的state;

第三句先构造一个新的子组件状态对象,注意它是new出来的,不是{};然后子组件的当前状态和新的状态都merge进去,得到父组件的nextState对象(也是partial state);

最后一句更新状态,如果父组件也是StateUp组件,它继续调用父父组件方法更新状态,如果不是,它直接调用React组件的setState方法,两者的语义被设计成一致的;

本质上这个函数是一个递归函数;React的this.setState最终一定会被调用,但在此之前,一直是this.props.setState在沿着StateUp组件的层级关系自下向上调用;

如果熟悉immutable的话,会发现StateUp组件的状态对象树是满足immutable要求的,因为在计算nextSubStateMerged时使用了Object.assign();所以这个递归过程沿着对象路径一直到root都会更新,由于所有StateUp组件都是继承自PureComponent,自带的SCU,其结果是:

  1. 按照React的设计逻辑,这是最佳性能;

  2. PureComponent自带SCU逻辑,不需要手工代码;

  3. 没有额外的state store, dispatch, action/event之类的逻辑,只使用React组件和JavaScript类对象来维护状态;

  4. 真正会触发render的setState只在顶级父组件中被调用一次;不是组件和状态管理器之间binding之后的触发多个组件的render。

setSubStateBound用于产生和获取父组件向子组件传递的setState bound function

它首先在父组件上创建名字为setSubStateBoundObj的容器,用于装载bound function;一般使用而言没必要用Map,就用Plain Object做key value容器即可;

该函数采用了Lazy方式工作,即每次需要时才创建bound function;这个做法优于试图在构造函数中创建这些bound function的做法,后者或者需要用户手工代码,或者需要使用Decorator去Hack class的构造函数,没有必要的复杂、危险、或不兼容;

该函数为每个子组件创建一个bound function(即子组件的this.props.setState);它除了bind this之外还需要bind (property) name;创建的bound function保存在容器内,每次调用setSubStateBound时返回结果一致,确保了所有子组件的SCU工作。

最后的stateBinding方法是便利函数,用于书写JSX时不需要手写statesetState,使用spread operator即可;

<SubComponent {...this.stateBinding('subComponentName')} />

解耦

StateUp的核心是实现了组件(view)和它的状态对象(state)的解耦;

StateUp组件的状态对象是class对象,这个class是JavaScript class,与React无关,它可以提供各种方法;

其中的只读方法,对父组件而言可以直接访问,这对于父组件协调几种子组件的显示非常有用,例如按钮的使能;

class对象也可以有改变对象状态的方法,但约定是,它只能返回新对象,即保证immutable;对于子组件而言,这些方法可以直接调用,然后通过this.props.setState实现更新;对于父组件而言这些方法同样可以调用,但更新路径是this.setSubState

后者相当于在普通的React组件组合中,父组件在拿到子组件的引用后直接去调用子组件的setState方法;

在React中这不是值得推荐的方法(洗剪吹们称之为anti-pattern),首先是因为React的setState没有封装可言,调用该方法需要理解组件内部的状态的含义,其次子组件很动态,父组件容易拿到旧的子组件的引用导致错误;

StateUp组件的状态对象从组件中剥离出去,它解耦了“需要更新状态以更新显示”和“了解如何更新状态”这两件事情,后者用类对象方法实现封装,而父组件可以只完成前者;

例如一个输入框对应的状态对象可能有一个叫做reset的方法,父组件可以调用reset方法获得一个新的子组件状态对象,父组件仅更新对象即可,它不需要了解reset如何工作;reset方法写在状态对象类上,本身也可以最大限度的重用;

另外一个例子,考虑一个Form和向导组件;Form中的一些元素,例如用户的用户名和密码输入框,如果输入合法,则next button使能;

StateUp模式中,用户名密码输入可以作为一个组件封装,它的状态对象可以提供ready方法用于判断是否完成,这比在组件上提供props和传递bound function通知方便,父组件也不需要cache一个ready状态;

同样的,如果next之后,用户名密码组件从视图中移除,父组件需要保存之前输入的用户名密码副本,如果用户回退,这些内容还要交给新创建的用户名密码组件,而在StateUp模式下,这些都不是问题,因为子组件状态对象并未消除,它也不需要把内容展开到父组件的状态容器内,如果父组件需要获取输入结果,那么一个get方法即可做到;

这样的组件无论在向导页面、用户修改用户名密码的页面等等地方都很容易重用,父组件仅仅需要在自己的状态内创建一个子组件的状态对象即可,在render时也仅仅需要传递这个对象而不是展开的一组props,也不需要去增加很多接受状态变化通知的方法并传递到子组件上;

换句话说,StateUp模式把面向对象的设计方法应用到了状态对象的管理上,在遵循React的组件化机制和基于props实现组件通讯方式的前提(context)之下做到了这一点。

能够在组件的状态对象上实现维护状态的方法,父子组件均可访问,均有更新路径,是StateUp模式的重要的收益,它兼顾了便利性、灵活性、和代码重用。

完整例子

下面看一个简单且无聊的代码实例:

class Sub extends PureComponent {

  static State = class State {
    constructor() {
      this.label = ''
    }
  }

  render() {

    console.log('Sub render:' + this.props.state.label)

    return (
      <div>
        <button
          style={{width: 64, height: 24}}
          onClick={() => this.props.setState({ label: this.props.state.label + 'a' })}
        >
          {this.props.state.label}
        </button>
      </div>
    )
  }
}

可以看到对子组件而言没有因为Pattern引入带来的过多代码负担;StateUp组件需要提供状态对象的class,必须写成static且名字为State;这是一个约定;父组件利用这个约定找到子组件的构造函数创建子组件的状态对象;

下面的代码展示了如何在父组件中使用子组件;这个父组件本身也是一个StateUp组件,即继续向上层容器传递状态,而不是自己维护状态;

class Composite extends StateUp(PureComponent) {

  static State = class State {
    constructor() {
      this.sub1 = new Sub.State()
      this.sub2 = new Sub.State()
      this.sub3 = new Sub.State()
    }
  }

  render() {

    return (
      <div>
        <Sub {...this.stateBinding('sub1') } />
        <Sub {...this.stateBinding('sub2') } />
        <Sub {...this.stateBinding('sub3') } />
      </div>
    )
  }
}

注意extends关键字后面的写法,这是目前为止JavaScript里最好的mixin模式,它不污染prototype,也没有因为前卫的语法导致兼容性问题,StateUp本身不重载constructor,也不会影响super,instanceof等关键字的使用;

Composite也是StateUp组件,也要提供一个State class,其构造函数中调用Sub.State类的构造函数构造子组件的状态对象,用sub1, sub2sub3命名;

render方法里展示了子组件的使用方式;这里应该看作是一种binding;把一个组件的状态对象和它的view binding在一起,后者是pure的。

Composite仍然是StateUp组件,这意味着如果要使用它需要一个更上层的容器;我们来写一个通用的组件终结这个层层向上传递状态的游戏。

class Stateful extends StateUp(Component) {

  constructor(props) {
    super()
    this.state = { state: new props.component.State() }
  }

  render() {
    let Component = this.props.component
    return <Component {...this.stateBinding('state')} />
  }
}

class App extends Component {
  render() {
    return <Stateful component={Composite} />
  }
}

Stateful仍然需要继承StateUp mixin,这样它就有在内部组合使用StateUp组件的便利;但是它不用PureComponent做起点,而使用标准的React Component,它是有态的,也是它下面所有StateUp组件树的唯一顶层态容器。

Stateful不需要static State class,它直接用自己的this.state作为状态容器;由于StateUp代码里要求子组件状态对象在父组件状态对象中必须有名字,所以这里在this.state内再创建一个叫state的property,引用子组件状态对象(这样可以重用代码);

Stateful是通用的,它具体wrap了哪个StateUp组件,用名字为component的prop传递进来,在render方法里直接渲染这个component即可;

最终我们在示例代码中用Stateful把Composite用在页面上。

上述代码很容易调试;在Sub组件的render方法中有一句打印,可以看到在每次点击button时只有该button会渲染,即所有StateUp,作为PureComponent,SCU自动工作;

小结

目前的代码只能用对象方式组合,不能用数组,但这不是一个很大的麻烦,如果你仔细看StateUp mixin函数代码就会发现,name改成index是很容易的,只是bound function的处理方式要小心,因为它在对象被销毁之前没有回收机制。

这个Pattern不是为了作为大一统的状态管理器被提出的;我最初只想实现一些反复重写的代码的重用;

React本身通过Composition的重用,在理论上没有问题,但非常不灵活;虽然有container component和pure component的概念,但是container component的状态变化,仍然需要在更高层的组件内cache状态,cache的更新通过props传递notification实现,这形成了一个两难局面:如果状态local,则组件的props设计需要考虑可能的观察者逻辑,如果状态提升,则破坏封装原则;

StateUp模式就是为了解决这个问题设计的;它给出了一种方式让子组件既能独立封装逻辑,便于重用,又能绕开写起来非常繁琐的props通讯机制,让父组件方便获取子组件状态,灵活组合行为;

StateUp组件的重用能力是卓越的,你不需要把状态和维护状态的逻辑代码放到另外一个文件里;StateUp也没有外部依赖,不强制要求消息总线或状态管理器,没有因此导致的性能问题,binding问题,消息名称的namespace问题;它是百分之百纯JS和百分之百纯React;

它在性能上,以及为了获取这种性能所需要的额外编码上,也接近完美。

事实上,我个人认为,既然React都有了PureComponent作为内置组件,这种StateUp模式,也应该是React内置功能。

更新

  1. 最新关于React StateUp模式的数学背景介绍: https://segmentfault.com/a/11…

  2. subProps函数重命名为stateBinding,因为本质上它是向子组件绑定状态和更新状态的方法;

    原文作者:uglee
    原文地址: https://segmentfault.com/a/1190000008569588
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞