使用 React 和 XState 构建登录表单

Avatar of Brad Woods
Brad Woods

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

更新 2019 年 6 月 15 日

自从撰写本文以来,XState 发生了一些变化。 可以在这里找到使用 React & XState 的登录表单的更新版本 here.


要创建具有良好 UX 的登录表单,需要 UI 状态管理,这意味着我们希望最大程度地减少完成表单所需的认知负荷,并减少用户操作的次数,同时提供直观的体验。 想想看:即使是相对简单的电子邮件和密码登录表单也需要处理许多不同的状态,例如空字段、错误、密码要求、加载和成功。

值得庆幸的是,状态管理是 React 的专长,我能够使用一种方法使用它来创建登录表单,该方法的特点是 XState,一个使用有限状态机的 JavaScript 状态管理库。

状态管理?有限状态机? 我们将一起探讨这些概念,同时构建一个可靠的登录表单。

提前看看,这是我们将一起构建的内容

首先,让我们进行设置

在开始之前,我们需要一些工具。 以下是需要获取的内容

准备好这些工具后,我们可以确保项目文件夹已设置为开发模式。 以下是文件结构的概述

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.badFormatemailErr.noAccount

对于 emailErr 状态,我们定义了两个子状态:badFormatnoAccount。 这意味着机器不再只能处于 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 属性接受一个函数,该函数有两个参数:contextevent(但我们这里只使用 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。我们还添加一个带有动作 onAuthenticationonDone 属性。现在,当状态 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

映射动作

我们在机器配置中声明了以下动作

  • focusEmailInput
  • focusPasswordInput
  • focusSubmitBtn
  • cacheEmail
  • cachePassword
  • onAuthentication

动作映射在机器配置的 actions 属性中。每个函数都接受两个参数:上下文 (ctx) 和事件 (evt)。

focusEmailInputfocusPasswordInput 很直观,但是存在一个错误。当从禁用状态进入时,这些元素会获得焦点。用于聚焦这些元素的函数在元素重新启用之前立即被调用。delay 函数解决了这个问题。

cacheEmailcachePassword 需要更新上下文。为此,我们使用 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')
      }
    },
  }
}

设置守卫

我们在机器配置中声明了以下守卫

  • isBadEmailFormat
  • isPasswordShort
  • isNoAccount
  • isIncorrectPassword
  • isServiceErr

守卫映射在机器配置的 guards 属性中。isBadEmailFormatisPasswordShort 守卫使用 context 读取用户输入的电子邮件和密码,然后将它们传递给相应的函数。isNowAccountisIncorrectPasswordisServiceErr 使用事件对象读取从对身份验证服务的调用返回的错误类型。

// 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——configoptions——并返回一个 XState servicestate 对象。

// 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 控制的,具有出色用户体验的登录表单。我们不仅创建了一个用户可以交互的表单,我们还对需要考虑的许多状态和交互类型进行了深入思考,这对任何需要放入组件的功能来说都是一个很好的练习。

如果您对某些内容感到困惑,或者您认为表单中需要考虑其他内容,请访问评论表单。我很乐意听取您的意见!

更多资源