Next.js14从入门到实战019:NextJS基础篇之表单提交验证与输入错误的显示

404 阅读6分钟

在上一章中,我们介绍了如何捕捉错误(包括 404 错误)并向用户显示。不过,我们还需要讨论另一块拼图:表单验证。让我们看看如何使用并实现服务器端验证,以及如何使用 useFormState 钩子显示表单错误!

在本章节中,你将学习

  • 如何将 eslint-plugin-jsx-a11y 与 Next.js 结合使用,以实现可访问性最佳实践。
  • 如何实施服务器端表单验证。
  • 如何使用 React useFormState 钩子处理表单错误并将其显示给用户。

什么是无障碍环境?

可访问性是指设计和实施每个人(包括残障人士)都能使用的网络应用程序。这是一个庞大的话题,涉及键盘导航、语义 HTML、图像、颜色、视频等多个领域。

虽然我们不会在本课程中深入探讨可访问性,但我们会讨论 Next.js 中可用的可访问性功能和一些常见做法,以使您的应用程序更具可访问性。

如果您想了解更多有关辅助功能的信息,我们向您推荐 web.dev 提供的 学习辅助功能 课程。

在Next.js中使用ESLint辅助功能插件

默认情况下,Next.js 包含 eslint-plugin-jsx-a11y 插件,以帮助及早发现可访问性问题。例如,如果您的图片中没有 alt 文本、错误使用 aria-* 和 role 属性等,该插件就会发出警告。

让我们看看它是如何工作的!

在 package.json 文件中添加 next lint 作为脚本:

/package.json

"scripts": {
    "build": "next build",
    "dev": "next dev",
    "seed": "node -r dotenv/config ./scripts/seed.js",
    "start": "next start",
    "lint": "next lint"
},

然后在终端运行 npm run lint :

npm run 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`} // Delete this line
/>

现在再次运行 npm run 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

如果您尝试将应用程序部署到 Vercel,警告也会显示在构建日志中。这是因为 next lint 作为构建过程的一部分运行。因此,您可以在本地运行 lint 以在部署应用程序之前发现可访问性问题。

提高表格的无障碍性

我们已经做了三件事来提高表格的无障碍性:

  • 语义 HTML:使用语义元素( <input> 、 <option> 等)而不是 <div> 。这样,辅助技术(AT)就能关注输入元素,并向用户提供适当的上下文信息,从而使表单更易于浏览和理解。
  • 标签:包括 <label> 和 htmlFor 属性可确保每个表单字段都有一个描述性文本标签。这样可以通过提供上下文来改进 AT 支持,还可以让用户点击标签来关注相应的输入字段,从而提高可用性。
  • 聚焦轮廓:字段的样式正确,当它们处于焦点时会显示一个轮廓。这对可访问性至关重要,因为它可以直观地显示页面上的活动元素,帮助键盘和屏幕阅读器用户了解他们在表单中的位置。您可以按 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
/>

再次提交表单后,如果尝试提交空值表单,浏览器就会发出警告。

这种方法一般没有问题,因为有些 AT 支持浏览器验证。

服务端验证是客户端验证的另一种选择。让我们在下一节看看如何实现它。现在,如果您添加了 required 属性,请将其删除。

服务端验证

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

  • 在将数据发送到数据库之前,确保数据符合预期格式。
  • 降低恶意用户绕过客户端验证的风险。
  • 对于什么是有效数据,要有唯一的真理来源。

在 create-form.tsx 组件中,从 react-dom 导入 useFormState 钩子。由于 useFormState 是一个钩子,因此您需要使用 "use client" 指令将表单转化为客户端组件:

/app/ui/invoices/create-form.tsx

'use client';
 
// ...
import { useFormState } from 'react-dom';

在表单组件中,使用 useFormState 钩子:

  • 接收两个参数: (action, initialState) .
  • 返回两个值: [state, dispatch] - 表单状态,以及一个调度函数(类似于 useReducer)

将 createInvoice 动作作为 useFormState 的参数传递,并在 <form action={}> 属性中调用 dispatch 。

/app/ui/invoices/create-form.tsx

// ...
import { useFormState } from 'react-dom';
 
export default function Form({ customers }: { customers: CustomerField[] }) {
  const [state, dispatch] = useFormState(createInvoice, initialState);
 
  return <form action={dispatch}>...</form>;
}

initialState 可以是您定义的任何内容,在本例中,创建一个包含两个空键的对象: message 和 errors 。

/app/ui/invoices/create-form.tsx

// ...
import { useFormState } from 'react-dom';
 
export default function Form({ customers }: { customers: CustomerField[] }) {
  const initialState = { message: null, errors: {} };
  const [state, dispatch] = useFormState(createInvoice, initialState);
 
  return <form action={dispatch}>...</form>;
}

一开始可能会让人感到困惑,但更新服务器操作后就会明白了。现在就让我们来做这件事。.

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

/app/lib/action.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 就会抛出错误,因为它期望的类型是 string 。但是,如果用户没有选择客户,我们可以添加一条友好信息。
  • amount - 由于您将金额类型从 string 强制改为 number ,因此如果字符串为空,它将默认为 0。让我们用 .gt() 函数告诉 Zod,我们总是希望金额大于 0。
  • status - 如果状态字段为空,Zod 就会出错,因为它希望状态为 "待定 "或 "已支付"。如果用户没有选择状态,我们还可以添加一条友好信息。

接下来,更新 createInvoice 操作,使其接受两个参数:

/app/lib/actions.ts

// This is temporary until @types/react-dom is updated
export type State = {
  errors?: {
    customerId?: string[];
    amount?: string[];
    status?: string[];
  };
  message?: string | null;
};
 
export async function createInvoice(prevState: State, formData: FormData) {
  // ...
}
  • formData - 同上。
  • prevState - 包含从 useFormState 钩子传递的状态。在本示例中,您不会在操作中使用它,但它是一个必备参数

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

/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 块中。

在将信息发送到数据库之前,请检查表单字段是否已通过条件验证:

/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'),
  });
 
  // 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) {
  // Validate form 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.',
    };
  }
 
  // Prepare data for insertion into the database
  const { customerId, amount, status } = validatedFields.data;
  const amountInCents = amount * 100;
  const date = new Date().toISOString().split('T')[0];
 
  // Insert data into the database
  try {
    await sql`
      INSERT INTO invoices (customer_id, amount, status, date)
      VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
    `;
  } catch (error) {
    // If a database error occurs, return a more specific error.
    return {
      message: 'Database Error: Failed to Create Invoice.',
    };
  }
 
  // Revalidate the cache for the invoices page and redirect the user.
  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}

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

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

/app/ui/invoices/create-form.tsx

<form action={dispatch}>
  <div className="rounded-md bg-gray-50 p-4 md:p-6">
    {/* Customer Name */}
    <div className="mb-4">
      <label htmlFor="customer" className="mb-2 block text-sm font-medium">
        Choose customer
      </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>
            Select a customer
          </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 内的错误内容更新时,页面应礼貌地通知用户。当内容发生变化时(例如,当用户纠正错误时),页面将显示这些变化,但只在用户空闲时显示,以免打扰用户。

练习:添加其它错误提示标签

使用上面的示例,在剩余的表单字段中添加错误信息。如果缺少任何字段,还应在表单底部显示一条信息。您的用户界面应该如下所示:

image.png

准备就绪后,运行 npm run lint 检查是否正确使用了 aria 标签。

如果你想挑战一下自己,可以利用本章所学的知识,在 edit-form.tsx 组件中添加表单验证。

您需要

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

代码如下:

/app/ui/invoices/edit-form.tsx

export default function EditInvoiceForm({
  invoice,
  customers,
}: {
  invoice: InvoiceForm;
  customers: CustomerField[];
}) {
  const initialState = { message: null, errors: {} };
  const updateInvoiceWithId = updateInvoice.bind(null, invoice.id);
  const [state, dispatch] = useFormState(updateInvoiceWithId, initialState);
 
  return <form action={dispatch}></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: 'Missing Fields. Failed to Update Invoice.',
    };
  }
 
  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: 'Database Error: Failed to Update Invoice.' };
  }
 
  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}