状态机系列 (一) : 令人头疼的状态管理

状态机系列 (一) : 令人头疼的状态管理已关闭评论

这次,ycaptain 将带着大家解锁一条新的系列文章:「XState 有限状态机与状态图

XState?什么?又出了一个状态管理库?

有些读者看到这可能顿感 PTSD ,哀嚎”学不动了”~

先别急,待我细细道来。

状态管理真令人头疼

当我们写应用时,其实都是在利用 API 来控制应用的表现。好的 API 一般有三个特性:

  • 自我解释:它们具有很好的注释或者自我解释能力,只要读文档,你就能清晰地明白这个 API 在做什么
  • 可预测:它们应该是可预测的,它们在相同条件下,每次执行的结果应该都是相同的
  • 可测试:它们应该是可测试的,你可以通过 API 提供的 mocks 或者测试集去进行测试,保证能够正常执行得到如你所愿的结果

那么,大部分人写的 API 呢?大部分人写的 API 也有三个特性

当用户使用我们的应用时,并不会总按我们预想的方式去使用。让我们假设有一个理想中才存在的用户,他确实会按照我们理想的方式去使用应用。

拿网络请求举例,在这个例子中,我们将发送一个网络请求,并将请求的结果展示在应用中。

onSearch(query) {
    fetch(BD_API + '&tags=' + query)
        .then(
            data => {
                this.setState({ data });
            }
        );
}

这段代码看起来很简单,很容易就完成了。

接下来,让我们假设后端存在性能问题,或者需要进行一些耗时的运算,这个搜索 API 可能几秒甚至十几秒才能返回结果,那么我们需要加一个 loading 的状态。

onSearch(query) {
    this.setState({ loadingtrue });

    fetch(BD_API + '&tags=' + query)
        .then(
            data => {
                this.setState({ data, loadingfalse });
            }
        );
}

就像这样。我们需要在获取到数据前展示 loading 界面,获取到数据后,将 loading 设为 false,隐藏 loading 界面,并展示获取到的结果。

那么我们现在完成了么?并没有。如果请求中出错了呢?我们必须隐藏 loading 界面,展示错误提示。

onSearch(query) {
    this.setState({ loadingtrue });

    fetch(BD_API + '&tags=' + query)
        .then(
            data => {
                this.setState({ data, loadingfalse });
            }
        ).catch(error => {
            this.setState({
                loadingfalse,
                errortrue
            });
        });
}

现在我们是不是 bug free 了?

并没有。我们还需要确保用户再次发起请求时,清空了错误状态。

onSearch(query) {
    this.setState({
        loadingtrue,
        errorfalse
    });

    fetch(BD_API + '&tags=' + query)
        .then(
            data => {
                this.setState({
                    data,
                    loadingfalse,
                    errorfalse
                });
            }
        ).catch(error => {
            this.setState({
                loadingfalse,
                errortrue
            });
        });
}

正如你所看到的,应用的复杂性越来越大,我们的心智负担也在不断加大。我相信这时候你们能够联想到一些实际场景。

那么,如果这时候 PM 又加需求了,我们现在需要提供取消请求的能力了?

如同之前的假设,这个请求耗时太长了,用户可能会发起另外一个请求来取代这一个请求。

onSearch(query) {
    if (this.state.loading) {
        return;
    }

    this.setState({
        loadingtrue,
        errorfalse,
        canceledfalse
    });

    fetch(BD_API + '&tags=' + query)
        .then(
            data => {
                if (this.state.canceled) {
                    return;
                }

                this.setState({
                    data,
                    loadingfalse,
                    errorfalse
                });
            }
        ).catch(error => {
            if (this.state.canceled) {
                return;
            }

            this.setState({
                loadingfalse,
                errortrue
            });
        });
}

onCancel() {
    this.setState({
        loadingfalse,
        errorfalse,
        canceledtrue
    });
}

这时我们的代码将变得更加复杂,在这样一个小小的搜索事件中,我们处理了非常多的逻辑。

大家或许听说过 Spaghetti (意大利面) Code,也就是逻辑之间互相耦合依赖,非常难以维护的代码。

或许有些人会说:“我不写 Spaghetti Code,我的代码都是模块化、分层设计、高内聚低耦合的”。

即便如此,绝大多数的人都会陷入另一个陷阱:Lasagna (千层面) Code,即在代码片段之外,代码模块之间也互相耦合。


为了解决这一问题,我们可以通过一种自底向上的模式处理逻辑。

在这种模式下,无论是处理 onClick 还是 onChange 事件,所有的逻辑都是在 event (事件) 之下。

每个 event 可能对应许多不同的 action (行为),并且其中一些 action 还会修改 state (状态)

但是必须小心地为 event 选择合适的 action,否则很可能用错误的方式修改 state,比如在 action 内写了一连串的 if else 或者 switch 语句。

这样的代码很快就会变得难以维护。因为所有的逻辑只存在于你的脑袋里,当你写测试时,必须从记忆深处找回并解读出来。

拿刚才的示例代码举例,如果你尝试对新加入的团队成员讲解,你会发现让他们理解这段逻辑并不容易,更别说一整个项目了。

这也会使代码更难扩展,就像我们刚才引入取消功能时,加入难度远比之前的功能点要大。而新加入的功能,比如“取消请求”,会成倍地使代码变得更难维护。

让我们从另一个角度继续思考。

当我们需要实现一组互相有依赖的组件。我们会用分离组件的框架,比如 React,去实现这些组件。这些组件能够直接被嵌入页面中的任何位置。

在设计上,它们逻辑间互相分离,通过 props 建立关系。但是在实际场景中,不同组件间并不是无关的。我们需要组织好组件间的嵌套、创建、修改和通信。

那么,我们的解决方案是什么呢?

解决方案: 有限状态机与状态图

许多人在学校可能有学习过状态机的相关概念和学术定义,看学术定义或许理解成本比较高,让我们来通过例子直观理解下。

有限状态机包含五个重要部分

  • 初始状态值 (initial state)
  • 有限的一组状态 (states)
  • 有限的一组事件 (events)
  • 由事件驱动的一组状态转移关系 (transitions)
  • 有限的一组最终状态 (final states)

举个例子,当我们 fetch 时会返回一个 Promise,这时它进入 pending 状态。如果它被 resolve,进入 fullfilled 状态。如果它被 reject,进入 rejected 状态。

对于应用开发来说,大部分状态都是连续的。相对而言,最终状态出现的比例会小很多,在 Promise 中,fulfilled 和 rejected 就是它的最终状态。

基于有限状态机实现搜索

回到前面的搜索问题,我们可以用有限状态机对其建模。

默认状态为 idle,当我们触发了 search 事件,应用会进入 searching 状态。

如果我们在 searching 状态下,再触发 search 事件,应用仍处于 searching 状态。

接下来,我们可以 resolve 或者 reject 搜索的结果,并分别进入 success 或 failure 状态。

在这两种状态下,我们可以再次发起新的 search 事件,通过箭头指向,我们可以清晰地看出它将回到 searching 状态。

上面的状态机逻辑可以写成一个 JSON 对象(比起黑盒函数,JSON 或许更加可读,它能用简单的方式枚举所有可能的 states, actions 以及 transitions)

const machine = {
    initial'idle',
    states: {
        idle: {
            on: { SEARCH'searching' },
        },
        searching: {
            on: {
                RESOLVE'success',
                REJECT'failure',
                SEARCH'searching'
            }
        },
        success: {
            on: { SEARCH'searching' }
        },
        failure: {
            on: { SEARCH'searching' }
        }
    }
};

function transition(state, event{
    return machine.states[state].on[event];
}

完整代码见:https://codesandbox.io/s/xstate-search-react-o8jvx?file=/src/App.js

基于有限状态机实现 Live Share

举一个 XState 作者本人的例子,XState 的作者 David 来自微软,他还开发了 VSCode 的 Live Share 插件,可以用来结对编程、面试或者代码分享。

David 在开发这个插件时,因为复杂的逻辑,写了很多 bug。尤其是这类工具类应用,我们需要在同一个页面停留,不断处理非常多的状态。

拿登录举例。登录后进入 Signed In 状态,这时可以做两件事,share (分享) 一个 session (会话) 或者 join (加入) 一个 session。登录失败,需要返回 Signed Out 状态。

以上是基本流程。除此之外,用户可能在 share session 的过程中 sign out,还可能在 share session 的同时尝试 join 另一个 session。这些逻辑可以归类为一堆 if else,但利用状态机可以使它一目了然。

并且,通过监听状态转移,作者轻松实现了 Live Share 后来新增的埋点需求。

transition(currentState, event) {
    const nextState = //...

    Telemetry.sendEvent(
        currentState,
        nextState,
        event
    );

    return nextState;
}

从图上能够明显看出,用户进行 sign in 的频率最高。

登录后,用户进行 share 和 join session 的频率差不多。同时,它也清晰地展示了有多少用户进入了 success 状态,多少用户进入了 error 状态。

XState

XState 是对有限状态机(finite state machine)和状态图(statechars)面向现代 Web 开发的 js 实现,XState 没有自己创造新的概念,而是遵循 W3C 对 SCXML (State Chart extensible Markup Language) 进行了实现。

目前为止,在 GitHub 上已经有 17k 的 Star 数。

XState 有良好的生态支持,包括

  • xstate: 有限状态机和状态图的核心库 + 解释器
  • @xstate/fsm: 最小化的有限状态机库
  • @xstate/graph: 图遍历工具
  • @xstate/react: 针对 React 应用的 hooks 和 utilities
  • @xstate/vue: 针对 Vue 应用的 composition functions 和 utilities
  • @xstate/test: 基于 model 的测试工具
  • @xstate/inspect: 可视化库

等等

后续,我们将继续分享如何利用可视化工具,降低开发中的心智负担,提升开发效率。

XState 官方文档:https://xstate.js.org/docs/guides/start.html

迫不及待想要直接上手的朋友可以看看官方的 Todo MVC 样例: https://xstate.js.org/docs/examples/todomvc.html

– END –


来源: ByteDance Web Infra