在上一章中,我们讨论了如何捕获错误(包括 404 错误)并向用户显示回退页面。然而,还有一个关键问题尚未讨论:表单验证。现在,我们来看如何使用服务器操作实现服务器端验证,以及如何使用 React 的 useActionState
钩子来显示表单错误,同时确保无障碍性!
在本章中
我们将涵盖以下主题:
- 如何在 Next.js 中使用
eslint-plugin-jsx-a11y
实现无障碍性最佳实践。 - 如何实现服务器端表单验证。
- 如何使用 React 的
useActionState
钩子处理表单错误,并将其显示给用户。
什么是无障碍性?
无障碍性是指设计和实现所有人都能使用的 Web 应用程序,包括那些有残疾的人。这是一个广泛的话题,涉及许多领域,如键盘导航、语义 HTML、图像、颜色、视频等。
虽然我们不会在本课程中深入探讨无障碍性,但我们将讨论 Next.js 中提供的一些无障碍功能以及一些使应用程序更具无障碍性的常见做法。
如果你想了解更多关于无障碍性的内容,我们推荐 web.dev 的 Learn 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
:
pnpm lint
这将指导你为项目安装和配置 ESLint。如果你现在运行 pnpm lint
,你应该会看到如下输出:
✔ 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
,你应该会看到如下警告:
./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 不是必需的步骤,但它有助于在开发过程中捕捉到无障碍性问题。
改进表单的无障碍性
我们已经在表单中采取了三项措施来提高无障碍性:
-
语义(Semantic) HTML:使用语义元素(如
<input>
、<option>
等)而不是<div>
。这使得辅助技术(AT)能够更好地聚焦输入元素并为用户提供适当的上下文信息,使表单更易于导航和理解。 - **标签(labelling)**:包含
<label>
和htmlFor
属性,确保每个表单字段都有描述性文本标签。这提高了辅助技术的支持,提供了上下文信息,并且还增强了可用性,用户可以通过点击标签聚焦到相应的输入字段。 - **焦点轮廓(Focus Outline)**:正确地样式化字段,以在它们处于焦点时显示轮廓。这对于无障碍性至关重要,因为它以视觉方式指示页面上的活动元素,帮助键盘和屏幕阅读器用户了解他们在表单中的位置。你可以通过按
tab
键来验证这一点。
这些做法为使你的表单更具无障碍性奠定了良好的基础。然而,它们并未解决 表单验证 和 错误 问题。
表单验证
访问 http://localhost:3000/dashboard/invoices/create,然后提交一个空表单。会发生什么?
你会发现出现了一个错误!这是因为你向服务器操作发送了空的表单值。你可以通过在客户端或服务器上验证表单来防止这种情况。
客户端验证
在客户端验证表单有几种方式。最简单的方法是依靠浏览器提供的表单验证功能,为表单中的 <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 />
再次提交表单。如果你尝试提交带有空值的表单,浏览器将显示警告。
这种方法通常可以接受,因为一些辅助技术支持浏览器的验证功能。
客户端验证的另一种替代方法是服务器端验证。让我们看看如何在接下来的部分中实现它。现在,如果你添加了 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
可以是你定义的任何内容,在这种情况下,创建一个包含两个空键 message
和 errors
的对象,并从 actions.ts
文件中导入 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: 'Please select a customer.', }), amount: z.coerce.number().gt(0, { message: 'Please enter an amount greater than $0.' }), status: z.enum(['pending', 'paid'], { invalid_type_error: 'Please select an invoice status.', }), date: z.string(), });
-
customerId
——Zod 已经会在客户
ID 未通过验证时抛出一个通用错误消息。因此,我们添加了 invalid_type_error
以设置错误消息。
-
amount
——我们使用z.coerce.number()
,因为 Zod 将输入值作为字符串处理。我们还添加了一个验证,以确保发票金额大于零。 -
status
——这是一个枚举类型,因为发票只能具有 “待处理” 或 “已支付” 的状态。
更新表单架构后,修改 createInvoice
操作以在表单无效时抛出错误:
// /app/lib/actions.ts export async function createInvoice(values: z.infer<typeof FormSchema>) { const errors = validate(values, FormSchema); if (errors) throw new ValidationError(errors); // Otherwise, continue processing the form... }
-
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'), }); // 如果表单验证失败,提前返回错误信息。否则,继续执行。 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'); }
safeParse()
会返回一个包含 success
或 error
字段的对象。这有助于在不将逻辑放在 try/catch
块中时更优雅地处理验证。
在将信息发送到数据库之前,使用条件语句检查表单字段是否正确验证:
// /app/lib/actions.ts export async function createInvoice(prevState: State, formData: FormData) { // Validate form fields using 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) { // Validate form fields using Zod const validatedFields = CreateInvoice.safeParse({ customerId: formData.get('customerId'), amount: formData.get('amount'), status: formData.get('status'), }); // If form validation fails, return errors early. Otherwise, continue. if (!validatedFields.success) { return { errors: validatedFields.error.flatten().fieldErrors, message: 'Missing Fields. Failed to Create Invoice.', }; } // ... }
如果 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
,并检查所有内容是否正确连接。在 Dev Tools 中查看控制台,因为您的表单现在是客户端组件。
在上述代码中,您还添加了以下 aria
标签:
-
aria-describedby="customer-error"
:这在select
元素和错误消息容器之间建立关系,表示id="customer-error"
的容器描述了select
元素。当用户与select
框交互时,屏幕阅读器将读取该描述以通知他们错误。 -
id="customer-error"
:此id
属性唯一标识包含select
输入错误消息的 HTML 元素。这对于aria-describedby
建立关系是必要的。 -
aria-live="polite"
:屏幕阅读器应礼貌地通知用户div
内错误的更新。当内容发生变化时(例如用户更正错误),屏幕阅读器将宣布这些更改,但仅在用户空闲时通知他们,以避免打断。
练习:添加 aria
标签
使用上述示例,为剩余的表单字段添加错误信息。如果有任何字段缺失,您还应在表单底部显示一条消息。您的用户界面应如下图所示:
准备好后,运行 pnpm lint
以检查您是否正确使用了 aria
标签。
如果您想挑战自己,可以将本章学到的知识添加到 edit-form.tsx
组件中,进行表单验证。
您需要做以下几点:
- 在
edit-form.tsx
组件中添加useActionState
。 - 编辑
updateInvoice
操作以处理来自 Zod 的验证错误。 - 在组件中显示错误信息,并添加
aria
标签以提高可访问性。
准备好后,展开下面的代码片段以查看解决方案:
解决方案
编辑发票表单:
// **/app/ui/invoices/edit-form.tsx** // ... import { updateInvoice, State } from '@/app/lib/actions'; import { useActionState } from 'react'; export default function Edit InvoiceForm({ invoice, customers, }: { invoice: InvoiceForm; customers: CustomerField[]; }) { const initialState: State = { message: null, errors: {} }; const updateInvoiceWithId = updateInvoice.bind(null, invoice.id); const [state, formAction] = useActionState(updateInvoiceWithId, initialState); return <form action={formAction}></form>; }
服务器操作:
// **/app/lib/actions.ts** export async function updateInvoice( id: string, prevState: State, formData: FormData, ) { const validatedFields = UpdateInvoice.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; try { await sql` UPDATE invoices SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status} WHERE id = ${id} `; } catch (error) { return { message: '数据库错误:更新发票失败。' }; } revalidatePath('/dashboard/invoices'); redirect('/dashboard/invoices'); }