数据变更 6个月前

编程语言
749
数据变更

在上一章中,你使用了 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
关注者
2259718
累计阅读

热门教程文档

C#
57小节
Dart
35小节
Objective-C
29小节
爬虫
6小节
QT
33小节