用户界面可以通过两件事来表达
- UI 的状态
- 可以改变该状态的操作
从信用卡支付设备和加油泵屏幕到公司创建的软件,用户界面都会对用户的操作和其他来源做出反应,并相应地改变其状态。这个概念不仅限于技术,它是所有事物运作方式的一个基本组成部分。
对于每一个作用力,都有一个大小相等、方向相反的反作用力。
– 艾萨克·牛顿
这是一个我们可以应用于开发更好用户界面的概念,但在我们进入这个话题之前,我想让你尝试一些事情。考虑一个具有以下用户交互流程的照片库界面
- 显示一个搜索输入框和一个搜索按钮,允许用户搜索照片
- 点击搜索按钮时,从 Flickr 获取包含搜索词的照片
- 以小尺寸照片网格的形式显示搜索结果
- 点击/轻触照片时,显示全尺寸照片
- 再次点击/轻触全尺寸照片时,返回到图库视图
现在想想你将如何开发它。甚至可以尝试在 React 中编程实现它。我会等一下;我只是篇文章。我不会去任何地方。
完成了吗?太棒了!这并不太难,对吧?现在想想你可能忘记的以下场景
- 如果用户反复点击搜索按钮会发生什么?
- 如果用户想在搜索进行中取消搜索怎么办?
- 搜索时搜索按钮是否禁用?
- 如果用户恶作剧地启用禁用的按钮会发生什么?
- 是否有任何迹象表明结果正在加载?
- 如果出现错误会发生什么?用户可以重试搜索吗?
- 如果用户搜索然后点击照片会发生什么?应该发生什么?
这些只是在规划、开发或测试期间可能出现的一些问题。在软件开发中,没有什么比认为你已经涵盖了所有可能的用例,然后发现(或收到)新的边缘情况更糟糕的了,这些边缘情况会在你考虑它们后进一步使你的代码复杂化。尤其是在进入一个所有这些用例都没有记录的预先存在的项目时,这尤其困难,但这些用例却隐藏在意大利面条代码中,留待你破译。
显而易见的事情
如果我们能够确定所有可能的操作在每个状态上执行后可能产生的所有可能的 UI 状态?如果我们可以将这些状态、操作和状态之间的转换可视化?设计师直观地做到这一点,在所谓的“用户流程”(或“UX 流程”)中,来描述根据用户交互 UI 的下一个状态应该是什么。

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

什么是有限状态机?
状态机是模拟应用程序行为的一种有用方法:对于每个操作,都会以状态更改的形式产生一个反应。经典有限状态机有 5 个部分
- 一组状态(例如,
idle
、loading
、success
、error
等) - 一组操作(例如,
SEARCH
、CANCEL
、SELECT_PHOTO
等) - 初始状态(例如,
idle
) - 转换函数(例如,
transition('idle', 'SEARCH') == 'loading'
) - 最终状态(不适用于本文。)
确定性有限状态机(我们将要处理的)也有一些约束
- 可能的状态数量是有限的
- 可能的操作数量是有限的(这些是“有限”的部分)
- 应用程序一次只能处于这些状态中的一个
- 给定一个
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
对象中查找currentState
和action
,因为
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/else
或switch
语句来确定下一个状态,例如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 解决方案也支持属性选择器。
优势和资源
使用有限状态机来描述复杂应用程序的行为并不是什么新鲜事。传统上,这是使用 switch
和 goto
语句完成的,但是通过将有限状态机描述为状态、动作和下一个状态之间的声明性映射,您可以使用这些数据来可视化状态转换。

此外,使用声明性有限状态机允许您
- 在任何地方存储、共享和配置应用程序逻辑 - 类似的组件、其他应用程序、数据库、其他语言等。
- 使与设计师和项目经理的协作更容易。
- 静态分析和优化状态转换,包括无法到达的状态。
- 轻松更改应用程序逻辑,无需担心。
- 自动化集成测试。
结论和要点
有限状态机是用于对应用程序中可以表示为有限状态的部分进行建模的抽象,并且几乎所有应用程序都有这些部分。本文中介绍的 FSM 编码模式
- 可以与任何现有的状态管理设置一起使用;例如,Redux 或 MobX
- 可以适应任何框架(不仅仅是 React),或者根本不使用框架。
- 不是一成不变的;开发人员可以根据自己的编码风格调整这些模式。
- 并非适用于所有情况或用例。
从现在开始,当您遇到诸如 isLoaded
或 isSuccess
之类的“布尔标志”变量时,我鼓励您停下来思考您的应用程序状态如何可以建模为有限状态机。这样,您可以重构您的应用程序以将状态表示为 state === 'loaded'
或 state === 'success'
,使用枚举状态代替布尔标志。
资源
如果您想了解有关动机和原理的更多信息,我在 2017 年 React Rally 上做了一个关于使用有限自动机和状态图创建更好用户界面的演讲。
以下是一些其他资源
好文章。但是普通的面向过程的程序员会看到光明吗?如果他们阅读了您出色的作品,他们可能会看到,然后因为花费太长时间而受到批评。生活就是这样。
谢谢!我认为以上代码比典型的面向过程代码更短、更简洁。而且您花费在编码上的时间更少,因为您不必担心到达不可能的状态。
我在我的上一个项目中使用了 stately.js(https://github.com/fschaefer/Stately.js),它为我完成了许多繁重的工作。
很棒的文章,非常喜欢 CSS 的想法。我发现利用 elm 的联合类型编写 FSM 已经成为使用 css 动画的直观方法。从未想过将其引入仅 js 的环境中。这让我想要重新开始使用 React。使用 data 属性是一个有趣的想法。
我实际上使用一个自建库在 JS 中构建了一些东西,该库使用了从学习有限自动机中获得的类似概念。我发现,尽管它可能使事情更清晰,但它非常依赖于我正在构建的内容,并且在更大的 UI 实现方面可能显得有点难以驾驭。
对我来说,优点是它可以非常轻松地映射出应用程序的状态/转换,并且我认为在应用程序状态管理方面这确实很有启发性。
不过,这是一篇很棒的文章!我认为这绝对是更多 Web 开发人员应该研究的东西,如果他们像我一样,他们可能会发现这比充斥着互联网和教科书的通常华而不实且自恋的 FSA 解释更容易接受。
FSM(和状态图)永远不应该完全依赖于正在构建的 UI。UI 应该只是一个用户可以与其交互的层,可以调度操作,而 FSM 应该只负责根据这些操作和当前状态确定下一个状态。
对于更大的 UI 实现,我强烈建议您阅读有关 状态图 的信息,状态图是有限状态机的扩展,并解决了您在使用 FSM 和具有更多状态的复杂 UI 时会遇到的许多扩展问题。
我很快就会写一篇关于状态图的文章!
与其在 SUCCESS 或 ERROR 事件上更改“loading”属性,不如在 .catch() 之后使用另一个 .then() 并将“loading”属性设置为 false。这只是 SoC 和 DRY 模式。:)
你能解释一下吗?没有“loading”属性;这就是重点。您应该枚举有限状态,而不是在应用程序中使用一组布尔标志。
通过使用布尔标志(如“loading”),您实际上有 2^n 种可能的状态(其中“n”是布尔标志的数量)。然后,由您(开发人员)来确保这些 2^n 个状态中的很大一部分是不可能的,方法是执行诸如“if (!loading && success && !canceled)”之类的愚蠢操作,这很快就会变得难以维护。
FSM 完全体现了 SoC 和 DRY。面向过程的编程则没有。
很长时间以来最好的 JavaScript 文章。让 FSM 回归。谢谢!
优秀的文章。OMG 在此处 http://www.omg.org/spec/PSSM 对 FSM 的语义进行了很好的定义。
开发 FSM 和任何 UML 的一个非常好的工具是 http://staruml.io/
也可以使用此工具生成这些代码。
注意!!!!一旦你理解了有限状态机,你就会爱上它。
很棒的文章。您说这可以应用于 MobX 存储;希望能看到一篇简短的/后续文章。
会有 Redux 的示例吗?那将非常棒。
您好,文章很棒!style={{'–i': i}} 的目的是什么?
好问题!这是内联 CSS 变量,现在在最新版本的 React 中得到支持。