前端状态管理请三思

1,684 阅读10分钟

最近我开始思考React应用的状态管理。我已经取得一些有趣的结论,并且在这篇文章里我会向你展示我们所谓的状态管理并不是真的在管理状态。

译者:阿里云前端-也树

原文链接:managing-state-in-javascript-with-state-machines-stent

我们避而不谈的是什么(The elephant in the room)

我们来看一个简单的例子。想象这是一个展示用户名称、密码和一个按钮的表单组件。用户会在填写表单后点击提交。如果一切顺利,我们完成了登录,并且有必要展示欢迎信息和一些链接:


我们假定这个组件有两个展示状态。一个是未登录状态,另一个是用户登录后的状态。所以从管理这两种状态开始,我们用一个布尔值的标志位来描述用户的状态。

var isLoggedIn;
isLoggedIn = false; // 展示表单
isLoggedIn = true; // 展示欢迎信息和链接

但是这样还不够。如果我们点击提交按钮后触发的HTTP请求需要一些时间来响应,我们不能把表单孤零零的放在屏幕上,而需要更多的UI元素来展示这样的中间状态,因此我们不得不在组件中引入另一个状态。

现在我们有了第三种展示状态,仅仅用一个 isLoggedIn 变量已经不能解决了。不走运的是我们不能设置变量值为 false-ish,它不是 true 也不是 false。当然,我们可以引入另一个变量比如说 isInProgress。一旦我们发送请求就会把这个变量的值置为 true。这个变量会告诉我们是处于请求的过程中并且用户应该看到加载中的展示状态。

var isLoggedIn;
var isInProgress; 

// 展示表单
isLoggedIn = false;
isInProgress = false;

// 请求过程中
isLoggedIn = false;
isInProgress = true;

// 展示欢迎信息和链接
isLoggedIn = true;
isInProgress = false;

非常棒!我们用到两个变量并且需要记住这三种情况对应的变量值。看起来我们解决了问题。但另外的问题是,我们维护了太多状态。如果我们需要展示一个请求成功的信息,或者一切顺利的时候我们需要告知用户:“Yep, 你成功登录了”,并且两秒后信息伴随着华丽的动画隐藏起来,接着展示出最终的界面,要怎么办?


现在情况变得有些复杂。我们有了 isLoggedInisInProgress,但是看起来仅仅使用它们还不够。isInProgress 在请求结束后确实是 false,但是他的默认值同样是 false。我觉得我们需要第三个变量 - isSuccessful

var isLoggedIn, isInProgress, isSuccessful;

// 展示表单
isLoggedIn = false;
isInProgress = false;
isSuccessful = false;

// 请求过程中
isLoggedIn = false;
isInProgress = true;
isSuccessful = false;

// 展示成功状态
isLoggedIn = true;
isInProgress = false;
isSuccessful = true;

// 展示欢迎信息和链接
isLoggedIn = true;
isInProgress = false;
isSuccessful = false;

我们简单的状态管理一步步变成了由 if-else 组成的巨大的条件网,很难去理解和维护。

if (isInProgress) {
  // 请求过程中
} else if (isLoggedIn) {
  if (isSuccessful) {
    // 展示请求成功信息
  } else {
    // 展示欢迎信息和链接
  }
} else {
  // 等待输入,展示表单
}

我们还有一个问题会让这个情景变得更糟:如果请求失败我们要怎么做?我们需要展示一个错误信息和一个重试链接,如果点击重试我们会重复一次请求的过程。

现在我们的代码已经没有任何可维护性。我们有非常多的场景需要满足,仅仅依赖引入新的变量是不可接受的。让我们想想是否可以通过更好的命名方式来解决,同时可能还需要引入一个新的条件声明。

isInProgress 仅仅在请求的过程中被用到。我们现在还关心请求结束之后的过程。

isLoggedIn 有一点误导的含义,因为我们只要请求结束就把它置为 true。而如果请求出错,用户并没有真正登入。所以我们把它重命名为 isRequestFinished。虽然看起来好些了,但是它仅仅代表我们从服务器获得了响应,并不能用它来判断响应是否为错误。

isSuccessful 是一个最终状态合适的候选变量。如果请求出错我们可以把它设置为 false,但是等等,它的默认值也是 false。所以它也不能作为代表错误状态的变量。

我们需要第四个变量,isFailed 怎么样?

var isRequestFinished, isInProgress, isSuccessful, isFailed;

if (isInProgress) {
  // 请求过程中
} else if (isRequestFinished) {
  if (isSuccessful) {
    // 展示请求成功信息
  } else if (isFailed) {
    // 展示请求失败信息和重试链接
  } else {
    // 展示欢迎信息和链接
  }
} else {
  // 等待输入,展示表单
}

这四个变量描述了一个看似简单但实际并不简单的过程,这个过程包含了许多边界情况。当项目进一步迭代时,最终可能会由于已有变量的组合不能满足新的需求,而定义更多的变量。这就是构建用户界面十分困难的原因。

我们需要更好的状态管理方式。也许可以使用更现代和更流行的概念。

Flux 或者 Redux 怎么样?

最近我在思考 Flux 架构和 Redux 库在状态管理中的定位。即使这些工具和状态管理有关,但是它们本质上不是解决这类问题的。

Flux 是 Facebook 用来构建客户端 web 应用的架构。它利用单向数据流补足了 React 的视图组件的组织方式。

Redux 是一个可预测的状态容器,用来构建 JavaScript 应用。

它们是 “单向数据流” 和 “状态容器”,而不是 “状态管理”。Flux 和 Redux 背后的概念是非常实用和讨巧的。我认为它们是适合构建用户界面的方式。单向数据流让数据拥有可预测性,改进了前端开发。Redux 中的 reducer 拥有的不可变特性,提供了一种可以减少 bug 的数据传送方式。
就我的感受来说,这些模式更适用于数据管理和数据流管理。它们提供了完善的 API 来交换改变我们应用数据的信息,但是并不能解决我们状态管理的问题。这也因为这些问题是跟项目强相关的,问题的上下文取决于我们正在做的事情。
当然像处理 HTTP 请求我们可以通过某个库来解决,但是对其它相关的业务逻辑我们仍然需要自己编写代码来实现。问题在于我们如何用一种合适的方式去组织这些代码,而不至于每两年就把整个应用重写一遍。

几个月之前我开始寻找可以解决状态管理问题的模式,最终我发现了状态机的概念。事实上我们一直都在构建状态机,只不过我们不知道。

什么是状态机?

状态机的数学定义是一个计算模型,我的理解是:状态机就是保存你的状态和状态变化的一个盒子。这里有一些不同种类的状态机,适用于我们这个案例的是有限状态机。像它的名字一样,有限状态机包含有限的几种状态。它接收一个输入并且基于这个输入和当前的状态决定下一个状态,可能会有多种情况输出。当状态机改变了状态,我们就称为它过渡到一个新的状态。

实战状态机

为了使用状态机我们或多或少需要定义两件事 - 状态和可能的过渡方法。让我们来尝试实现上面提到的表单需求。

在这个表格中我们可以清楚的看到所有状态和他们可能的输出情况。我们同样定义了如果输入被传递进状态机后的下一个状态。编写这样的表格对你的开发周期大有裨益,因为他会回答你以下问题:

  • 用户界面可能出现的所有状态有哪些?
  • 每种状态之间会发生什么?
  • 如果某种状态改变,结果是什么?

这三个问题可以解决非常多的难题。想象一下当我们改变内容展示的时候有一个动画效果,当动画开始时,UI 仍然处于之前的状态并且用户仍然可以产生交互。举个例子,用户非常快速地点击了两次提交按钮。如果不适用状态机,我们需要使用if语句通过标志变量来防止代码的执行。但是如果回到上面那个表格,我们会看到 loading 状态不接受 Submit 状态的输入。所以如果我们在第一次点击按钮后把状态机转变为 loading 状态,我们就会处于一个安全的位置。即使 Submit 输入/动作被分发过来,状态机也会忽略它,当然也不会再向后端发出一个请求。

状态机模式对我来说是适用的。以下有三个理由支撑我在我的应用中使用状态机:

  • 状态机模式免去了很多可能出现的 bug 和奇怪的清洁,因为它不会让 UI 变化为我们不知道的状态。
  • 状态机不接受没有明确定义的输入作为当前的状态。这会免去我们对其它代码执行的部分容错处理。
  • 状态机强制开发者以声明式的方式思考。因为我们大部分的逻辑需要提前定义。

在 JavaScript 里实现状态机

现在,既然我们知道什么是状态机,那就让我们来实现一个并且解决我们一开始的问题。用一些嵌套的属性定义一个简单的对象字面量。

const machine = {
  currentState: 'login form',
  states: {
    'login form': {
      submit: 'loading'
    },
    'loading': {
      success: 'profile',
      failure: 'error'
    },
    'profile': {
      viewProfile: 'profile',
      logout: 'login form'
    },
    'error': {
      tryAgain: 'loading'
    }
  }
}

这个状态机对象使用我们上面表格中的内容定义了状态。像示例中那样,当我们在 login form 状态时,我们用 submit 作为一个输入并且应该以 loading 状态结束。现在我们需要一个接收输入的函数。

const input = function (name) {
  const state = machine.currentState;

  if (machine.states[state][name]) {
    machine.currentState = machine.states[state][name];
  }
  console.log(`${ state } + ${ name } --> ${ machine.currentState }`);
}

我们获得了当前状态并且检查提供的input是否合法,如果通过检查,我们就改变当前的状态,或者换句话说,将状态机过渡到一个新的状态。我们提供了一个日志输出用来输入、当前状态和新的状态(如果有变化的话)。下面是如何去使用我们的状态机:

input('tryAgain');
// login form + tryAgain --> login form

input('submit');
// login form + submit --> loading

input('submit');
// loading + submit --> loading

input('failure');
// loading + failure --> error

input('submit');
// error + submit --> error

input('tryAgain');
// error + tryAgain --> loading

input('success');
// loading + success --> profile

input('viewProfile');
// profile + viewProfile --> profile

input('logout');
// profile + logout --> login form

注意我们尝试通过在 login form 状态的时候发送 tryAgain 状态来打破状态机的运转或者是重复发送提交请求。在这些场景下,当前的状态没有被改变并且状态机会忽略这些输入。

最后的话

我不知道状态机的概念是否适用于你自己的场景,但是对我来说非常适用。我仅仅改变了我处理状态管理的方式。我建议去尝试一下,绝对是值得的。