更新 2019 年 6 月 15 日
自从撰写本文以来,XState 发生了一些变化。 可以在这里找到使用 React & XState 的登录表单的更新版本 here.
要创建具有良好 UX 的登录表单,需要 UI 状态管理,这意味着我们希望最大程度地减少完成表单所需的认知负荷,并减少用户操作的次数,同时提供直观的体验。 想想看:即使是相对简单的电子邮件和密码登录表单也需要处理许多不同的状态,例如空字段、错误、密码要求、加载和成功。
值得庆幸的是,状态管理是 React 的专长,我能够使用一种方法使用它来创建登录表单,该方法的特点是 XState,一个使用有限状态机的 JavaScript 状态管理库。
状态管理?有限状态机? 我们将一起探讨这些概念,同时构建一个可靠的登录表单。
提前看看,这是我们将一起构建的内容

首先,让我们进行设置
在开始之前,我们需要一些工具。 以下是需要获取的内容
- 一个 UI 库: React
- 一个样式库: styled-components
- 一个状态管理库: XState
准备好这些工具后,我们可以确保项目文件夹已设置为开发模式。 以下是文件结构的概述
public/
|--src/
|--Loader/
|--SignIn/
|--contactAuthService.js
|--index.jsx
|--isPasswordShort.js
|--machineConfig.js
|--styles.js
|--globalStyles.js
|--index.jsx
package.json
关于 XState 的一些背景知识
我们已经提到 XState 是一个状态管理 JavaScript 库。 它的方法使用有限状态机,这使其成为这类项目的理想选择。 例如
- 它是一种经过充分验证和测试的状态管理方法。 有限状态机已经存在 30 多年了。
- 它根据 规范 构建。
- 它允许逻辑与实现完全分离,使其易于测试和模块化。
- 它有一个可视化解释器,可以对已编码的内容提供很好的反馈,并使与其他人员沟通系统变得更加容易。
有关有限状态机的更多信息,请 查看 David Khourshid 的文章。
机器配置
机器配置是 XState 的核心。 它是一个 状态图,它将定义表单的逻辑。 我已将其分解为以下部分,我们将逐一进行介绍。
1. 状态
我们需要一种方法来控制显示、隐藏、启用和禁用哪些内容。 我们将使用命名状态来控制这一点,其中包括
dataEntry: 这是用户可以将电子邮件和密码输入提供的字段中的状态。 我们可以将其视为默认状态。 当前字段将以蓝色突出显示。

awaitingResponse: 这是浏览器向身份验证服务发出请求后我们等待响应时。 当表单处于此状态时,我们将禁用表单并将按钮替换为加载指示器。

emailErr: 糟糕! 此状态是在用户输入的电子邮件地址存在问题时引发的。 我们将突出显示该字段,显示错误,并禁用其他字段和按钮。

passwordErr: 这是另一个错误状态,这次是用户输入的密码存在问题时。 与之前的错误一样,我们将突出显示该字段,显示错误,并禁用表单的其余部分。

serviceErr: 当我们无法联系身份验证服务时,我们将进入此状态,从而阻止提交的数据被检查。 我们将显示错误以及“重试”按钮以重新尝试连接服务。

signedIn: 成功! 这是用户成功完成身份验证并通过登录表单继续进行时。 通常,这将把用户带到某个视图,但由于我们只关注表单,因此我们将简单地确认身份验证。

请查看 SignIn 目录中的 machinConfig.js 文件? 打开该文件,以便我们可以定义我们的状态。 我们将它们列为 states 对象的属性。 我们还需要定义一个初始状态,如前所述,它将是 dataEntry 状态,允许用户在表单字段中输入数据。
const machineConfig = {
id: 'signIn',
initial: 'dataEntry',
states: {
dataEntry: {},
awaitingResponse: {},
emailErr: {},
passwordErr: {},
serviceErr: {},
signedIn: {},
}
}
export default machineConfig

本文的每个部分都将显示 machineConfig.js 的代码以及使用 XState 的 可视化器 从代码生成的图表。
2. 过渡
既然我们已经定义了状态,我们需要定义如何从一个状态更改为另一个状态,在 XState 中,我们使用一种称为过渡的事件类型来执行此操作。 我们在每个状态内定义过渡。 例如,如果 ENTER_EMAIL 过渡是在我们处于 emailErr 状态时触发的,那么系统将移动到状态 dataEntry。
emailErr: {
on: {
ENTER_EMAIL: {
target: 'dataEntry'
}
}
}
请注意,如果在 emailErr 状态下触发了不同类型的过渡(例如 ENTER_PASSWORD),则不会发生任何事情。 只有在状态内定义的过渡才有效。
当过渡没有目标时,它是一个外部(默认情况下)自过渡。 当触发时,该状态将退出并重新进入自身。 例如,当 ENTER_EMAIL 过渡被触发时,机器将从 dataEntry 更改回 dataEntry。
以下是定义方式
dataEntry: {
on: {
ENTER_EMAIL: {}
}
}
我知道这听起来很奇怪,但我们稍后会解释。 以下是迄今为止的 machineConfig.js 文件。
const machineConfig = {
id: 'signIn',
initial: 'dataEntry',
states: {
dataEntry: {
on: {
ENTER_EMAIL: {},
ENTER_PASSWORD: {},
EMAIL_BLUR: {},
PASSWORD_BLUR: {},
SUBMIT: {
target: 'awaitingResponse',
},
},
},
awaitingResponse: {},
emailErr: {
on: {
ENTER_EMAIL: {
target: 'dataEntry',
},
},
},
passwordErr: {
on: {
ENTER_PASSWORD: {
target: 'dataEntry',
},
},
},
serviceErr: {
on: {
SUBMIT: {
target: 'awaitingResponse',
},
},
},
signedIn: {},
},
};
export default machineConfig;

3. 上下文
我们需要一种方法来保存用户输入输入字段的内容。 我们可以在 XState 中使用上下文来做到这一点,上下文是机器内的对象,使我们能够存储数据。 因此,我们也需要在我们的文件中定义它。
默认情况下,电子邮件和密码都是空字符串。 当用户输入其电子邮件或密码时,我们将存储在这里。
const machineConfig = {
id: 'signIn',
context: {
email: '',
password: '',
},
...
4. 分层状态
我们需要一种方法来更具体地描述我们的错误。 与仅仅告诉用户存在电子邮件错误不同,我们需要告诉他们发生了什么类型的错误。 也许是格式错误的电子邮件,或者没有与输入的电子邮件关联的帐户——我们应该让用户知道,这样就不会让他们猜测。 这就是我们可以使用分层状态的地方,它们本质上是状态机内的状态机。 因此,与仅拥有一个 emailErr 状态不同,我们可以添加子状态,例如 emailErr.badFormat 或 emailErr.noAccount。
对于 emailErr 状态,我们定义了两个子状态:badFormat 和 noAccount。 这意味着机器不再只能处于 emailErr 状态; 它将处于 emailErr.badFormat 状态或 emailErr.noAccount 状态,并且将其解析出来可以让我们在表单中以唯一消息的形式向用户提供更多上下文,从而在每个子状态中使用唯一消息。
const machineConfig = {
...
states: {
...
emailErr: {
on: {
ENTER_EMAIL: {
target: 'dataEntry',
},
},
initial: 'badFormat',
states: {
badFormat: {},
noAccount: {},
},
},
passwordErr: {
on: {
ENTER_PASSWORD: {
target: 'dataEntry',
},
},
initial: 'tooShort',
states: {
tooShort: {},
incorrect: {},
},
},
...

5. 保护
当用户将输入项置于失焦状态或单击提交按钮时,我们需要检查电子邮件和/或密码是否有效。 如果其中一个值格式错误,我们需要提示用户进行更改。 保护措施允许我们根据这些类型的条件过渡到某个状态。
这里,我们使用 EMAIL_BLUR 过渡来更改状态为 emailErr.badFormat,前提是条件 isBadEmailFormat 返回 true。我们对 PASSWORD_BLUR 做了类似的事情。
我们还将 SUBMIT 过渡的值更改为包含目标和条件属性的对象数组。当 SUBMIT 过渡被触发时,机器将从第一个到最后一个依次检查每个条件,并将第一个返回 true 的条件的状态更改。例如,如果 isBadEmailFormat 返回 true,机器将更改为状态 emailErr.badFormat。但是,如果 isBadEmailFormat 返回 false,机器将移动到下一个条件语句并检查它是否返回 true。
const machineConfig = {
...
states: {
...
dataEntry: {
...
on: {
EMAIL_BLUR: {
cond: 'isBadEmailFormat',
target: 'emailErr.badFormat'
},
PASSWORD_BLUR: {
cond: 'isPasswordShort',
target: 'passwordErr.tooShort'
},
SUBMIT: [
{
cond: 'isBadEmailFormat',
target: 'emailErr.badFormat'
},
{
cond: 'isPasswordShort',
target: 'passwordErr.tooShort'
},
{
target: 'awaitingResponse'
}
],
...

6. 调用
如果我们没有向身份验证服务发出请求,我们到目前为止所做的所有工作都将付诸东流。表单中输入和提交的内容结果将影响我们定义的许多状态。因此,调用该请求应该导致两种状态之一
- 如果调用成功,则过渡到
signedIn状态,或者 - 如果调用失败,则过渡到我们的错误状态之一。
调用方法允许我们声明一个承诺并根据该承诺的返回值过渡到不同的状态。src 属性接受一个函数,该函数有两个参数:context 和 event(但我们这里只使用 context)。我们返回一个包含来自上下文的电子邮件和密码值的承诺(我们的身份验证请求)。如果承诺成功返回,我们将过渡到 onDone 属性中定义的状态。如果返回错误,我们将过渡到 onError 属性中定义的状态。
const machineConfig = {
...
states: {
...
// We’re in a state of waiting for a response
awaitingResponse: {
// Make a call to the authentication service
invoke: {
src: 'requestSignIn',
// If successful, move to the signedIn state
onDone: {
target: 'signedIn'
},
// If email input is unsuccessful, move to the emailErr.noAccount sub-state
onError: [
{
cond: 'isNoAccount',
target: 'emailErr.noAccount'
},
{
// If password input is unsuccessful, move to the passwordErr.incorrect sub-state
cond: 'isIncorrectPassword',
target: 'passwordErr.incorrect'
},
{
// If the service itself cannot be reached, move to the serviceErr state
cond: 'isServiceErr',
target: 'serviceErr'
}
]
},
},
...

7. 动作
我们需要一种方法来保存用户在电子邮件和密码字段中输入的内容。动作允许在过渡发生时触发副作用。下面,我们在 dataEntry 状态的 ENTER_EMAIL 过渡中定义了一个动作 (cacheEmail)。这意味着如果机器处于 dataEntry 状态并且 ENTER_EMAIL 过渡被触发,cacheEmail 动作也将被触发。
const machineConfig = {
...
states: {
...
// On submit, target the two fields
dataEntry: {
on: {
ENTER_EMAIL: {
actions: 'cacheEmail'
},
ENTER_PASSWORD: {
actions: 'cachePassword'
},
},
...
},
// If there’s an email error on that field, trigger email cache action
emailErr: {
on: {
ENTER_EMAIL: {
actions: 'cacheEmail',
...
}
}
},
// If there’s a password error on that field, trigger password cache action
passwordErr: {
on: {
ENTER_PASSWORD: {
actions: 'cachePassword',
...
}
}
},
...

8. 终态
我们需要一种方法来指示用户是否成功认证,并根据结果触发用户旅程的下一阶段。为此需要两件事
- 我们声明其中一个状态是终态,并且
- 定义一个
onDone属性,当达到终态时可以触发动作。
在 signedIn 状态内,我们添加 type: final。我们还添加一个带有动作 onAuthentication 的 onDone 属性。现在,当状态 signedIn 被达到时,动作 onAuthentication 将被触发,机器将 完成(不再可执行)。
const machineConfig = {
...
states: {
...
signedIn: {
type: 'final'
},
onDone: {
actions: 'onAuthentication'
},
...

9. 测试
XState 的一个很棒的功能是,机器配置完全独立于实际实现。这意味着我们现在可以对其进行测试,并在将其连接到 UI 和后端服务之前对我们所做的事情充满信心。我们可以将机器配置文件复制粘贴到 XState 的 可视化工具 中,并获得一个自动生成的 statechart 图表,该图表不仅概述了所有定义的状态,以及说明它们如何连接的箭头,而且还允许我们与图表进行交互。这是内置测试!

将机器连接到 React 组件
现在我们已经编写了 statechart,是时候将其连接到我们的 UI 和后端服务了。XState 机器选项对象允许我们将我们在配置中声明的字符串映射到函数。
我们将从定义一个具有三个 ref 的 React 类组件开始
// SignIn/index.jsx
import React, { Component, createRef } from 'react'
class SignIn extends Component {
emailInputRef = createRef()
passwordInputRef = createRef()
submitBtnRef = createRef()
render() {
return null
}
}
export default SignIn
映射动作
我们在机器配置中声明了以下动作
focusEmailInputfocusPasswordInputfocusSubmitBtncacheEmailcachePasswordonAuthentication
动作映射在机器配置的 actions 属性中。每个函数都接受两个参数:上下文 (ctx) 和事件 (evt)。
focusEmailInput 和 focusPasswordInput 很直观,但是存在一个错误。当从禁用状态进入时,这些元素会获得焦点。用于聚焦这些元素的函数在元素重新启用之前立即被调用。delay 函数解决了这个问题。
cacheEmail 和 cachePassword 需要更新上下文。为此,我们使用 assign 函数(由 XState 提供)。assign 函数返回的内容将被添加到我们的上下文。在我们的例子中,它是从事件对象中读取输入的值,然后将该值添加到上下文的电子邮件或密码中。从那里 property.assign 被添加到上下文。同样,在我们的例子中,它是从事件对象中读取输入的值,并将该值添加到上下文的电子邮件或密码属性中。
// SignIn/index.jsx
import { actions } from 'xstate'
const { assign } = actions
const delay = func => setTimeout(() => func())
class SignIn extends Component {
...
machineOptions = {
actions: {
focusEmailInput: () => {
delay(this.emailInputRef.current.focus())
},
focusPasswordInput: () => {
delay(this.passwordInputRef.current.focus())
},
focusSubmitBtn: () => {
delay(this.submitBtnRef.current.focus())
},
cacheEmail: assign((ctx, evt) => ({
email: evt.value
})),
cachePassword: assign((ctx, evt) => ({
password: evt.value
})),
// We’ll log a note in the console to confirm authentication
onAuthentication: () => {
console.log('user authenticated')
}
},
}
}
设置守卫
我们在机器配置中声明了以下守卫
isBadEmailFormatisPasswordShortisNoAccountisIncorrectPasswordisServiceErr
守卫映射在机器配置的 guards 属性中。isBadEmailFormat 和 isPasswordShort 守卫使用 context 读取用户输入的电子邮件和密码,然后将它们传递给相应的函数。isNowAccount、isIncorrectPassword 和 isServiceErr 使用事件对象读取从对身份验证服务的调用返回的错误类型。
// isPasswordShort.js
const isPasswordShort = password => password.length < 6
export default isPasswordShort
// SignIn/index.jsx
import { isEmail } from 'validator'
import isPasswordShort from './isPasswordShort'
class SignIn extends Component {
...
machineOptions = {
...
guards: {
isBadEmailFormat: ctx => !isEmail(ctx.email),
isPasswordShort: ctx => isPasswordShort(ctx.password),
isNoAccount: (ctx, evt) => evt.data.code === 1,
isIncorrectPassword: (ctx, evt) => evt.data.code === 2,
isServiceErr: (ctx, evt) => evt.data.code === 3
},
},
...
}
连接服务
我们在机器配置中声明了以下服务(在 invoke 定义中):requestSignIn。
服务映射在机器配置的 services 属性中。在这种情况下,该函数是一个承诺,并传递给来自 context 的电子邮件密码。
// contactAuthService.js
// error code 1 - no account
// error code 2 - wrong password
// error code 3 - no response
const isSuccess = () => Math.random() >= 0.8
const generateErrCode = () => Math.floor(Math.random() * 3) + 1
const contactAuthService = (email, password) =>
new Promise((resolve, reject) => {
console.log(`email: ${email}`)
console.log(`password: ${password}`)
setTimeout(() => {
if (isSuccess()) resolve()
reject({ code: generateErrCode() })
}, 1500)
})
export default contactAuthService
// SignIn/index.jsx
...
import contactAuthService from './contactAuthService.js'
class SignIn extends Component {
...
machineOptions = {
...
services: {
requestSignIn: ctx => contactAuthService(ctx.email, ctx.password)
}
},
...
}
react-xstate-js 连接 React 和 XState
现在我们已经准备好机器配置和选项,我们可以创建实际的机器!为了在 现实世界 场景中使用 XState,这需要一个解释器。 react-xstate-js 是一个解释器,它使用 render props 方法将 React 连接到 XState。(完全披露,我开发了这个库。)它接受两个 props——config 和 options——并返回一个 XState service 和 state 对象。
// SignIn/index.jsx
...
import { Machine } from 'react-xstate-js'
import machineConfig from './machineConfig'
class SignIn extends Component {
...
render() {
<Machine config={machineConfig} options={this.machineOptions}>
{({ service, state }) => null}
</Machine>
}
}
让我们创建 UI!
好的,我们有一个功能正常的机器,但用户需要看到表单才能使用它。这意味着是时候创建 UI 组件的标记了。我们需要做两件事来与机器通信
1. 读取状态
要确定我们处于哪个状态,我们可以使用状态的 matches 方法并返回一个布尔值。例如:state.matches('dataEntry')。
2. 触发过渡
为了触发过渡,我们使用服务的 `send` 方法。它接受一个包含我们想要触发的过渡类型的对象,以及我们想要在 `evt` 对象中的任何其他键值对。例如:`service.send({ type: 'SUBMIT' })`。
// SignIn/index.jsx
...
import {
Form,
H1,
Label,
Recede,
Input,
ErrMsg,
Button,
Authenticated,
MetaWrapper,
Pre
} from './styles'
class SignIn extends Component {
...
render() {
<Machine config={machineConfig} options={this.machineOptions}>
{({ service, state }) => {
const disableEmail =
state.matches('passwordErr') ||
state.matches('awaitingResponse') ||
state.matches('serviceErr')
const disablePassword =
state.matches('emailErr') ||
state.matches('awaitingResponse') ||
state.matches('serviceErr')
const disableSubmit =
state.matches('emailErr') ||
state.matches('passwordErr') ||
state.matches('awaitingResponse')
const fadeHeading =
state.matches('emailErr') ||
state.matches('passwordErr') ||
state.matches('awaitingResponse') ||
state.matches('serviceErr')
return (
<Form
onSubmit={e => {
e.preventDefault()
service.send({ type: 'SUBMIT' })
}}
noValidate
>
<H1 fade={fadeHeading}>Welcome Back</H1>
<Label htmlFor="email" disabled={disableEmail}>
email
</Label>
<Input
id="email"
type="email"
placeholder="[email protected]"
onBlur={() => {
service.send({ type: 'EMAIL_BLUR' })
}}
value={state.context.email}
err={state.matches('emailErr')}
disabled={disableEmail}
onChange={e => {
service.send({
type: 'ENTER_EMAIL',
value: e.target.value
})
}}
ref={this.emailInputRef}
autoFocus
/>
<ErrMsg>
{state.matches({ emailErr: 'badFormat' }) &&
"email format doesn't look right"}
{state.matches({ emailErr: 'noAccount' }) &&
'no account linked with this email'}
</ErrMsg>
<Label htmlFor="password" disabled={disablePassword}>
password <Recede>(min. 6 characters)</Recede>
</Label>
<Input
id="password"
type="password"
placeholder="Passw0rd!"
value={state.context.password}
err={state.matches('passwordErr')}
disabled={disablePassword}
onBlur={() => {
service.send({ type: 'PASSWORD_BLUR' })
}}
onChange={e => {
service.send({
type: 'ENTER_PASSWORD',
value: e.target.value
})
}}
ref={this.passwordInputRef}
/>
<ErrMsg>
{state.matches({ passwordErr: 'tooShort' }) &&
'password too short (min. 6 characters)'}
{state.matches({ passwordErr: 'incorrect' }) &&
'incorrect password'}
</ErrMsg>
<Button
type="submit"
disabled={disableSubmit}
loading={state.matches('awaitingResponse')}
ref={this.submitBtnRef}
>
{state.matches('awaitingResponse') && (
<>
loading
<Loader />
</>
)}
{state.matches('serviceErr') && 'retry'}
{!state.matches('awaitingResponse') &&
!state.matches('serviceErr') &&
'sign in'
}
</Button>
<ErrMsg>
{state.matches('serviceErr') && 'problem contacting server'}
</ErrMsg>
{state.matches('signedIn') && (
<Authenticated>
<H1>authenticated</H1>
</Authenticated>
)}
</Form>
)
}}
</Machine>
}
}
我们有一个表单!
就是这样。一个由 XState 控制的,具有出色用户体验的登录表单。我们不仅创建了一个用户可以交互的表单,我们还对需要考虑的许多状态和交互类型进行了深入思考,这对任何需要放入组件的功能来说都是一个很好的练习。
如果您对某些内容感到困惑,或者您认为表单中需要考虑其他内容,请访问评论表单。我很乐意听取您的意见!
更多资源
- XState 文档
- react-xstate-js 代码库
- 使用 React 的有限状态机,作者 Jon Bellah(非常适合下一步提升我们的有限状态机水平)
为什么有人要使用 XState 而不是 Redux?它是否添加了 Redux 无法轻松实现的功能?
当我第一次从 Redux 背景看到 XState 时,我犹豫了,但我发现它提供了许多显著的优势。我做了一个比较两个的演示 这里,其中涵盖了一些重要内容。
为什么不在点击提交时尽早检查所有输入,并一次性将所有错误返回给用户?这将为用户提供有关错误的更多上下文信息,并为用户提供更多自由度,让他们可以按任何顺序填写字段,并使用任何他们喜欢的内容。
这不是使用状态机的良好示例。
我会立即远离这种固执己见的、不宽容的 UI。
感谢您的反馈。对于
在最后一段中,您写道“它确实强制用户……”,这就是
我认为不对的地方。我在过去 40 年的软件开发中所学到的经验是
“永远不要强制用户……”!
我试着使用您的登录表单,实际上它让我感到认知超载,因为它开始
在任何错误的情况下,在字段之间来回切换,并且它在不应该出现错误的地方给出错误。
在一个新的、空的表单上,点击字段外部的任何地方,而不是“登录”,会显示“错误的电子邮件错误”,
所有其他元素都消失了。为什么?
接下来,输入有效的电子邮件和一个六个字符长的密码,会显示“错误的密码”。为什么?
state: {
“passwordErr”: “incorrect”
}
context (ctx): {
“email”: “[email protected]”,
“password”: “‘\”‘\”‘\””
}
所有其他元素都消失了!
当我收到“联系服务器时出现问题”的重试按钮时
整个表单都消失了。为什么?
我尝试了其他几种组合,并获得了非常不稳定的行为,有时
我成功了,有时没有成功。
我很欣赏您为实现良好用户体验而付出的努力和思维方式,但说实话
这种实现不会通过我的质量关。
永远不要强制用户……
此致
Heinz
这是 最小权力原则在起作用。由于 XState 的声明性本质,机器中编码的信息可以被重复使用以进行可视化。
在出现错误时隐藏/显示字段背后的思路是帮助用户专注于他们需要做的事情。如果存在电子邮件错误,他们需要更改电子邮件输入的值,因此不需要显示密码输入或提交按钮。
关于输入有效的电子邮件和一个六个字符长的密码,会显示“错误的密码”。为什么? – 没有“错误的密码”错误消息。有一个“密码错误”错误消息。当调用模拟身份验证服务时,就会发生这种情况。它会随机选择以下场景之一(没有与给定电子邮件关联的帐户、密码错误、无法联系服务或成功身份验证)。我这样做是为了让人们看到每个场景在表单上的样子。这也解释了您提到的不稳定行为。
关于当我收到“联系服务器时出现问题”的重试按钮时,整个表单都消失了。为什么? – 当表单无法联系身份验证服务时,就会进入此错误状态。在这种状态下,用户没有理由更改他们的电子邮件或密码,因为它们可能没有问题。只有在身份验证服务检查了电子邮件和密码并发现问题后,才需要更改输入。因此,输入被隐藏,并显示一个重试按钮,因为这是用户需要执行的唯一操作,其他任何操作都是没有必要的。
我在这里测试过,但 onDone 状态似乎根本没有执行。可能是什么原因导致的?
我不确定。我刚刚再次检查了推荐的 CodeSandbox,onDone 对我来说是有效的。请注意,只有当用户成功进行身份验证时,onDone 才会触发。您可能需要尝试几次才能进入此状态,因为我随机化了模拟服务器响应。