使用有限状态机构建健壮的 React 用户界面

Avatar of David Khourshid
David Khourshid 发布

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

用户界面可以通过两件事来表达

  1. UI 的状态
  2. 可以改变该状态的操作

从信用卡支付设备和加油泵屏幕到公司创建的软件,用户界面都会对用户的操作和其他来源做出反应,并相应地改变其状态。这个概念不仅限于技术,它是所有事物运作方式的一个基本组成部分。

对于每一个作用力,都有一个大小相等、方向相反的反作用力。

– 艾萨克·牛顿

这是一个我们可以应用于开发更好用户界面的概念,但在我们进入这个话题之前,我想让你尝试一些事情。考虑一个具有以下用户交互流程的照片库界面

  1. 显示一个搜索输入框和一个搜索按钮,允许用户搜索照片
  2. 点击搜索按钮时,从 Flickr 获取包含搜索词的照片
  3. 以小尺寸照片网格的形式显示搜索结果
  4. 点击/轻触照片时,显示全尺寸照片
  5. 再次点击/轻触全尺寸照片时,返回到图库视图

现在想想你将如何开发它。甚至可以尝试在 React 中编程实现它。我会等一下;我只是篇文章。我不会去任何地方。

完成了吗?太棒了!这并不太难,对吧?现在想想你可能忘记的以下场景

  • 如果用户反复点击搜索按钮会发生什么?
  • 如果用户想在搜索进行中取消搜索怎么办?
  • 搜索时搜索按钮是否禁用?
  • 如果用户恶作剧地启用禁用的按钮会发生什么?
  • 是否有任何迹象表明结果正在加载?
  • 如果出现错误会发生什么?用户可以重试搜索吗?
  • 如果用户搜索然后点击照片会发生什么?应该发生什么?

这些只是在规划、开发或测试期间可能出现的一些问题。在软件开发中,没有什么比认为你已经涵盖了所有可能的用例,然后发现(或收到)新的边缘情况更糟糕的了,这些边缘情况会在你考虑它们后进一步使你的代码复杂化。尤其是在进入一个所有这些用例都没有记录的预先存在的项目时,这尤其困难,但这些用例却隐藏在意大利面条代码中,留待你破译。

显而易见的事情

如果我们能够确定所有可能的操作在每个状态上执行后可能产生的所有可能的 UI 状态?如果我们可以将这些状态、操作和状态之间的转换可视化?设计师直观地做到这一点,在所谓的“用户流程”(或“UX 流程”)中,来描述根据用户交互 UI 的下一个状态应该是什么。

图片来源:简化的结账流程 作者:Michael Pons

在计算机科学术语中,有一种称为有限自动机或“有限状态机”(FSM)的计算模型,可以表达相同类型的信息。也就是说,它们描述了当在当前状态上执行操作时,下一个状态是什么。就像用户流程一样,这些有限状态机可以以清晰明了的方式进行可视化。例如,以下是描述交通灯 FSM 的状态转换图

什么是有限状态机?

状态机是模拟应用程序行为的一种有用方法:对于每个操作,都会以状态更改的形式产生一个反应。经典有限状态机有 5 个部分

  1. 一组状态(例如,idleloadingsuccesserror 等)
  2. 一组操作(例如,SEARCHCANCELSELECT_PHOTO 等)
  3. 初始状态(例如,idle
  4. 转换函数(例如,transition('idle', 'SEARCH') == 'loading'
  5. 最终状态(不适用于本文。)

确定性有限状态机(我们将要处理的)也有一些约束

  • 可能的状态数量是有限的
  • 可能的操作数量是有限的(这些是“有限”的部分)
  • 应用程序一次只能处于这些状态中的一个
  • 给定一个currentState和一个action,转换函数必须始终返回相同的nextState(这是“确定性”部分)

表示有限状态机

有限状态机可以表示为从state到其“转换”的映射,其中每个转换都是一个action以及该操作之后的nextState。此映射只是一个普通的 JavaScript 对象。

让我们考虑一个美国交通灯的例子,这是最简单的 FSM 示例之一。假设我们从green开始,然后在一段时间TIMER后转换为yellow,然后在另一个TIMER后转换为RED,然后在另一个TIMER后返回到green

const machine = {
  green: { TIMER: 'yellow' },
  yellow: { TIMER: 'red' },
  red: { TIMER: 'green' }
};
const initialState = 'green';

**转换函数**回答以下问题

给定当前状态和操作,下一个状态是什么?

根据我们的设置,基于操作(在本例中为TIMER)转换到下一个状态只是在machine对象中查找currentStateaction,因为

  • machine[currentState]为我们提供了下一个操作映射,例如:machine['green'] == {TIMER: 'yellow'}
  • machine[currentState][action]从操作中为我们提供了下一个状态,例如:machine['green']['TIMER'] == 'yellow'
// ...
function transition(currentState, action) {
  return machine[currentState][action];
}

transition('green', 'TIMER');
// => 'yellow'

我们不再使用if/elseswitch语句来确定下一个状态,例如if (currentState === 'green') return 'yellow';,而是将所有这些逻辑移到一个可以序列化为 JSON 的普通 JavaScript 对象中。这是一种在测试、可视化、重用、分析、灵活性和可配置性方面将带来巨大回报的策略。

查看 CodePen 上 David Khourshid (@davidkpiano) 的笔:简单的有限状态机示例

React 中的有限状态机

让我们来看一个更复杂的示例,看看我们如何使用有限状态机来表示我们的图库应用程序。应用程序可以处于以下几种状态之一

  • start – 初始搜索页面视图
  • loading – 搜索结果获取视图
  • error – 搜索失败视图
  • gallery – 搜索结果成功视图
  • photo – 详细的单张照片视图

并且可以执行几个操作,无论是用户还是应用程序本身

  • SEARCH – 用户点击“搜索”按钮
  • SEARCH_SUCCESS – 搜索成功,获取到查询的照片
  • SEARCH_FAILURE – 由于错误导致搜索失败
  • CANCEL_SEARCH – 用户点击“取消搜索”按钮
  • SELECT_PHOTO – 用户点击图库中的照片
  • EXIT_PHOTO – 用户点击退出详细照片视图

首先,可视化这些状态和操作如何结合在一起的最佳方法是使用两种非常强大的工具:铅笔和纸。在状态之间画箭头,并用导致状态之间转换的操作标记箭头

现在我们可以像交通灯示例中一样,将这些转换表示为一个对象

const galleryMachine = {
  start: {
    SEARCH: 'loading'
  },
  loading: {
    SEARCH_SUCCESS: 'gallery',
    SEARCH_FAILURE: 'error',
    CANCEL_SEARCH: 'gallery'
  },
  error: {
    SEARCH: 'loading'
  },
  gallery: {
    SEARCH: 'loading',
    SELECT_PHOTO: 'photo'
  },
  photo: {
    EXIT_PHOTO: 'gallery'
  }
};

const initialState = 'start';

现在让我们看看如何将这个有限状态机配置和转换函数合并到我们的图库应用程序中。在App组件的状态中,将有一个属性指示当前的有限状态,gallery

class App extends React.Component {
  constructor(props) {
    super(props);

    this.state = {
      gallery: 'start', // initial finite state
      query: '',
      items: []
    };
  }
  // ...

transition函数将是此App类的另一个方法,以便我们可以检索当前的有限状态

  // ...
  transition(action) {
    const currentGalleryState = this.state.gallery;
    const nextGalleryState =
      galleryMachine[currentGalleryState][action.type];

    if (nextGalleryState) {
      const nextState = this.command(nextGalleryState, action);

      this.setState({
        gallery: nextGalleryState,
        ...nextState // extended state
      });
    }
  }
  // ...

这看起来类似于前面描述的transition(currentState, action)函数,但有一些区别

  • action是一个具有type属性的对象,该属性指定字符串操作类型,例如type: 'SEARCH'
  • 只传递了action,因为我们可以从this.state.gallery检索当前的有限状态
  • 整个应用程序状态将使用下一个有限状态(即nextGalleryState)以及执行基于下一个状态和操作负载的命令所产生的任何扩展状态nextState)进行更新(请参阅“执行命令”部分)

执行命令

当状态发生变化时,可能会执行“副作用”(或我们将其称为“命令”)。例如,当用户点击“搜索”按钮并发出'SEARCH'操作时,状态将转换为'loading',并且应该执行异步 Flickr 搜索(否则,'loading'将是谎言,开发人员永远不应该说谎)。

我们可以在command(nextState, action)方法中处理这些副作用,该方法根据下一个有限状态和操作负载确定要执行的操作,以及扩展状态应该是什么

  // ...
  command(nextState, action) {
    switch (nextState) {
      case 'loading':
        // execute the search command
        this.search(action.query);
        break;
      case 'gallery':
        if (action.items) {
          // update the state with the found items
          return { items: action.items };
        }
        break;
      case 'photo':
        if (action.item) {
          // update the state with the selected photo item
          return { photo: action.item };
        }
        break;
      default:
        break;
    }
  }
  // ...

操作可以具有除操作type之外的负载,应用程序状态可能需要使用它进行更新。例如,当'SEARCH'操作成功时,可以发出一个带有搜索结果中的items'SEARCH_SUCCESS'操作

    // ...
    fetchJsonp(
      `https://api.flickr.com/services/feeds/photos_public.gne?lang=en-us&format=json&tags=${encodedQuery}`,
      { jsonpCallback: 'jsoncallback' })
      .then(res => res.json())
      .then(data => {
        this.transition({ type: 'SEARCH_SUCCESS', items: data.items });
      })
      .catch(error => {
        this.transition({ type: 'SEARCH_FAILURE' });
      });
    // ...

上面的 command() 方法会立即返回任何扩展状态(即,除了有限状态之外的状态),this.state 应该在 this.setState(...) 中使用它进行更新,以及有限状态的变化。

最终的机器控制应用程序

由于我们已经声明性地配置了应用程序的有限状态机,因此我们可以通过根据当前有限状态有条件地渲染来更简洁地渲染正确的 UI。

  // ...
  render() {
    const galleryState = this.state.gallery;

    return (
      <div className="ui-app" data-state={galleryState}>
        {this.renderForm(galleryState)}
        {this.renderGallery(galleryState)}
        {this.renderPhoto(galleryState)}
      </div>
    );
  }
  // ...

最终结果

查看 CodePen 上 David Khourshid(@davidkpiano)的示例 使用有限状态机的画廊应用程序

CSS 中的有限状态

您可能已经注意到上面代码中的 data-state={galleryState}。通过设置该 data 属性,我们可以使用属性选择器有条件地为应用程序的任何部分设置样式。

.ui-app {
  // ...
  
  &[data-state="start"] {
    justify-content: center;
  }
  
  &[data-state="loading"] {
    .ui-item {
      opacity: .5;
    }
  }
}

这比使用 className 更可取,因为您可以强制执行以下约束:一次只能为 data-state 设置一个值,并且特异性与使用类相同。大多数流行的 CSS-in-JS 解决方案也支持属性选择器。

优势和资源

使用有限状态机来描述复杂应用程序的行为并不是什么新鲜事。传统上,这是使用 switchgoto 语句完成的,但是通过将有限状态机描述为状态、动作和下一个状态之间的声明性映射,您可以使用这些数据来可视化状态转换。

Gallery app state transition diagram

此外,使用声明性有限状态机允许您

  • 在任何地方存储、共享和配置应用程序逻辑 - 类似的组件、其他应用程序、数据库、其他语言等。
  • 使与设计师和项目经理的协作更容易。
  • 静态分析和优化状态转换,包括无法到达的状态。
  • 轻松更改应用程序逻辑,无需担心。
  • 自动化集成测试。

结论和要点

有限状态机是用于对应用程序中可以表示为有限状态的部分进行建模的抽象,并且几乎所有应用程序都有这些部分。本文中介绍的 FSM 编码模式

  • 可以与任何现有的状态管理设置一起使用;例如,ReduxMobX
  • 可以适应任何框架(不仅仅是 React),或者根本不使用框架。
  • 不是一成不变的;开发人员可以根据自己的编码风格调整这些模式。
  • 并非适用于所有情况或用例。

从现在开始,当您遇到诸如 isLoadedisSuccess 之类的“布尔标志”变量时,我鼓励您停下来思考您的应用程序状态如何可以建模为有限状态机。这样,您可以重构您的应用程序以将状态表示为 state === 'loaded'state === 'success',使用枚举状态代替布尔标志。

资源

如果您想了解有关动机和原理的更多信息,我在 2017 年 React Rally 上做了一个关于使用有限自动机和状态图创建更好用户界面的演讲。

幻灯片:使用有限自动机构建无限更好的 UI

以下是一些其他资源