使用 React 的有限状态机

Avatar of Jon Bellah
Jon Bellah

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

随着 Web 上 JavaScript 应用程序变得越来越复杂,处理这些应用程序中状态的复杂性也随之增加——状态是指应用程序执行其功能所需的所有数据的集合。在过去的几年里,状态 *管理* 领域出现了大量优秀的创新,例如 Redux、MobX 和 Vuex 等工具。然而,状态 *设计* 却并没有得到足够的关注。

我所说的状态设计到底是什么意思?

让我们稍微设置一下场景。过去,在构建需要从后端服务获取一些数据并将其显示给用户的应用程序时,我将状态设计为使用布尔标志来表示各种内容,例如 isLoadingisSuccessisError,等等。但是,随着这些布尔标志数量的增加,应用程序可能具有的状态数量呈指数级增长——从而大大增加了用户遇到意外或错误状态的可能性。

为了解决这个问题,我过去几个月一直在探索使用有限状态机来更好地 *设计* 应用程序的状态。

有限状态机是一种计算的数学模型,最初开发于 20 世纪 40 年代初期,几十年来一直被用于构建各种技术的硬件和软件。

有限状态机可以定义为任何抽象机器,在给定时间正好处于有限数量的状态之一。不过,从更实际的角度来看,状态机以状态列表为特征,每个状态定义一组有限的、确定性的状态,可以通过给定的动作转换到这些状态。

由于这种有限且确定的特性,我们可以使用状态图来可视化我们的应用程序——在构建之前或之后。

例如,如果我们想可视化身份验证工作流程,我们可以为应用程序中的用户定义三个主要状态:已登录、已注销或加载中。

一个状态图,显示应用程序从已注销状态到加载状态,再到已登录状态。

由于状态机的可预测性,它们在可靠性至关重要的应用程序中尤其受欢迎——例如航空软件、制造业,甚至 NASA 太空发射系统。它们也是游戏开发社区几十年来的支柱。

在本文中,我们将解决构建大多数 Web 应用程序使用的功能:身份验证。我们将使用上面的状态图来指导我们。

但是,在我们开始之前,让我们先熟悉一下我们将用于构建此应用程序的一些库和 API。

React Context API

React 16.3 引入了一个新的、稳定的 Context API 版本。如果您过去使用过 React,您可能熟悉数据如何通过 props 从父组件传递到子组件。当某些数据需要各种组件时,您最终可能会进行所谓的 属性穿透——通过组件树的多个级别传递数据,以将数据传递到需要它的组件。

Context 通过提供一种在组件之间共享数据的方式来缓解属性穿透的痛苦,而无需通过组件树显式传递该数据,这使其非常适合存储身份验证数据。

当我们创建上下文时,会得到一个 ProviderConsumer 对。提供者将充当包含状态机定义的“智能”状态组件,并维护应用程序当前状态的记录。

xstate

xstate 是一个用于函数式、无状态有限状态机和状态图的 JavaScript 库——它将为我们提供一个简洁的 API 来管理状态的定义和转换。

一个 *无状态* 有限状态机库听起来可能有点奇怪,但本质上这意味着 xstate 只关心您传递给它的状态和转换——这意味着由您的应用程序来跟踪其自己的 *当前* 状态。

xstate 还有很多值得一提的功能,我们在这篇文章中不会过多介绍(因为我们只会触及 状态图 的表面):分层机器、并行机器、历史状态和守卫,仅举几例。

方法

所以,现在我们已经对 Context 和 xstate 有了一些了解,让我们来谈谈我们将采用的方法。

我们将首先为应用程序定义上下文,然后创建一个有状态的 <App /> 组件(我们的提供者),该组件将包含我们的身份验证状态机,以及有关当前用户的信息和用户注销的方法。

为了稍微设置一下场景,让我们快速浏览一下我们将构建内容的 CodePen 演示。

查看 Jon Bellah 编写的 Pen 身份验证状态机示例 (@jonbellah) 在 CodePen 上。

因此,事不宜迟,让我们深入研究一些代码!

定义我们的上下文

我们需要做的第一件事是定义我们的应用程序上下文并使用一些默认值对其进行设置。上下文中的默认值有助于我们隔离地测试组件,因为仅当没有匹配的提供者时才会使用默认值。

对于我们的应用程序,我们将设置一些默认值:authState,它将是当前用户的身份验证状态,一个名为 user 的对象,如果用户已认证,则包含有关用户的数据,然后是一个 logout() 方法,如果用户已认证,则可以在应用程序的任何位置调用该方法。

const Auth = React.createContext({
  authState: 'login',
  logout: () => {},
  user: {},
});

定义我们的机器

当我们考虑身份验证在应用程序中的行为方式时,在其最简单的形式中,有三个主要状态:已注销、已登录和加载中。这些是我们之前绘制的三个状态。

回顾该状态图,我们的机器包含相同的三种状态:已注销、已登录和加载中。我们还有四种不同的可以触发的动作类型:SUBMITSUCCESSFAILLOGOUT

我们可以像这样在代码中建模该行为

const appMachine = Machine({
  initial: 'loggedOut',
  states: {
    loggedOut: {
      onEntry: ['error'],
      on: {
        SUBMIT: 'loading',
      },
    },
    loading: {
      on: {
        SUCCESS: 'loggedIn',
        FAIL: 'loggedOut',
      },
    },
    loggedIn: {
      onEntry: ['setUser'],
      onExit: ['unsetUser'],
      on: {
        LOGOUT: 'loggedOut',
      },
    },
  },
});

因此,我们只是在代码中表达了之前的图表,但您准备好让我告诉您一个小秘密了吗?该图是使用 David Khourshidxviz 库 *从这段代码* 生成的——它可以用于直观地探索为状态机提供支持的 *实际* 代码。

如果您有兴趣使用有限状态机深入研究复杂的用户界面,David Khourshid 在 CSS-Tricks 上有一篇相关的文章 值得一读。

在尝试调试应用程序中出现的问题状态时,这可能是一个非常强大的工具。

现在回到上面的代码,我们定义了初始应用程序状态——我们将其称为 loggedOut,因为我们希望在初始访问时显示登录屏幕。

请注意,在典型的应用程序中,您可能希望从加载状态开始,并确定用户是否以前已认证……但是由于我们正在模拟登录过程,因此我们从已注销状态开始。

states 对象中,我们定义了每个状态以及每个状态对应的动作和转换。然后,我们将所有这些作为对象传递给 Machine() 函数,该函数是从 xstate 导入的。

除了 loggedOutloggedIn 状态,我们还定义了一些在应用程序进入或退出这些状态时想要触发的动作。我们稍后会看看这些动作的作用。

这就是我们的状态机。

为了再次分解一下,让我们看看loggedOut: { on: { SUBMIT: 'loading'} }这一行。这意味着如果我们的应用程序处于loggedOut状态,并且我们使用SUBMIT操作调用转换函数,我们的应用程序将始终从loggedOut状态转换到loading状态。我们可以通过调用appMachine.transition('loggedOut', 'SUBMIT')来进行此转换。

从那里,loading状态要么将用户作为已认证用户继续操作,要么将他们送回登录屏幕并显示错误消息。

创建我们的上下文提供者

上下文提供者将是位于应用程序顶层的组件,它包含与已认证或未认证用户相关的所有数据。

在与状态机定义相同的文件中,让我们创建一个<App />组件并为其设置所需的一切。不用担心,我们很快就会介绍每个方法的作用。

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      authState: appMachine.initialState.value,
      error: '',
      logout: e => this.logout(e),
      user: {},
    };
  }

  transition(event) {
    const nextAuthState = appMachine.transition(this.state.authState, event.type);
    const nextState = nextAuthState.actions.reduce(
      (state, action) => this.command(action, event) || state,
      undefined,
    );
    this.setState({
      authState: nextAuthState.value,
      ...nextState,
    });
  }

  command(action, event) {
    switch (action) {
      case 'setUser':
        if (event.username) {
          return { user: { name: event.username } };
        }
        break;
      case 'unsetUser':
        return {
          user: {},
        };
      case 'error':
        if (event.error) {
          return {
            error: event.error,
          };
        }
        break;
      default:
        break;
    }
  }

  logout(e) {
    e.preventDefault();
    this.transition({ type: 'LOGOUT' });
  }

  render() {
    return (
      <Auth.Provider value={this.state}>
        <div className="w5">
          <div className="mb2">{this.state.error}</div>
          {this.state.authState === 'loggedIn' ? (
            <Dashboard />
          ) : (
            <Login transition={event => this.transition(event)} />
          )}
        </div>
      </Auth.Provider>
    );
  }
}

哇,代码太多了!让我们将其分解成可管理的块,逐个查看此类的每个方法。

constructor()中,我们将组件状态设置为appMachine的初始状态,并将我们的logout函数设置为状态,以便它可以通过我们的应用程序上下文传递给任何需要它的使用者。

transition()方法中,我们做了一些重要的事情。首先,我们将当前应用程序状态和事件类型或操作传递给xstate,以便我们可以确定我们的下一个状态。然后,在nextState中,我们获取与该下一个状态关联的任何操作(这将是我们onEntryonExit操作之一),并将其通过command()方法运行 - 然后我们获取所有结果并设置我们的新应用程序状态。

command()方法中,我们有一个switch语句返回一个对象 - 根据操作类型 - 我们使用它将数据传递到我们的应用程序状态中。这样,一旦用户完成身份验证,我们就可以将有关该用户的相关详细信息(用户名、电子邮件、ID等)设置到我们的上下文中,使其可用于我们的任何使用者组件。

最后,在我们的render()方法中,我们实际上是在定义我们的提供者组件,然后通过value属性传递我们当前的所有状态,这使得状态可用于组件树中它下方的所有组件。然后,根据应用程序的状态,我们为用户呈现仪表板或登录表单。

在这种情况下,我们在提供者(Auth.Provider)下方有一个非常扁平的组件树,但请记住,上下文允许该值可用于提供者下方组件树中的任何组件,无论深度如何。因此,例如,如果我们在嵌套了三层或四层的组件中,并且想要显示当前用户名,我们可以直接从上下文中获取它,而不是一直向下传递到该组件。

创建上下文使用者

现在,让我们创建一些使用应用程序上下文的组件。从这些组件中,我们可以做各种事情。

我们可以从为我们的应用程序构建一个登录组件开始。

class Login extends Component {
  constructor(props) {
    super(props);
    this.state = {
      yourName: '',
    }
    this.handleInput = this.handleInput.bind(this);
  }

  handleInput(e) {
    this.setState({
      yourName: e.target.value,
    });
  }

  login(e) {
    e.preventDefault();
    this.props.transition({ type: 'SUBMIT' });
    setTimeout(() => {
      if (this.state.yourName) {
        return this.props.transition({
          type: 'SUCCESS',
          username: this.state.yourName,
        }, () => {
          this.setState({ username: '' });
        });
      }
      return this.props.transition({
        type: 'FAIL',
        error: 'Uh oh, you must enter your name!',
      });
    }, 2000);
  }

  render() {
    return (
      <Auth.Consumer>
        {({ authState }) => (
          <form onSubmit={e => this.login(e)}>
            <label htmlFor="yourName">
              <span>Your name</span>
              <input
                id="yourName"
                name="yourName"
                type="text"
                value={this.state.yourName}
                onChange={this.handleInput}
              />
            </label>
            <input
              type="submit"
              value={authState === 'loading' ? 'Logging in...' : 'Login' }
              disabled={authState === 'loading' ? true : false}
            />
          </form>
        )}
      </Auth.Consumer>
    );
  }
}

哦,我的!那又是很大一部分代码,所以让我们再次遍历每个方法。

constructor()中,我们声明了默认状态并绑定了handleInput()方法,以便它在内部引用正确的this

handleInput()中,我们从render()方法中获取表单字段的值,并在状态中设置该值 - 这被称为受控表单

login()方法是您通常放置身份验证逻辑的地方。在本应用程序中,我们只是使用setTimeout()模拟延迟,如果用户提供了名称则对其进行身份验证,或者如果字段为空则返回错误。请注意,它调用的transition()函数实际上是我们定义在<App />组件中的函数,该函数已通过props传递下来。

最后,我们的render()方法显示我们的登录表单,但请注意,<Login />组件也是上下文使用者。我们使用authState上下文来确定是否以禁用、加载状态显示登录按钮。

从组件树深处使用上下文

现在我们已经处理了状态机的创建以及用户登录应用程序的方式,我们现在可以依赖于在<Dashboard />组件下嵌套的任何组件中获取有关该用户的信息 - 因为它只有在用户登录时才会呈现。

因此,让我们创建一个无状态组件,该组件获取当前已认证用户的用户名并显示欢迎消息。由于我们将logout()方法传递给我们的所有使用者,我们还可以让用户选择从组件树中的任何位置注销。

const Dashboard = () => (
  <Auth.Consumer>
    {({ user, logout }) => (
      <div>
        <div>Hello {user.name}</div>
        <button onClick={e => logout(e)}>
          Logout
        </button>
      </div>
    )}
  </Auth.Consumer>
);

使用状态图构建更大的应用程序

使用有限状态机与React并不局限于身份验证,也不局限于上下文API。

使用状态图,您可以拥有分层机器和/或并行机器 - 意味着各个React组件可以拥有自己的内部状态机,但仍然与应用程序的整体状态相关联。

在这篇文章中,我们主要关注使用xstate直接与原生上下文API;在较大的应用程序中,我强烈建议查看react-automata,它提供了xstate顶部的抽象薄层。react-automata还具有能够自动为您的组件生成Jest测试的额外优势。

状态机和状态管理工具并不相互排斥

很容易混淆,以为你必须使用xstateRedux;但重要的是要注意,状态机更多的是一个实现概念,关注的是你如何设计你的状态 - 不一定是如何管理它。

事实上,状态机可以与几乎任何不带意见的状态管理工具一起使用。我鼓励您探索各种方法,以确定最适合您、您的团队和您的应用程序的方法。

总结

这些概念可以扩展到现实世界,而无需重构整个应用程序。状态机是一个极好的重构目标 - 也就是说,下次您处理一个布满诸如isFetchingisError之类的布尔标志的组件时,请考虑将该组件重构为使用状态机。

作为一名前端开发人员,我发现我经常修复两类错误之一:显示相关问题或意外的应用程序状态。

状态机使第二类错误几乎消失。

如果您有兴趣更深入地了解状态机,我过去几个月一直在开发一个关于有限状态机的课程 - 如果您注册了电子邮件列表,您将在课程 8 月份推出时收到折扣码。