24

背景

近年来由于一些前端框架的兴起而后逐渐成熟,组件化的概念已经深入人心,为了管理好大型应用中错综复杂的组件,又有了单向数据流的思想指引着我们,Vuex、Redux、MobX等状态管理工具也许大家都信手拈来。
我们手握着这些工具,不断思考着哪些数据应该放在全局,哪些数据应该局部消化,这样那样的数据该怎样流转。仔细想想会发现,我们一直在做的是如何将数据存在合理的地方,进而去规范怎样使用这些数据,我们称之为状态管理,但我觉得好像只是做了状态存储与使用,却没有做好管理二字。嗯,总感觉差了些什么。

你能将状态描述清楚吗?

来看一段简单的代码:

state = {
    data: [
        {
            id: 1,
            userName: xxx
        },
        {
            id: 2,
            userName: yyy
        }
    ]
}

如果根据UI = f(state)来说,上面的state.data就是一个状态,它能直接反映视图的样子:

render () {
    const {data} = this.state
    return (
        <div>
            {
                data && data.length ? data.map(item => <div :key={item.id}>{item.userName}</div>) : '暂无数据'
            }
        </div>
    )
}

我们还会在合适的时机进行某种操作去更新状态,比如请求获取数据的接口就会去更新上面的data:

updateData () {
  getData().then(({data}) => {
    this.setState({data})
  })
}

但随着时间的推移,这样的状态会越来越多,更新状态的方法暗藏在日益膨胀的代码中,维护者可能自己都要如履薄冰地一翻抽丝剥茧才勉强捋清楚状态什么时机更新,为什么要更新,更别说如果是去接盘一份祖传代码了。
究其原因,我觉得是没有将状态描述清楚,更别说管理好状态。所以一个描述得清楚的状态是长什么样子呢?比如:开始的时候,data是空数组,初始化完成需要去更新data,增删改完成后都需要更新data。
想想日常,有没有地方能一眼就看清楚这些状态信息?需求文档?UI稿?靠谱吗?
图片描述

有限状态机了解一下

自己动手丰衣足食,我们的目标是在代码里就能清晰地看到这些状态信息。如果我们能够写一份配置文件来将它们描述清楚,然后写代码的时候就根据这份配置文件来写,有修改的时候也必须先修改这份配置文件,那我们最后看配置文件就能对状态信息一目了然了。
为了达到这样的目标,我们得请有限状态机来帮忙。概念性的东西请移步到JavaScript与有限状态机,总的来说,有限状态机是一个模型,它能描述清楚有哪些状态,状态之间是怎样转化的,它有以下特点:
1.状态的数量是固定的
2.状态会因为触发了某种行为而转变成另一种状态(比如典型的promise,初始状态为pending,resolve后状态转变成fulfilled,reject则变成rejected)
3.任意时间点状态唯一(初始化完成了才能进行增删改嘛)
ok,了解这些之后,我们来看看怎样一步步达到目的。
我们以一个需求为例:
图片描述
就是一个没有一毛钱特效的Todoist,非常简单的增删改查。

初版

按照之前的想法,我们首先需要一份配置文件来描述状态:

const machine = {
  // 初始状态
  initial: "start",
  start: {
    INIT: "loadList"
  },
  loadList: {
    LOAD_LIST_SUCCESS: "showList",
    LOAD_LIST_ERROR: "showListError"
  },
  showListError: {
    RETRY: "loadList"
  },
  showList: {
    ADD: "add",
    EDIT: "edit",
    DELETE: "delete"
  },
  edit: {
    SAVE_EDIT: "saveEdit"
  },
  saveEdit: {
    SAVE_EDIT_SUCCESS: "loadList"
  },
  delete: {
    DELETE_SUCCESS: "loadList"
  },
  add: {
    ADD_SUCCESS: "loadList"
  }
};

配置是写完了,现在对着上面的需求gif图说一下这份配置是什么意思。

  1. 加载列表数据(initial: "start"表示初始状态是start,start: {INIT: "loadList"}表示状态start触发INIT事件之后状态会转变成loadList)
  2. 加载列表数据失败了(loadList触发LOAD_LIST_ERROR事件状态转变为showListError)
  3. 加载失败后重新加载(showListError触发RETRY事件之后状态重新变回loadList
  4. 重新加载列表成功(loadList触发LOAD_LIST_SUCCESS事件状态转变为showList)
  5. 列表加载成功就可以对列表进行增删改操作(showList可以触发ADD、DELETE、EDIT事件对应增删改操作带来的状态变化)

剩下的配置就不继续写了,可以看到通过这份配置,我们可以清晰知道,这份代码究竟做了些什么,而且写这份配置有利于整理好自己的思路,让自己首先将需求过一遍,将所有边边角角通过写配置预演一遍,而不是拿到需求就开撸,遇到了问题才发现之前写的代码不适用。同理如果需求有变,首先从这份配置入手,看看这波修改会对哪些状态分支造成影响,就不会出现那种不知道改一个地方会不会影响到别的地方宛如拆炸弹一样的心情。
接着,为了方便根据这份配置来进行操作,需要实现一点辅助函数:

class App extends Component {
    constructor(props) {
        state = {
            curState: machine.initial
        }
    }
    handleNextState (nextState, action) {
        switch (nextState) {
            case "loadList":
            // 处理loadList的逻辑
            break;
        }
    }
    transition (action) {
        const { curState } = this.state;
        const nextState = machine[curState][action.type];
        if (nextState) {
            this.setState({ curState: nextState }, () =>
                this.handleNextState(nextState, action)
            );
        }
    }
}

基本就是这样的结构,通过this.transition({ type: "INIT" })触发一个事件(INIT)将当前状态(start)转变成另外一个状态(loadList),而handleNextState则处理状态转变后的逻辑(当状态变成loadList需要去请求接口获取列表数据)。通过这样的方式,我们真正将状态管理了起来,因为我们有清晰的配置文件去描述状态,我们有分层清晰的地方去处理当前状态需要处理的逻辑,这就相当于有明确的战略图,大家都根据这份战略图各司其职做好自己的本分,这不是将状态管理得井井有条吗?
而且这样做之后,比较容易规避一些意外的错误,因为任意时间点状态唯一这个特点,带来了状态只能从一个状态转变到另一个状态,比如点击一个按钮提交,这时的状态是提交中,我们经常需要去处理用户重复点击而导致重复提交的事情:

let isSubmit = false
const submit = () => {
    if (isSubmit === true) return
    isSubmit = true
    toSubmit().then(() => isSubmit = false)
}
submit()

使用有限状态机进行管理后就不需要写这种额外的isSubmit状态,因为提交中的状态只能转变为提交完成。
上面的代码完整版请点这里

更进一步

虽然初版对于状态的管理更加清晰了一些,但仍然不够直观,如果能将配置转化成图就好了,有图有真相嘛。心想事成:
图片描述
不仅有图可看,还可以逼真地将所有状态都预演一遍。这个好东西就是xstate给予我们的,它是一个实现有限状态机模型的js库,感兴趣可以去详看,这里我们只需要按照它的写法去写状态机的配置,就可以生成出这样的图
看过xstate会发现,里面的东西真不少,其实如果只是想在简单的项目上用这种模式试试水,却要把整个库引进来似乎不太划算。那,不如自己来撸一个简化版?
心动不如行动,先分析一下初版有什么不足之处。

  1. 没有将有限状态机的模式分离出来,如果不是用react而是用vue就用不了了。
  2. 没有将模式分离出来导致复用性很差,总不能每个地方要用的时候都要写一次transition等方法吧。
  3. 配置项没写成xstate的样子无法使用xstate提供的工具生成图。

现在首要的任务就是把有限状态机的模式抽离出来,顺便使用xstate的写法来写配置。

const is = (type, val) =>
  Object.prototype.toString.call(val) === "[object " + type + "]";

export class Fsm {
  constructor(stateConfig) {
    // 状态描述配置
    this.stateConfig = stateConfig;
    // 当前状态
    this.state = stateConfig.initial;
    // 上一个状态
    this.lastState = "";
    // 状态离开回调集合
    this.onExitMap = {};
    // 状态进入回调集合
    this.onEntryMap = {};
    // 状态改变回调
    this.handleStateChange = null;
  }

  /**
   * 改变状态
   * @param type 行为类型 描述当前状态通过该类型的行为转变到另一个状态
   * @param arg 转变过程中的额外传参
   * @returns {Promise<void>}
   */
  transition({ type, ...arg }) {
    const states = this.stateConfig.states;
    const curState = this.state;
    if (!states) {
      throw "states undefined";
    }
    if (!is("Object", states)) {
      throw "states should be object";
    }
    if (
      !states[curState] ||
      !states[curState]["on"] ||
      !states[curState]["on"][type]
    ) {
      console.warn(`transition fail, current state is ${this.state}`);
      return;
    }
    const nextState = states[curState]["on"][type];
    const curStateObj = states[curState];
    const nextStateObj = states[nextState];
    // 状态转变的经历
    return (
      Promise.resolve()
        // 状态离开
        .then(() =>
          this.handleLifeCycle({
            type: "onExit",
            stateObj: curStateObj,
            arg: { exitState: curState }
          })
        )
        // 状态改变
        .then(() => this.updateState({ state: nextState, lastState: curState }))
        // 进入新状态
        .then(() =>
          this.handleLifeCycle({
            type: "onEntry",
            stateObj: nextStateObj,
            arg: { state: nextState, lastState: curState, ...arg }
          })
        )
    );
  }

  /**
   * 状态改变回调 只注册一次
   * @param cb
   */
  onStateChange(cb) {
    cb &&
      is("Function", cb) &&
      !this.handleStateChange &&
      (this.handleStateChange = cb);
  }

  /**
   * 注册状态离开回调
   * @param type
   * @param cb
   */
  onExit(type, cb) {
    !this.onExitMap[type] && (this.onExitMap[type] = cb);
  }

  /**
   * 注册状态进入回调
   * @param type
   * @param cb
   */
  onEntry(type, cb) {
    !this.onEntryMap[type] && (this.onEntryMap[type] = cb);
  }

  /**
   * 更新状态
   * @param state
   * @param lastState
   */
  updateState({ state, lastState }) {
    this.state = state;
    this.lastState = lastState;
    this.handleStateChange && this.handleStateChange({ state, lastState });
  }

  /**
   * 处理状态转变的生命周期
   * @param stateObj
   * @param type onExit/onEntry
   * @param arg
   * @returns {*}
   */
  handleLifeCycle({ stateObj, type, arg }) {
    const cbName = stateObj[type];
    if (cbName) {
      const cb = this[`${type}Map`][cbName];
      if (cb && is("Function", cb)) {
        return cb(arg);
      }
    }
  }

  /**
   * 获取当前状态
   * @returns {*}
   */
  getState() {
    return this.state;
  }

  /**
   * 获取上一个状态
   * @returns {string|*}
   */
  getLastState() {
    return this.lastState;
  }
}

然后这样使用就好:

const stateConfig = {
  initial: "start",
  states: {
    start: {
      on: {
        INIT: "loadList"
      },
      onExit: "onExitStart"
    },
     loadList: {
      on: {
        LOAD_LIST_SUCCESS: "showList",
        LOAD_LIST_ERROR: "showListError"
      },
      onEntry: "onEntryLoadList"
    }
  }
}
/*
结果:
1.console.log('onExitStart')
2.console.log('onEntryLoadList')
3.console.log('transition success')
transition以及生命周期函数onExit、onEntry都支持promise控制异步流程
*/
const fsm = new Fsm(stateConfig);
transition({ type: "INIT"}).then(() => {
  console.log('transition success')
})
fsm.onExit('onExitStart', (data) => {
  return new Promise(resolve => {
    setTimeout(() => {
      console.log('onExitStart')
      resolve()
    }, 1000)
  })
})
fsm.onEntry('onEntryLoadList', (data) => {
  console.log('onEntryLoadList')
})

总算把有限状态机抽成一个工具来使用了,已经完成了最关键的一步。

集成到react去使用

如果想在react中使用,想到比较方便的使用形式是高阶组件,需要用到有限状态机的组件传进高阶组件,就立马拥有了使用有限状态机的能力。

import React from "react";
import { Fsm } from "../fsm";
export default function(stateConfig) {
  const fsm = new Fsm(stateConfig);
  return function(Component) {
    return class extends React.Component {
      constructor() {
        super();
        this.state = {
          machineState: {
            // 当前状态
            value: stateConfig.initial,
            // 上一个状态
            lastValue: ""
          }
        };
      }
      updateMachineState(data) {
        this.setState({
          machineState: { ...this.state.machineState, ...data }
        });
      }
      componentDidMount() {
        this.handleStateChange();
        this.handleEvent();
      }

      /**
       * 处理状态更新
       */
      handleStateChange() {
        fsm.onStateChange(({ state, lastState }) => {
          this.updateMachineState({ value: state, lastValue: lastState });
        });
      }

      /**
       * 处理状态改变事件
       */
      handleEvent() {
        const states = stateConfig.states;
        // 获取状态配置中所有的onEntry与onExit
        const eventObj = Object.keys(states).reduce(
          (obj, key) => {
            const value = states[key];
            const onEntry = value.onEntry;
            const onExit = value.onExit;
            onEntry && obj.onEntry.push(onEntry);
            onExit && obj.onExit.push(onExit);
            return obj;
          },
          {
            onEntry: [],
            onExit: []
          }
        );
        // 获取组件实例中onEntry与onExit的回调方法
        Object.keys(eventObj).forEach(key => {
          eventObj[key].forEach(item => {
            this.ref[item] && fsm[key](item, this.ref[item].bind(this.ref));
          });
        });
      }
      render() {
        return (
          <Component
            ref={c => (this.ref = c)}
            {...this.state}
            transition={fsm.transition.bind(fsm)}
          />
        );
      }
    };
  };
}

使用的时候就可以:

const stateConfig = {
  initial: "start",
  states: {
    start: {
      on: {
        INIT: "loadList"
      },
      onExit: "onExitStart"
    }
  }
}
class App extends Component {
    componentDidMount () {
        this.props.transition({ type: "INIT" });
    }
    onExitStart () {
        console.log('onExitStart ')
    }
}
export default withFsm(machine)(App);

现在我们可以愉快地使用这个高阶组件将Todoist重构一遍
当然,大佬们会说了,我的项目比较复杂,有没有比较完善的解决方案呢?那肯定是有的,可以看看react-automata,将xstate集成到react中使用。由于我们上面的小高阶组件用法比较像react-automata,所以基本不需要什么改动,就可以迁移到react-automata,使用react-automata再重构一遍Todoist

最后

对于符合有限状态机的使用场景,使用它确实能将状态管理起来,因为我们的状态再也不是那种如isSubmit = false/true那样杂乱无章的状态,而是某个时间节点里的一个总括状态。不管怎样,有限状态机的方案还是促使了我们去重新思考怎样能更大程度地提高项目的可维护性,提供了一个新方向尽可能减少祖传代码,改起bug或者需求的时候分析起来更加容易,终极目的只有一个,那就是,希望能早点下班。


写Bug
5.4k 声望2.2k 粉丝