提升可访问性
在上一章节中,我们探讨了如何捕获错误(包括 404 错误)并向用户展示回退内容。然而,我们还需要讨论另一个关键部分:表单验证。让我们看看如何通过服务端操作 (Server Actions) 实现服务端验证,以及如何使用 React 的 useActionState
钩子展示表单错误——同时兼顾可访问性!
什么是可访问性?
可访问性 (accessibility) 是指设计和实现所有人都能使用的网页应用,包括残障人士。这是一个涵盖多个领域的广泛主题,例如键盘导航、语义化 HTML、图片、颜色、视频等。
虽然本课程不会深入探讨可访问性,但我们会讨论 Next.js 提供的可访问性功能以及一些使应用更具可访问性的常见实践。
如果你想了解更多关于可访问性的内容,我们推荐 web.dev 的 Learn Accessibility 课程。
在 Next.js 中使用 ESLint 可访问性插件
Next.js 在其 ESLint 配置中内置了 eslint-plugin-jsx-a11y
插件,帮助及早发现可访问性问题。例如,该插件会在以下情况发出警告:图片缺少 alt
文本、错误使用 aria-*
和 role
属性等。
如果你想尝试此功能,可以在 package.json
文件中添加 next lint
脚本:
"scripts": {
"build": "next build",
"dev": "next dev",
"start": "next start",
"lint": "next lint"
},
然后在终端运行 pnpm lint
:
pnpm lint
这将引导你安装并配置项目的 ESLint。如果现在运行 pnpm lint
,你应该会看到以下输出:
✔ No ESLint warnings or errors
但如果你的图片缺少 alt
文本会怎样呢?让我们试试看!
转到 /app/ui/invoices/table.tsx
并移除图片的 alt
属性。你可以使用编辑器的搜索功能快速找到 <Image>
:
<Image
src={invoice.image_url}
className="rounded-full"
width={28}
height={28}
alt={`${invoice.name}'s profile picture`} // 删除此行
/>
再次运行 pnpm lint
,你应该会看到以下警告:
./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
属性。例如:
<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"
指令将表单转为客户端组件:
'use client';
// ...
import { useActionState } from 'react';
在表单组件内部,useActionState
钩子:
- 接收两个参数:
(action, initialState)
- 返回两个值:
[state, formAction]
—— 表单状态和表单提交时调用的函数
将 createInvoice
操作作为参数传递给 useActionState
,并在 <form action={}>
属性中调用 formAction
。
// ...
import { useActionState } from 'react';
export default function Form({ customers }: { customers: CustomerField[] }) {
const [state, formAction] = useActionState(createInvoice, initialState);
return <form action={formAction}>...</form>;
}
initialState
可以是您定义的任何内容,本例中创建一个包含两个空键的对象:message
和 errors
,并从 actions.ts
文件导入 State
类型(State
目前还不存在,我们将在下一步创建):
// ...
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
:
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 金额必须大于 0status
- Zod 已会在状态字段为空时抛出错误(因为它期望 "pending" 或 "paid"),我们也添加了更友好的提示信息
接下来,更新 createInvoice
操作以接收两个参数 —— prevState
和 formData
:
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()
:
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()
会返回包含 success
或 error
字段的对象,这样无需将逻辑放在 try/catch
块中就能更优雅地处理验证。
在将信息发送到数据库前,用条件语句检查表单字段是否验证成功:
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 块外单独处理表单验证,可以为任何数据库错误返回特定消息。最终代码应如下所示:
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
访问错误。
添加一个三元运算符来检查每个特定错误。例如,在客户字段后可以添加:
<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 标签提升可访问性
完成后,展开下方代码片段查看解决方案: