数据变更 3个月前

编程语言
715
数据变更

在上一章中,你使用了 URL 搜索参数和 Next.js API 实现了搜索和分页。现在,让我们继续完善发票页面,添加创建、更新和删除发票的功能!

在本章中...

我们将讨论以下内容:

  • 什么是 React 服务器操作(Server Actions)以及如何使用它们来修改数据。
  • 如何使用表单和服务器组件(Server Components)。
  • 使用原生 formData 对象的最佳实践,包括类型验证。
  • 如何使用 revalidatePath API 重新验证客户端缓存。
  • 如何创建具有特定 ID 的动态路由段。

什么是服务器操作?

React 服务器操作允许你直接在服务器上运行异步代码。它们消除了创建 API 端点来修改数据的需要。相反,你可以编写在服务器上执行的异步函数,并可以从客户端或服务器组件中调用它们。

安全性是 Web 应用程序的重中之重,因为它们可能会受到各种威胁。这就是服务器操作的作用所在。它们提供了有效的安全解决方案,保护数据免受不同类型的攻击,确保授权访问。服务器操作通过 POST 请求、加密闭包、严格的输入检查、错误消息哈希和主机限制等技术实现了这一点,共同显著提高了应用程序的安全性。

如何使用表单与服务器操作

在 React 中,你可以使用 <form> 元素中的 action 属性来调用操作。该操作将自动接收包含捕获数据的原生 FormData 对象。

例如:

// 服务器组件
export default function Page() {
  // 操作
  async function create(formData: FormData) {
    'use server';
    // 修改数据的逻辑...
  }

  // 使用 "action" 属性调用操作
  return (
    <form action={create}>
      {/* 表单内容 */}
    </form>
  );
}

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

Next.js 与服务器操作

服务器操作还与 Next.js 的 缓存(caching) 深度集成。当通过服务器操作提交表单时,你不仅可以使用操作来修改数据,还可以使用如 revalidatePathrevalidateTag 等 API 来重新验证相关的缓存。

现在是时候进行测验了!

使用服务器操作的一个好处是什么?

A. 改善 SEO。
B. 渐进增强。
C. 更快的网站。
D. 数据加密。

检查答案

让我们看看所有内容如何协同工作!

创建发票

以下是创建新发票的步骤:

  1. 创建一个表单来捕获用户输入。
  2. 创建一个服务器操作并从表单中调用它。
  3. 在服务器操作内部,从 formData 对象中提取数据。
  4. 验证并准备要插入到数据库中的数据。
  5. 插入数据并处理任何错误。
  6. 重新验证缓存并将用户重定向回发票页面。

1. 创建新路由和表单

首先,在 /invoices 文件夹中,添加一个名为 /create 的新路由段,并创建一个 page.tsx 文件:

image

你将使用这个路由来创建新的发票。在 page.tsx 文件中,粘贴以下代码,然后花些时间学习它:

// /dashboard/invoices/create/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: '发票', href: '/dashboard/invoices' },
          {
            label: '创建发票',
            href: '/dashboard/invoices/create',
            active: true,
          },
        ]}
      />
      <Form customers={customers} />
    </main>
  );
}

你的页面是一个服务器组件,它获取 customers 并将其传递给 <Form> 组件。为了节省时间,我们已经为你创建了 <Form> 组件。

导航到 <Form> 组件,你会看到表单:

  • 有一个 <select> 元素(下拉框),列出 customers
  • 有一个 type="number"<input> 元素用于 amount
  • 有两个 type="radio"<input> 元素用于状态。
  • 有一个 type="submit" 的按钮。

http://localhost:3000/dashboard/invoices/create 上,你应该会看到以下 UI:

image

2. 创建服务器操作

很好,现在让我们创建一个服务器操作,这个操作将在表单提交时被调用。

导航到 lib 目录,并创建一个名为 actions.ts 的新文件。在此文件的顶部,添加 React use server 指令:

// /app/lib/actions.ts
'use server';

通过添加 'use server',你标记文件中所有导出的函数为服务器操作。这些服务器函数可以从客户端和服务器组件中导入和使用。

你也可以直接在服务器组件中编写服务器操作,通过在操作内部添加 "use server"。但在本课程中,我们将它们都组织在一个单独的文件中。

actions.ts 文件中,创建一个新的异步函数,接受 formData

// /app/lib/actions.ts
'use server';
export async function createInvoice(formData: FormData) {}

然后,在 <Form> 组件中,从 actions.ts 文件中导入 createInvoice。为 <form> 元素添加 action 属性,并调用 createInvoice 操作:

// /app/ui/invoices/create-form.tsx
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}>
      {/* 表单内容 */}
    </form>
  );
}

值得知道:在 HTML 中,你会将 URL 传递给 action 属性。这个 URL 将是表单数据应该提交到的目的地(通常是 API 端点)。

然而,在 React 中,action 属性被视为一个特殊的 prop —— 这意味着 React 在其基础上进行了扩展,以允许调用操作。

在幕后,服务器操作创建了一个 POST API 端点。这就是为什么在使用服务器操作时,你不需要手动创建 API 端点的原因。

3. 从 formData 中提取数据

回到 actions.ts 文件,你需要从 formData 中提取值,有几种方法可以使用。对于这个示例,我们将使用 .get(name) 方法:

// /app/lib/actions.ts
'use server';
export async function createInvoice(formData: FormData) {
  const rawFormData = {
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  };

  // 测试一下
  console.log(rawFormData);
}

提示:如果你处理的表单字段很多,你可能会考虑使用 entries() 方法结合 JavaScript 的 Object.fromEntries()。 例如

const formDataEntries = Object.fromEntries(formData.entries());
console.log(formDataEntries);

4. 验证和准备数据

在将表单数据发送到数据库之前,您需要确保其格式和类型正确。如课程中早期所述,您的发票表格期望的数据格式如下:

// /app/lib/definitions.ts
export type Invoice = {  
  id: string; // 将在数据库中创建  
  customer_id: string;  
  amount: number; // 存储为分  
  status: 'pending' | 'paid';  
  date: string;  
};

到目前为止,您只从表单中获得了 customer_idamountstatus

类型验证和强制转换

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

console.log(typeof rawFormData.amount);

你会注意到 amount 的类型是 string 而不是 number。这是因为 type="number"input 元素实际上返回的是字符串,而不是数字!

要处理类型验证,您可以手动验证类型,也可以使用类型验证库来节省时间和精力。对于您的示例,我们将使用 Zod,这是一个以 TypeScript 为主的验证库,可以简化这个任务。

在您的 actions.ts 文件中,导入 Zod 并定义一个与表单对象形状匹配的 schema。这个 schema 将在将数据保存到数据库之前验证 formData

// /app/lib/actions.ts
'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) {
  // ...
}

amount 字段特别设置为强制转换(从字符串转换)为数字,同时验证其类型。

然后,您可以将 rawFormData 传递给 CreateInvoice 来验证类型:

// /app/lib/actions.ts
// ...
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 浮点数错误并确保更高的准确性。

让我们将金额转换为分:

// /app/lib/actions.ts
// ...
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;
}

创建新日期

最后,让我们创建一个格式为 "YYYY-MM-DD" 的新日期,用于发票的创建日期:

// /app/lib/actions.ts
// ...
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];
}

5. 将数据插入到数据库中

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

// /app/lib/actions.ts
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})
  `;
}

目前,我们还没有处理任何错误。在下一章中我们会处理它们。现在,让我们继续下一步。

6. 重新验证并重定向

Next.js 具有一个 客户端路由缓存(a Client-side Router Cache),它在用户的浏览器中存储路由片段一段时间。加上 预取(prefetching),此缓存确保用户可以快速在路由之间导航,同时减少对服务器的请求数量。

由于您正在更新发票路由中显示的数据,您需要清除此缓存并触发对服务器的新请求。您可以使用 Next.js 的 revalidatePath 函数来完成此操作:

// /app/lib/actions.ts
'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) {
  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');
  redirect('/dashboard/invoices');
}

数据库更新后,/dashboard/invoices 路径将会重新验证,并从服务器获取最新的数据。

此时,您还需要将用户重定向回 /dashboard/invoices 页面。您可以使用 Next.js 的 redirect 函数来完成此操作:

// /app/lib/actions.ts
'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');
}

恭喜您!您刚刚实现了第一个服务器操作。通过添加新发票来测试它,如果一切正常:

  1. 您应在提交后重定向到 /dashboard/invoices 路由。
  2. 您应在表格顶部看到新发票。

更新发票(invoice)

更新发票表单类似于创建发票表单,唯一的区别是您需要传递发票的 id 以更新数据库中的记录。让我们看看如何获取和传递发票 id

您需要执行以下步骤来更新发票:

  1. 创建一个带有发票 id 的动态路由段。
  2. 从页面参数中读取发票 id
  3. 从数据库中获取特定的发票。
  4. 用发票数据预填充表单。
  5. 更新数据库中的发票数据。

1. 创建带有发票 id 的动态路由段

Next.js 允许您创建 动态路由段(Dynamic Route Segments),当您不知道确切的段名并想根据数据创建路由时。这可以是博客文章标题、产品页面等。您可以通过将文件夹名称用方括号括起来来创建动态路由段。例如 [id][post][slug]

在你的 /invoices 文件夹中,创建一个新的动态路由,命名为 [id],然后在该文件夹中创建一个名为 edit 的新路由,并添加一个 page.tsx 文件。你的文件结构应如下所示:

image

在你的 <Table> 组件中,注意到有一个 <UpdateInvoice /> 按钮,它从表格记录中接收发票的 ID。

// /app/ui/invoices/table.tsx
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 /> 组件,并更新 Linkhref 以接受 id 属性。你可以使用模板字面量来链接到动态路由段:

// /app/ui/invoices/buttons.tsx
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>
  );
}

2. 从页面参数中读取发票 id

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

// /app/dashboard/invoices/[id]/edit/page.tsx
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;
  // ...
}

注意,它与 /create 发票页面类似,不同之处在于它导入了不同的表单(来自 edit-form.tsx 文件)。该表单应预填充客户名称、发票金额和状态的 defaultValue。要预填充表单字段,你需要使用 id 获取特定的发票。

除了 searchParams,页面组件还接受一个名为 params 的属性,你可以使用它来访问 id。更新你的 <Page> 组件以接收这个属性:

// /app/dashboard/invoices/[id]/edit/page.tsx
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(),
  ]);
  // ...
}

3. 获取特定的发票

然后:

  • 导入一个名为 fetchInvoiceById 的新函数,并将 id 作为参数传递。
  • 导入 fetchCustomers 以获取下拉菜单的客户名称。

你可以使用 Promise.all 并行获取发票和客户:

// /dashboard/invoices/[id]/edit/page.tsx
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(),
  ]);
  // ...
}

你可能会看到一个暂时的 TypeScript 错误,因为 invoice 可能是 undefined。暂时不用担心这个问题,你将在下一章节中处理它,添加错误处理。

很好!现在测试一下是否一切正常。访问 http://localhost:3000/dashboard/invoices 并点击铅笔图标以编辑发票。导航后,你应该会看到一个预填充了发票详情的表单:

image

URL 也应更新为包含 id,格式如下:http://localhost:3000/dashboard/invoice/uuid/edit

UUID 与自增键

我们使用 UUID 而不是自增键(例如 1、2、3 等)。这使得 URL 更长;然而,UUID 消除了 ID 冲突的风险,全球唯一,并减少了枚举攻击的风险——使其适合大型数据库。

但是,如果你更喜欢更简洁的 URL,可能更倾向于使用自增键。

4. 将 id 传递给服务器操作

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

// /app/ui/invoices/edit-form.tsx
// 传递 id 作为参数不适用
<form action={updateInvoice(id)}>

相反,你可以使用 JavaScript 的 bindid 传递给服务器操作。这将确保传递给服务器操作的任何值都被编码。

// /app/ui/invoices/edit-form.tsx
// ...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

// /app/lib/actions.ts
// 使用 Zod 更新期望的类型
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 操作,你在这里:

  1. formData 中提取数据。
  2. 使用 Zod 验证类型。
  3. 将金额转换为分。
  4. 将变量传递给 SQL 查询。
  5. 调用 revalidatePath 以清除客户端缓存并发起新的服务器请求。
  6. 调用 redirect 将用户重定向到发票页面。

通过编辑发票进行测试。提交表单后,你应该会被重定向到发票页面,并且发票应已更新。

删除发票

要使用服务器操作删除发票,将删除按钮包装在 <form> 元素中,并使用 bindid 传递给服务器操作:

// /app/ui/invoices/buttons.tsx
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 的新操作:

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

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

进一步阅读

在这一章中,你学会了如何使用服务器操作来修改数据。你还学会了如何使用 revalidatePath API 重新验证 Next.js 缓存,以及如何使用 redirect 将用户重定向到新页面。

你还可以阅读更多关于 服务器操作的安全性(security with Server Actions) 以获取更多学习内容。

image
EchoEcho官方
无论前方如何,请不要后悔与我相遇。
1377
发布数
439
关注者
2222731
累计阅读

热门教程文档

Golang
23小节
Spring Cloud
8小节
Dart
35小节
Flutter
105小节
Maven
5小节