提升可访问性

在上一章节中,我们探讨了如何捕获错误(包括 404 错误)并向用户展示回退内容。然而,我们还需要讨论另一个关键部分:表单验证。让我们看看如何通过服务端操作 (Server Actions) 实现服务端验证,以及如何使用 React 的 useActionState 钩子展示表单错误——同时兼顾可访问性!

什么是可访问性?

可访问性 (accessibility) 是指设计和实现所有人都能使用的网页应用,包括残障人士。这是一个涵盖多个领域的广泛主题,例如键盘导航、语义化 HTML、图片、颜色、视频等。

虽然本课程不会深入探讨可访问性,但我们会讨论 Next.js 提供的可访问性功能以及一些使应用更具可访问性的常见实践。

如果你想了解更多关于可访问性的内容,我们推荐 web.devLearn Accessibility 课程。

在 Next.js 中使用 ESLint 可访问性插件

Next.js 在其 ESLint 配置中内置了 eslint-plugin-jsx-a11y 插件,帮助及早发现可访问性问题。例如,该插件会在以下情况发出警告:图片缺少 alt 文本、错误使用 aria-*role 属性等。

如果你想尝试此功能,可以在 package.json 文件中添加 next lint 脚本:

/package.json
"scripts": {
    "build": "next build",
    "dev": "next dev",
    "start": "next start",
    "lint": "next lint"
},

然后在终端运行 pnpm lint

Terminal
pnpm lint

这将引导你安装并配置项目的 ESLint。如果现在运行 pnpm lint,你应该会看到以下输出:

Terminal
 No ESLint warnings or errors

但如果你的图片缺少 alt 文本会怎样呢?让我们试试看!

转到 /app/ui/invoices/table.tsx 并移除图片的 alt 属性。你可以使用编辑器的搜索功能快速找到 <Image>

/app/ui/invoices/table.tsx
<Image
  src={invoice.image_url}
  className="rounded-full"
  width={28}
  height={28}
  alt={`${invoice.name}'s profile picture`} // 删除此行
/>

再次运行 pnpm lint,你应该会看到以下警告:

Terminal
./app/ui/invoices/table.tsx
45:25  Warning: Image elements must have an alt prop,
either with meaningful text, or an empty string for decorative images. jsx-a11y/alt-text

虽然添加和配置 linter 不是必需步骤,但它有助于在开发过程中发现可访问性问题。

提升表单可访问性

我们已经在表单中做了三件事来提升可访问性:

  • 语义化 HTML:使用语义化元素(如 <input><option>)而非 <div>。这使辅助技术 (AT) 能够聚焦输入元素并向用户提供适当的上下文信息,使表单更易于导航和理解。
  • 标签化:包含 <label>htmlFor 属性确保每个表单字段都有描述性文本标签。这通过提供上下文增强了 AT 支持,并通过允许用户点击标签聚焦到对应输入字段提升了可用性。
  • 焦点轮廓:字段在聚焦时正确显示轮廓样式。这对可访问性至关重要,因为它视觉上指示了页面上的活动元素,帮助键盘和屏幕阅读器用户理解他们在表单中的位置。你可以按 tab 键验证这一点。

这些实践为让表单对更多用户具有可访问性奠定了良好基础。但它们并未解决表单验证错误处理问题。

表单验证

访问 http://localhost:3000/dashboard/invoices/create 并提交空表单。会发生什么?

你会得到一个错误!这是因为你向服务端操作 (Server Action) 发送了空表单值。你可以通过在客户端或服务端验证表单来防止这种情况。

客户端验证

有几种方法可以在客户端验证表单。最简单的方式是利用浏览器提供的表单验证,即在表单的 <input><select> 元素上添加 required 属性。例如:

/app/ui/invoices/create-form.tsx
<input
  id="amount"
  name="amount"
  type="number"
  placeholder="Enter USD amount"
  className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
  required
/>

再次提交表单。如果你尝试提交包含空值的表单,浏览器会显示警告。

这种方式通常可行,因为部分 AT 支持浏览器验证。

客户端验证的替代方案是服务端验证。我们将在下一节探讨如何实现它。现在,如果你添加了 required 属性,请先删除它们。

服务端验证

通过在服务端验证表单,您可以:

  • 确保数据在发送到数据库前符合预期格式
  • 降低恶意用户绕过客户端验证的风险
  • 为"有效数据"建立单一可信来源

在您的 create-form.tsx 组件中,从 react 导入 useActionState 钩子。由于 useActionState 是钩子函数,您需要使用 "use client" 指令将表单转为客户端组件:

/app/ui/invoices/create-form.tsx
'use client';
 
// ...
import { useActionState } from 'react';

在表单组件内部,useActionState 钩子:

  • 接收两个参数:(action, initialState)
  • 返回两个值:[state, formAction] —— 表单状态和表单提交时调用的函数

createInvoice 操作作为参数传递给 useActionState,并在 <form action={}> 属性中调用 formAction

/app/ui/invoices/create-form.tsx
// ...
import { useActionState } from 'react';
 
export default function Form({ customers }: { customers: CustomerField[] }) {
  const [state, formAction] = useActionState(createInvoice, initialState);
 
  return <form action={formAction}>...</form>;
}

initialState 可以是您定义的任何内容,本例中创建一个包含两个空键的对象:messageerrors,并从 actions.ts 文件导入 State 类型(State 目前还不存在,我们将在下一步创建):

/app/ui/invoices/create-form.tsx
// ...
import { createInvoice, State } from '@/app/lib/actions';
import { useActionState } from 'react';
 
export default function Form({ customers }: { customers: CustomerField[] }) {
  const initialState: State = { message: null, errors: {} };
  const [state, formAction] = useActionState(createInvoice, initialState);
 
  return <form action={formAction}>...</form>;
}

这看起来可能有些复杂,但当您更新服务端操作后就会更清晰。现在让我们开始更新。

action.ts 文件中,可以使用 Zod 验证表单数据。按如下方式更新 FormSchema

/app/lib/actions.ts
const FormSchema = z.object({
  id: z.string(),
  customerId: z.string({
    invalid_type_error: '请选择客户',
  }),
  amount: z.coerce
    .number()
    .gt(0, { message: '请输入大于 $0 的金额' }),
  status: z.enum(['pending', 'paid'], {
    invalid_type_error: '请选择发票状态',
  }),
  date: z.string(),
});
  • customerId - Zod 已会在客户字段为空时抛出错误(因为它期望字符串类型),但我们添加了更友好的提示信息
  • amount - 由于您将金额类型从字符串强制转换为数字,空字符串会默认为零。我们使用 .gt() 函数告诉 Zod 金额必须大于 0
  • status - Zod 已会在状态字段为空时抛出错误(因为它期望 "pending" 或 "paid"),我们也添加了更友好的提示信息

接下来,更新 createInvoice 操作以接收两个参数 —— prevStateformData

/app/lib/actions.ts
export type State = {
  errors?: {
    customerId?: string[];
    amount?: string[];
    status?: string[];
  };
  message?: string | null;
};
 
export async function createInvoice(prevState: State, formData: FormData) {
  // ...
}
  • formData - 与之前相同
  • prevState - 包含从 useActionState 钩子传递的状态。本例中不会在操作中使用它,但这是必需属性

然后将 Zod 的 parse() 函数改为 safeParse()

/app/lib/actions.ts
export async function createInvoice(prevState: State, formData: FormData) {
  // 使用 Zod 验证表单字段
  const validatedFields = CreateInvoice.safeParse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
 
  // ...
}

safeParse() 会返回包含 successerror 字段的对象,这样无需将逻辑放在 try/catch 块中就能更优雅地处理验证。

在将信息发送到数据库前,用条件语句检查表单字段是否验证成功:

/app/lib/actions.ts
export async function createInvoice(prevState: State, formData: FormData) {
  // 使用 Zod 验证表单字段
  const validatedFields = CreateInvoice.safeParse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
 
  // 如果表单验证失败,提前返回错误。否则继续。
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: '字段缺失,创建发票失败',
    };
  }
 
  // ...
}

如果 validatedFields 不成功,我们将提前返回函数并携带 Zod 的错误信息。

提示: 可以 console.log validatedFields 并提交空表单查看其结构。

最后,由于您在 try/catch 块外单独处理表单验证,可以为任何数据库错误返回特定消息。最终代码应如下所示:

/app/lib/actions.ts
export async function createInvoice(prevState: State, formData: FormData) {
  // 使用 Zod 验证表单
  const validatedFields = CreateInvoice.safeParse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
 
  // 如果表单验证失败,提前返回错误。否则继续。
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: '字段缺失,创建发票失败',
    };
  }
 
  // 准备插入数据库的数据
  const { customerId, amount, status } = validatedFields.data;
  const amountInCents = amount * 100;
  const date = new Date().toISOString().split('T')[0];
 
  // 将数据插入数据库
  try {
    await sql`
      INSERT INTO invoices (customer_id, amount, status, date)
      VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
    `;
  } catch (error) {
    // 如果发生数据库错误,返回更具体的错误
    return {
      message: '数据库错误:创建发票失败',
    };
  }
 
  // 重新验证发票页面的缓存并重定向用户
  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}

很好,现在让我们在表单组件中显示错误。回到 create-form.tsx 组件,您可以通过表单 state 访问错误。

添加一个三元运算符来检查每个特定错误。例如,在客户字段后可以添加:

/app/ui/invoices/create-form.tsx
<form action={formAction}>
  <div className="rounded-md bg-gray-50 p-4 md:p-6">
    {/* 客户名称 */}
    <div className="mb-4">
      <label htmlFor="customer" className="mb-2 block text-sm font-medium">
        选择客户
      </label>
      <div className="relative">
        <select
          id="customer"
          name="customerId"
          className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
          defaultValue=""
          aria-describedby="customer-error"
        >
          <option value="" disabled>
            选择客户
          </option>
          {customers.map((name) => (
            <option key={name.id} value={name.id}>
              {name.name}
            </option>
          ))}
        </select>
        <UserCircleIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500" />
      </div>
      <div id="customer-error" aria-live="polite" aria-atomic="true">
        {state.errors?.customerId &&
          state.errors.customerId.map((error: string) => (
            <p className="mt-2 text-sm text-red-500" key={error}>
              {error}
            </p>
          ))}
      </div>
    </div>
    // ...
  </div>
</form>

提示: 您可以在组件内部 console.log state 检查所有连接是否正确。由于表单现在是客户端组件,请在开发者工具中查看控制台。

在上面的代码中,您还添加了以下 ARIA 标签:

  • aria-describedby="customer-error":建立 select 元素与错误消息容器之间的关系,表示具有 id="customer-error" 的容器描述了 select 元素。屏幕阅读器会在用户与 select 框交互时读出此描述以通知错误
  • id="customer-error":此 id 属性唯一标识包含 select 输入错误消息的 HTML 元素,这是 aria-describedby 建立关系所必需的
  • aria-live="polite":当 div 内的错误更新时,屏幕阅读器应礼貌地通知用户。当内容变化时(例如用户纠正错误),屏幕阅读器会在用户空闲时宣布这些变化,避免打断用户

实践:添加 ARIA 标签

使用上面的示例,为剩余表单字段添加错误显示。如果任何字段缺失,还应在表单底部显示消息。您的 UI 应如下所示:

创建发票表单显示各字段的错误消息

完成后,运行 pnpm lint 检查是否正确使用了 ARIA 标签。

如果想挑战自己,可以运用本章学到的知识为 edit-form.tsx 组件添加表单验证。

您需要:

  • edit-form.tsx 组件中添加 useActionState
  • 编辑 updateInvoice 操作以处理来自 Zod 的验证错误
  • 在组件中显示错误,并添加 ARIA 标签提升可访问性

完成后,展开下方代码片段查看解决方案:

On this page