如何在 React 中使用有限状态机?

1,726 阅读10分钟

在 React 中使用有限状态机,似乎不是一个寻常的话题。因为有限状态机通常和前端关系不大。 但是最近我发现了一个非常棒的技巧,可以在复杂的 React 项目中发挥有限状态机的作用。可以很好的提高程序的安全性。 下面我们就来看看吧。

什么是有限状态机?

有限状态机,英文是 Finite State Machine,简称 FSM,有时候也被称为有限状态自动机(Finite State AutoMation)。它是一种描述系统行为的数学计算模型,也就是一种抽象机。它可以替代图灵机等其他类型的模型。 有限状态机由给定对象的所有可能状态以及它们之间的转换组成。和图灵机相比,它的计算能力较低。这种计算能力的区别意味着 FSM 的计算能力更加有限,因为它具有有限数量的状态,不如的话就是无限状态机了。 更重要的是:状态机的一条规则是:它在任何时候都只处于一种状态。 由于有限状态机会根据适当的组合或预定的时间顺序产生某些动作,因此我们可以在现代社会的任何地方找到有限状态机的影子。这些包括自动售货机、电梯,甚至是红绿灯。

有限状态机的示例

一个现实中完美匹配有限状态机的例子是红绿灯。我们来分析一下红绿灯的工作方式: 它有四种状态:

  1. 停车-红灯。
  2. 准备开车-红灯和黄灯。
  3. 开车-绿灯。
  4. 准备停车-黄灯。

它有四种状态的转换:

  1. 停车->准备开车。
  2. 准备开车->开车
  3. 开车->准备停车。
  4. 准备停车->停车。

我们可以看到,我们有有限数量的状态和状态的转换。另外,红绿灯在任何时候都只能处于一种状态。这意味着我们在这处理的是有限状态机。 更重要的是,通过实现有限状态机,我们可以保证,模型不会发生意外。以红绿灯为例,红绿灯绝对不会出现直接从绿灯转换成红灯的情况。

有限状态机和软件开发、计算机科学有什么关系?

其实有很多关系。特别是游戏开发,很多游戏中都会大量使用有限状态机。 举个例子,大家应该都玩过超级马里奥这款 2D 游戏。马里奥在游戏里可以做什么呢? 他可以: 静止、朝右走、朝左走、跳跃。 从代码的角度来看,它对应的就是摇杆事件。

  • 什么都不按-默认设置静止状态。
  • 按左键-触发设置朝左走状态的朝左走事件。
  • 按右键-触发设置朝右走状态的朝右走事件。
  • 按跳跃键-触发设置跳跃状态的跳跃事件。
  • 松开按键-触发设置静止状态的静止事件。

举了那么多例子,但我该怎么展示在前端开发中使用状态机呢?

无论是从上面的概念还是具体的场景,我都是想保证你对有限状态机有一个了解。 接下来我来讲讲有限状态机在前端的应用场景。 首先我得承认,在前端开发中有限状态机并不是那么常见。我认为这个现象的主要原因是因为它不是实现功能最简单也不是最快的方法。 这有点像 TypeScript,它会让你慢一点,它会带来一些复杂性。但最终每个人都会从中受益。 为了证明我的这种观点并非毫无根据,我会展示一个我曾经开发的 React 项目中使用有限状态机的示例。 这是一个很简单的注册表单,分为三个部分。每个部分都会根据当前填写的进度进行渲染。 finite-state-machines-classic-form-602x375.png

React 应用程序中的注册表单的传统实现方式

我先快速演示一下我实现上述表单功能的方法。 首先,我要定义所有的组件以及初始状态。

const Step = {
  Company: 0,
  Director: 1,
  Contact: 2,
} as const;

const Views = [<CompanyDataFormPart />, <DirectorDataFormPart />, <ContactDataFormPart />];

const initialStep = Step.Account

接下来我们定义状态:

const [currentStep, setCurrentStep] = useState<number>(initialStep)

最后是组件本身:

<>
  <div className="stepsContainer">
    <Steps current={currentStep} labelPlacement="vertical" size="small">
      {Object.keys(Step).map(s => (
        <Steps.Step title={s} />
      ))}
    </Steps>
  </div>

  <Spacer />

  <FormPart
    onPrevious={() => {
      setCurrentStep(prev => prev - 1);
    }}
    onNext={() => {
      setCurrentStep(prev => prev + 1);
    }}
      >
    {Views[currentStep]}
  </FormPart>
</>

这一切看上去似乎很正常,表单可以切换到下一个和上一个步骤。但这里存在一个很明显的错误。 那就是程序没有考虑边界问题。这意味着 currentStep 的值可能超过最大步骤,也就是 2,也可能低于 0。 如果要修复它,我们会写出下列代码:

onPrevious={() => {
  setCurrentStep(prev => Math.max(prev - 1, 0))
}}
onNext={()=>{
  setCurrentStep(prev => Math.min(prev + 1, Views.length - 1))
}}

还有其他风险

这个代码运行起来确实没有问题,但还是会有一些潜在风险。 在软件开发中,很少会出现你一个人负责整个项目的情况,一般来说是一整个团队在协作,这也就意味着有许多其他开发人员会检查你的代码,并且会视图理解它并可能会修改它。 我们假设有个人在表单顶部写了一个方法,直接跳到了第三步。

const functionWithBadLogic = () => {
  setCurrentStep(3);
}

这是一个很好的反面教材,第三步在我们的表单中压根就不存在。 另外一个例子是下面这样的:

onNext={() => {
  setCurrentStep(prev => Math.min(prev + 2, Views.length -1))
}}

在这个代码中有什么问题吗?如果给定顺序中需要所有的步骤,为什么会有人跳过一个步骤? 这是最后一个例子:

const Step = {
  Company: 0,
  Director: 1,
  Contact: 2,
} as const;

const Views = [
  <CompanyDataFormPart />,
  <DirectorDataFormPart />,
  <ContactDataFormPart />,
  <div>I should not be there!</div>
]

这些错误中的任何一个投入生产都可能会出现下面这种情况: finite-state-machines-classic-form-broken-313x192.png

这似乎看上去不是什么大问题

也许确实不是什么大问题。但是你要知道,我的例子是很简单的一个流程。在真实的项目中,会有更大更复杂的恶项目,例如用于银行和汇款的金融类程序,用于审批和工作流的办公类程序。 如果我们在一个地方定义所有可能出现的状态和状态之间的转换,会不会更容易?类似于某种约定,我们可以很容易的查看其中的整个逻辑,并且确保不会发生其他任何约定之外的事情。 实现这种模式的那个东西就是有限状态机。

把表单重构为有限状态机

首先,我们先来只关注 onNext 和 onPrevious 函数。我们想要制造一台机器,我们用下面的状态和事件来描述它的行为,也就是为这台机器的特性设计一个模型。

状态

  1. company
  2. director
  3. contact

事件

  1. next:按顺序切换到下一个状态。
  2. prev:按顺序切换到上一个状态。

实现起来像下面这样:

const formMachine = createMachine({
  id: 'formState',
  initial: 'company',
  states: {
    company: {
      on: {
        next: { target: 'director' }
      }
    },
    director: {
      on: {
        previous: { target: 'company' },
        next: { target: 'contact' },
      },
    },
    contact: {
      on: {
        previous: { target: 'director' },
      },
    },
  }
})

现在让我们来分析一下这段代码。 createMachine 方法接受由 id、initial 和 states 共同组成的对象,这三个字端的作用分别是:

  • id:唯一标识符。
  • initial:初始状态
  • states:所有状态,其中的键是状态的名称,值是描述状态的对象。

接下来我们再分析一下 director 这个状态:

  • 它有一个名字,叫做 director。
  • 它可以对两个事件做出反应:previous 事件,将状态转换为 company。next 事件,将状态设置为 contacct。

使用 xstate 将有限状态机可视化

感谢 xstate 的开发人员,我们可以将上面的代码粘贴到 xstate 的在线可视化编辑器中。这个工具可以展示出有限状态机的所有可能的状态和事件。 我们的状态机是这个样子: image.png 我承认为了实现这样一个简单的小功能编写这么多代码似乎有些过度设计,但是我们继续往下看,我保证你会相信使用有限状态机是值得的。

通过 9 个步骤完成重构

我们实现了有限状态机,但是我们还没有做最重要的事情。我们必须重构渲染逻辑。 接下来我要为有限状态机实现一些上下文。

步骤1: 为上下文添加类型定义

type Context = {
  currentView: ReactNode;
}

步骤2: 添加将状态映射到组件的函数

const mapStateToComponent: Record<string, ReactNode> = {
  company: <CompanyDataFormPart />,
  director: <DirectorDataFormPart />,
  contact: <ContactDataFormPart />,
}

步骤3: 将上下文添加到有限状态机的定义中

context: {
  currentView: <CompanyDataFormPart />,
}

步骤4: 定义一个将改变上下文的函数

const changeComponent = assign<Context>({
  currentViewe: (context, event, { action }) => {
    return mapStateToComponent[action.payload as string];
  }
})

步骤5: 将这个函数添加到有限状态机的 actions 中

{
  actions: {
    changeComponent,
  }
}

步骤6: 将由 previous 和 next 事件触发这个操作

{
  director: {
    on: {
      previous: {
        target: 'company',
        actions: {
          type: 'changeComponent',
          payload: 'company'
        }
      },
      next: {
        target: 'contact',
          actions: {
            type: 'changeComponent',
            payload: 'contact'
          }
        }
    }
  }
}

步骤7: 向组件添加 useMachine Hook

const [current, send] = useMachine(formMachine)

步骤8: 通过 onPrevious 和 onNext 函数将事件发送到有限状态机

onPrevious={() => {
  send('previous');
}}
onNext={() => {
  send('next');
}}

步骤9: 渲染当前状态对应的组件

{current.context.currentView}

我们马上就要完成了!

有限状态机的安全性,使得我们的表单同样安全

我们再回来看看之前举的最后一个反例。

const Step = {
  Company: 0,
  Director: 1,
  Contact: 2,
} as const;

const Views = [
  <CompanyDataFormPart />,
  <DirectorDataFormPart />,
  <ContactDataFormPart />,
  <div>I should not be there!</div>
]

可以看到,Step 和 Views 是解耦的。我们通过逐步渲染分页进度面板的值来进行映射,并且使用当前索引来渲染 Views 数组中的元素。 在我们的有限状态机中如何用更好的方式来实现这一点? 我们首先来稍微改变一下上下文。

export type View = {
  Component: ReactNode;
  step: number;
}

export type Context = {
  currentView: View;
}

接下来修改一下 mapStateToComponent 这个函数,顺便把函数名也改掉。

const mapStateToView: Record<string, View> = {
  company: {
    Component: <CompanyDataFormPart />,
    step: 0,
  },
  director: {
    Component: <DirectorDataFormPart />,
    step: 1,
  },
  contact: {
    Component: <ContactDataFormPart />,
    step: 2,
  },
};

最后为我们的有限状态机添加一些类型,将类型和 actions 移到不同的文件里。 现在我们的代码像下面这样: formMachine.types.ts

import { ReactNode } from 'react';
import { StateNode } from 'xstate';

export type Event = { type: 'NEXT' } | { type: 'PREVIOUS' };

export type View = {
  Component: ReactNode;
  step: number;
};

export type Context = {
  currentView: View;
};

export type State = {
  states: {
    company: StateNode;
    director: StateNode;
    contact: StateNode;
  };
};

formMachine.actions.ts

import { assign } from 'xstate';

import { CompanyDataFormPart } from '../components/CompanyDataFormPart/CompanyDataFormPart';
import { ContactDataFormPart } from '../components/ContactDataFormPart/ContactDataFormPart';
import { DirectorDataFormPart } from '../components/DirectorDataFormPartt/ContactDataFormPart';

import { Context, View } from './formMachine.types';

export const mapNameToView: Record<string, View> = {
  company: {
    Component: <CompanyDataFormPart />,
    step: 0,
  },
  director: {
    Component: <DirectorDataFormPart />,
    step: 1,
  },
  contact: {
    Component: <ContactDataFormPart />,
    step: 2,
  },
};

export const changeView = assign<Context, Event>({
  currentView: (_context, _event, { action }) => {
    if (typeof action.payload !== 'string') {
      throw new Error('Action payload should be string');
    }

    return mapNameToView[action.payload];
  },
});

formMachine.ts

import { MachineConfig, MachineOptions, createMachine } from 'xstate';

import { mapNameToView, changeView } from './formMachine.actions';
import { State, Context } from './formMachine.types';

const initialStateName = 'company';

const formMachineConfig: MachineConfig<Context, State, Event> = {
  id: 'formState',
  initial: initialStateName,
  context: {
    currentView: mapNameToView[initialStateName],
  },
  states: {
    company: {
      on: {
        NEXT: { target: 'director', actions: { type: 'changeView', payload: 'director' } },
      },
    },
    director: {
      on: {
        PREVIOUS: { target: 'company', actions: { type: 'changeView', payload: 'company' } },
        NEXT: { target: 'contact', actions: { type: 'changeView', payload: 'contact' } },
      },
    },
    contact: {
      on: {
        PREVIOUS: { target: 'director', actions: { type: 'changeView', payload: 'director' } },
      },
    },
  },
};

const formMachineOptions: Partial<MachineOptions<Context, Event>> = {
  actions: { changeView },
};

export const formMachine = createMachine(formMachineConfig, formMachineOptions);

export const formMachineStates = Object.keys(formMachine.states);

App.tsx

import React from 'react';
import { useMachine } from '@xstate/react';
import Steps from 'rc-steps';

import { FormPart } from './components/FormPart/FormPart';
import { Spacer } from './components/Spacer/Spacer';
import { formMachine, formMachineStates } from './formMachine/formMachine';

function App() {
  const [current, send] = useMachine(formMachine);

  return (
    <div className="app">
      <div className="stepsContainer">
        <Steps current={current.context.currentView.step} labelPlacement="vertical" size="small">
          {formMachineStates.map(s => (
            <Steps.Step title={s} key={s} />
          ))}
        </Steps>
      </div>

      <Spacer />

      <FormPart
        onPrevious={() => {
          send('PREVIOUS');
        }}
        onNext={() => {
          send('NEXT');
        }}
      >
        {current.context.currentView.Component}
      </FormPart>
    </div>
  );
}

在 React 中使用有限状态机的概括

可能你会说,“它看起来仍然很复杂。”。但是请你记住,如果你正在为一家大公司研发一个非常重要的项目,那其中一点微小的错误都可能会导致非常严重的资金损失。 最后我总结一下使用有限状态机的几个优势:

  1. 类型安全。我们永远都不会使用在类型定义中的状态以外的状态,否则会编译错误。
  2. 不会有错误的状态和错误的转换。如果不改变有限状态机的定义,那么不可能有人能够做到从第1步直接跳转到第3步。
  3. 所有的逻辑都在一个位置进行描述。