nextjs-提交表单数据

原文链接:https://nextjs.org/learn/dashboard-app/mutating-data
在上一章中,您使用URL search Params和Next.js API实现了搜索和分页。让我们添加创建、更新和删除Invoices 页面的功能,继续使用Invoices 页面!

本章目标

  • 什么是React Server Actions,怎么使用React Server Actions进行提交数据
  • 如何使用表单和服务器组件。
  • 使用原生的 formData 对象的最佳实践,包括类型验证。
  • 如何使用revalidatePath API重新验证客户端缓存。
  • 如何创建具有特定ID的动态路由。

什么是Server Actions

React Server Actions允许您直接在服务器上运行异步代码。该功能取消了通过创建API才能提交数据的流程。现在,您可以编写在服务器上执行的异步函数,这些函数可以从客户端或服务器组件调用。
安全是web应用程序的首要任务,因为它们可能容易受到各种威胁。这就是服务器操作的作用所在。它们提供了一个有效的安全解决方案,可以抵御不同类型的攻击,保护您的数据安全,并确保授权访问。Server Actions 通过POST请求等技术实现这一点,加密闭包、严格的输入检查、错误消息哈希和主机限制等所有方案共同努力,显著提高应用程序的安全性。

使用forms的server actions

在react中,你可以使用react中form的action属性进行回调。该操作将自动接收捕获form表格的原始FormData对象数据。
例如:

// Server Component
export default function Page() {
  // Action
  async function create(formData: FormData) {
    'use server';
 
    // Logic to mutate data...
  }
 
  // Invoke the action using the "action" attribute
  return <form action={create}>...</form>;
}

在服务器组件中调用服务器操作的一个优点是渐进式增强——即使客户端禁用了JavaScript,表单也能工作。

next.js的server actions

server actions是深度集成到next.js框架中。通过服务器操作提交表单时,您不仅可以使用该操作来变异数据,您还可以使用诸如revalidatePath和revalidateTag之类的API来重新验证关联的缓存。

创建Invoices数据

以下是创建新发票的步骤

  1. 创建一个表单来捕获用户的输入。
  2. 创建server action 并从表单中调用它。
  3. 在 server action 中,从formData中提取数据
  4. 验证并准备要插入数据库的数据。
  5. 插入数据并处理任何错误。
  6. 重新验证缓存并将用户重定向回invoice页面。

新加一个路由和form

首先,在/invoices文件夹中,使用page.tsx文件添加名为/create的路由:
image.png
您将使用此路路由创建新invoie数据。在page.tsx文件中,粘贴以下代码,然后花一些时间进行研究:

import Form from '@/app/ui/invoices/create-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchCustomers } from '@/app/lib/data';
 
export default async function Page() {
  const customers = await fetchCustomers();
 
  return (
    <main>
      <Breadcrumbs
        breadcrumbs={[
          { label: 'Invoices', href: '/dashboard/invoices' },
          {
            label: 'Create Invoice',
            href: '/dashboard/invoices/create',
            active: true,
          },
        ]}
      />
      <Form customers={customers} />
    </main>
  );
}

您的页面是一个服务器组件,用于获取客户并将其传递给组件。为了节省时间,我们已经为您创建了组件。
导航到组件,您会看到该表单:

  • 有一个<select>的下拉组件,内容是包含了customers的列表。
  • 包含一个类型为type="number"的input组件,设置amount数据
  • 包含一个类型为type="radio"的input组件,用来设置status数据。
  • 有一个类型为 type="submit"的提交按钮

打开http://localhost:3000/dashboard/invoices/create,您应该会看到以下UI:
image.png

创建server action

很好,现在让我们创建一个服务器操作,该操作将在提交表单时调用。
导航到lib目录并创建一个名为actions.ts的新文件。在该文件的顶部,添加React use server指令:

'use server';

通过添加'use server',将文件中所有导出的函数标记为“服务器操作”。然后可以导入这些服务器功能,并在客户端和服务器组件中使用这些功能。
您也可以通过在操作中添加'use server',直接在服务器组件中编写服务器操作。但对于本课程,我们将把它们全部组织在一个单独的文件中。
在actions.ts文件中,创建一个接受formData的新异步函数:

'use server';
 
export async function createInvoice(formData: FormData) {}

然后,在组件中,从actions.ts文件导入createInvoice。向元素添加一个action属性,并调用createInvoice操作。

import { customerField } from '@/app/lib/definitions';
import Link from 'next/link';
import {
  CheckIcon,
  ClockIcon,
  CurrencyDollarIcon,
  UserCircleIcon,
} from '@heroicons/react/24/outline';
import { Button } from '@/app/ui/button';
import { createInvoice } from '@/app/lib/actions';
 
export default function Form({
  customers,
}: {
  customers: customerField[];
}) {
  return (
    <form action={createInvoice}>
      // ...
  )
}

从formData中提取数据

回到 actions.ts 文件中,您需要提取formData的值。有以下几种方法可以使用,对于这个例子,让我们使用.get(name)方法。

'use server';
 
export async function createInvoice(formData: FormData) {
  const rawFormData = {
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  };
  // Test it out:
  console.log(rawFormData);
}

提示:如果您使用的表单有很多字段,您可能需要考虑将entries()方法与JavaScript的Object.fromEntries()一起使用。例如
const rawFormData = Object.fromEntries(formData.entries());

要检查所有连接是否正确,请继续尝试该表单。提交后,您应该会在终端中看到刚刚输入表单的数据。
现在您的数据是一个对象,它将更容易使用。

验证和准备数据

在将表单数据发送到数据库之前,您希望确保它的格式和类型正确。如果您还记得本课程前面的内容,您的invoices表需要以下格式的数据:

export type Invoice = {
  id: string; // Will be created on the database
  customer_id: string;
  amount: number; // Stored in cents
  status: 'pending' | 'paid';
  date: string;
};

到目前为止,从form中您只有customer_id, amount, and status 数据。

类型验证和是否必填

重要的是要验证表单中的数据是否与数据库中的预期类型一致。例如,如果在操作中添加console.log:

console.log(typeof rawFormData.amount);

你将注意到amount是string类型,而不是number类型。但是input类型为type=“number”的输入元素实际上返回的是字符串,而不是数字!
要处理类型验证,您有几个选项。虽然您可以手动验证类型,但使用类型验证库可以节省时间和精力。对于您的示例,我们将使用Zod,这是一个TypeScript优先验证库,可以为您简化此任务。
在actions.ts文件中,导入Zod并定义一个与表单对象匹配的结构。此结构将在将formData保存到数据库之前对其进行验证。

'use server';
 
import { z } from 'zod';
 
const FormSchema = z.object({
  id: z.string(),
  customerId: z.string(),
  amount: z.coerce.number(),
  status: z.enum(['pending', 'paid']),
  date: z.string(),
});
 
const CreateInvoice = FormSchema.omit({ id: true, date: true });
 
export async function createInvoice(formData: FormData) {
  // ...
}

zod插件将amount字段,设置为在验证其类型的同时将字符串强制(更改)为数字。
然后,您可以将rawFormData传递给CreateInvoice以验证类型:

// ...
export async function createInvoice(formData: FormData) {
  const { customerId, amount, status } = CreateInvoice.parse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
}
以分为单位处理值

通常,在数据库中存储以分为单位的货币值是一种很好的做法,以消除JavaScript浮点错误并确保更高的准确性。
让我们把金额换算成分:

// ...
export async function createInvoice(formData: FormData) {
  const { customerId, amount, status } = CreateInvoice.parse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
  const amountInCents = amount * 100;
}
创建日期数据

让我们为invoice的创建日期创建一个格式为“YYYY-MM-DD”的新日期:

// ...
export async function createInvoice(formData: FormData) {
  const { customerId, amount, status } = CreateInvoice.parse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
  const amountInCents = amount * 100;
  const date = new Date().toISOString().split('T')[0];
}

将数据插入数据库

现在您已经拥有了数据库所需的所有值,可以创建一个SQL查询,将新发票插入数据库并传入变量:

import { z } from 'zod';
import { sql } from '@vercel/postgres';
 
// ...
 
export async function createInvoice(formData: FormData) {
  const { customerId, amount, status } = CreateInvoice.parse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
  const amountInCents = amount * 100;
  const date = new Date().toISOString().split('T')[0];
 
  await sql`
    INSERT INTO invoices (customer_id, amount, status, date)
    VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
  `;
}

现在,我们没有处理任何错误。我们将在下一章中做这件事。

重新验证和重定向

Next.js有一个客户端路由器缓存,它将路由存储在用户的浏览器中一段时间。与预获取一起,该缓存确保用户可以在路由之间快速导航,同时减少对服务器的请求数量。
由于您正在更新invoices路由中显示的数据,因此需要清除此缓存并触发对服务器的新请求。您可以使用Next.js中的revalidatePath函数来执行此操作:

'use server';
 
import { z } from 'zod';
import { sql } from '@vercel/postgres';
import { revalidatePath } from 'next/cache';
 
// ...
 
export async function createInvoice(formData: FormData) {
  const { customerId, amount, status } = CreateInvoice.parse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
  const amountInCents = amount * 100;
  const date = new Date().toISOString().split('T')[0];
 
  await sql`
    INSERT INTO invoices (customer_id, amount, status, date)
    VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
  `;
 
  revalidatePath('/dashboard/invoices');
}

一旦数据库已经被更新,将重新验证/dashboard/invoits路径,并从服务器获取新数据。此时,您还需要将用户重定向回/dashboard/invoices页面。您可以使用Next.js中的重定向函数来完成此操作:

'use server';
 
import { z } from 'zod';
import { sql } from '@vercel/postgres';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
 
// ...
 
export async function createInvoice(formData: FormData) {
  // ...
 
  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}

祝贺您刚刚实现了第一个服务器操作。如果一切正常,请添加新 invoices 数据进行测试:

  • 提交时应该重定向到 /dashboard/invoices路由下
  • 可以在表格的顶部看到新提交的数据

编辑更新Invoices数据

更新invoices表单类似于创建发票表单,更新需要传递发票id来更新数据库中的记录。让我们看看如何获取和传递invoices id。
您将采取以下步骤更新invoices

  • 使用 invoices id创建一个新的动态路由。
  • 从页面参数中读取 invoices id。
  • 从数据库中获取特定 invoices 数据。
  • 使用 invoices 数据预先填充表单。
  • 更新数据库中的 invoices 数据。

使用 invoices id创建一个新的动态路由

Next.js允许您在不知道确切的路由名称并希望根据props创建路由时创建动态路由。这可能是博客文章标题、产品页面等。可以通过将文件夹的名称括在方括号[]中来创建动态路由。例如[id], [post] or [slug]
在你的/invoices文件夹中,创建一个名为[id]的新动态路由,然后使用page.tsx文件创建一个名为edit的新路由。您的文件结构应该如下所示:
image.png
<Table>组件中,注意到<UpdateInvoice />组件它从表记录中接收 invoices 的id。

export default async function InvoicesTable({
  query,
  currentPage,
}: {
  query: string;
  currentPage: number;
}) {
  return (
    // ...
    <td className="flex justify-end gap-2 whitespace-nowrap px-6 py-4 text-sm">
      <UpdateInvoice id={invoice.id} />
      <DeleteInvoice id={invoice.id} />
    </td>
    // ...
  );
}

导航到<UpdateInvoice/>组件,并更新链接的href以接受id prop。可以使用模板文字链接到动态路由:

import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
import Link from 'next/link';
 
// ...
 
export function UpdateInvoice({ id }: { id: string }) {
  return (
    <Link
      href={`/dashboard/invoices/${id}/edit`}
      className="rounded-md border p-2 hover:bg-gray-100"
    >
      <PencilIcon className="w-5" />
    </Link>
  );
}

从页面参数获取 invoice id

回到<Page>组件,粘贴以下代码:

import Form from '@/app/ui/invoices/edit-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchCustomers } from '@/app/lib/data';
 
export default async function Page() {
  return (
    <main>
      <Breadcrumbs
        breadcrumbs={[
          { label: 'Invoices', href: '/dashboard/invoices' },
          {
            label: 'Edit Invoice',
            href: `/dashboard/invoices/${id}/edit`,
            active: true,
          },
        ]}
      />
      <Form invoice={invoice} customers={customers} />
    </main>
  );
}

现在你应该可以看到和/create页面有相似之处,除了导入了一个不同的表单(从edit-form.tsx文件);此表单预先填充表单的defaultValue 默认值,customer’s name, invoice amount, and status;要预填充表单字段,您需要使用id获取特定invoice数据。
除了searchParams,页面组件还接受一个名为params的props,您可以使用它来访问id。更新您的<page>组件以接收props:

import Form from '@/app/ui/invoices/edit-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchCustomers } from '@/app/lib/data';
 
export default async function Page({ params }: { params: { id: string } }) {
  const id = params.id;
  // ...
}

获取特定的invoice

  • 导入一个名为fetchInvoiceById的新函数,并将该id作为参数传递。
  • 导入fetchCustomers可获取下拉列表中的客户名称。

您可以使用Promise.all并行获取invoice和customers数据

import Form from '@/app/ui/invoices/edit-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchInvoiceById, fetchCustomers } from '@/app/lib/data';
 
export default async function Page({ params }: { params: { id: string } }) {
  const id = params.id;
  const [invoice, customers] = await Promise.all([
    fetchInvoiceById(id),
    fetchCustomers(),
  ]);
  // ...
}

您将在终端中看到invoice prop 的临时TS错误,因为invoice可能undefined。现在不要担心,您将在下一章添加错误处理时解决它。
太棒了现在,测试所有东西是否正确连接。查看http://localhost:3000/dashboard/invoices然后单击铅笔图标编辑invoice。导航后,您应该会看到一个预先填充了invoice详细信息的表单:
image.png
URL还应更新为如下id:: [http://localhost:3000/dashboard/invoice/uuid/edit](http://localhost:3000/dashboard/invoice/uuid/edit)

将id传递给服务器操作

最后,您希望将id传递给服务器操作,以便更新数据库中的正确记录。您不能像这样将id作为参数传递:

// Passing an id as argument won't work
<form action={updateInvoice(id)}>

相反,您可以使用JS绑定将id传递给服务器操作。这将确保传递给服务器操作的任何值都经过编码。

// ...
import { updateInvoice } from '@/app/lib/actions';
 
export default function EditInvoiceForm({
  invoice,
  customers,
}: {
  invoice: InvoiceForm;
  customers: CustomerField[];
}) {
  const updateInvoiceWithId = updateInvoice.bind(null, invoice.id);
 
  return (
    <form action={updateInvoiceWithId}>
      <input type="hidden" name="id" value={invoice.id} />
    </form>
  );
}

注意:在表单中使用隐藏的输入字段也可以(例如<input type="hidden" name="id" value={invoice.id}/>)。但是,这些值将在HTML源中显示为全文,这对于ID等敏感数据来说并不理想。

然后,在actions.ts文件中,创建一个新的操作 updateInvoice:

// Use Zod to update the expected types
const UpdateInvoice = FormSchema.omit({ id: true, date: true });
 
// ...
 
export async function updateInvoice(id: string, formData: FormData) {
  const { customerId, amount, status } = UpdateInvoice.parse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
 
  const amountInCents = amount * 100;
 
  await sql`
    UPDATE invoices
    SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status}
    WHERE id = ${id}
  `;
 
  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}

对比createInvoice操作,更新有如下操作

  • 从formData中提取数据。
  • 使用Zod验证类型。
  • 将amount转为分
  • 将变量传递给SQL查询
  • 调用revalidatePath以清除客户端缓存并发出新的服务器请求。
  • 调用redirect将用户重定向到invoice的页面。

通过编辑invoice进行测试。提交表单后,应将您重定向到invoice页面,并更新invoice。

删除Invoices数据

要使用服务器操作删除invoice,请将删除按钮包装在元素中,并使用bind将id传递给服务器操作:

import { deleteInvoice } from '@/app/lib/actions';
 
// ...
 
export function DeleteInvoice({ id }: { id: string }) {
  const deleteInvoiceWithId = deleteInvoice.bind(null, id);
 
  return (
    <form action={deleteInvoiceWithId}>
      <button type="submit" className="rounded-md border p-2 hover:bg-gray-100">
        <span className="sr-only">Delete</span>
        <TrashIcon className="w-4" />
      </button>
    </form>
  );
}

在actions.ts文件中,创建一个名为deleteInvoice的新操作。

export async function deleteInvoice(id: string) {
  await sql`DELETE FROM invoices WHERE id = ${id}`;
  revalidatePath('/dashboard/invoices');
}

由于此操作是在/dashboard/invoices路径中调用的,因此不需要调用redirect。调用revalidatePath将触发一个新的服务器请求并重新呈现表。

您还可以阅读有关服务器操作安全性的更多信息,以获得更多学习

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值