跳至內容

12

資料異動

在上一章中,您使用 URL 搜尋參數和 Next.js API 實作了搜尋和分頁功能。讓我們繼續處理發票頁面,加入建立、更新和刪除發票的功能!

本章內容…

以下是我們將涵蓋的主題

什麼是 React 伺服器動作以及如何使用它們來異動資料。

如何使用表單和伺服器元件。

使用原生 formData 物件的最佳實務,包括類型驗證。

如何使用 revalidatePath API 重新驗證客戶端快取。

如何使用特定 ID 建立動態路由區段。

什麼是伺服器動作?

React 伺服器動作允許您直接在伺服器上執行非同步程式碼。它們消除了建立 API 端點來異動資料的需求。您可以改為編寫在伺服器上執行的非同步函式,並可從您的客戶端或伺服器元件呼叫這些函式。

安全性是網頁應用程式的首要任務,因為它們容易受到各種威脅。這就是伺服器動作的用武之地。它們提供有效的安全解決方案,可防禦不同類型的攻擊,保護您的資料並確保授權存取。伺服器動作透過 POST 請求、加密閉包、嚴格的輸入檢查、錯誤訊息雜湊和主機限制等技術來實現此目標,所有這些技術共同作用,可顯著提升應用程式的安全性。

使用伺服器動作搭配表單

在 React 中,您可以使用 <form> 元素中的 action 屬性來呼叫動作。該動作會自動接收原生 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 與伺服器動作

伺服器動作也與 Next.js 的快取機制深度整合。當透過伺服器動作提交表單時,您不僅可以使用該動作來變更資料,還可以利用 revalidatePathrevalidateTag 等 API 重新驗證相關的快取。

讓我們看看這一切是如何協同工作的!

建立發票

以下是建立新發票的步驟:

  1. 建立一個表單來擷取使用者的輸入。
  2. 建立一個伺服器動作並從表單中呼叫它。
  3. 在您的伺服器動作內,從 formData 物件中提取資料。
  4. 驗證並準備要插入資料庫的資料。
  5. 插入資料並處理任何錯誤。
  6. 重新驗證快取並將使用者重新導向回發票頁面。

1. 建立新的路由和表單

首先,在 /invoices 資料夾內,新增一個名為 /create 的路由區段,並建立一個 page.tsx 檔案。

Invoices folder with a nested create folder, and a page.tsx file inside it

您將使用此路由來建立新的發票。在 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: 'Invoices', href: '/dashboard/invoices' },
          {
            label: 'Create Invoice',
            href: '/dashboard/invoices/create',
            active: true,
          },
        ]}
      />
      <Form customers={customers} />
    </main>
  );
}

您的頁面是一個伺服器元件,它會擷取 customers 並將其傳遞給 <Form> 元件。為了節省時間,我們已經為您建立了 <Form> 元件。

瀏覽至 <Form> 元件,您會看到該表單

  • 有一個包含**客戶**清單的 <select>(下拉式選單)元素。
  • 有一個用於輸入**金額**的 <input> 元素,其 type="number"
  • 有兩個用於設定狀態的 <input> 元素,其 type="radio"
  • 有一個 type="submit" 的按鈕。

https://127.0.0.1:3000/dashboard/invoices/create 上,您應該會看到以下 UI

Create invoices page with breadcrumbs and form

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}>
      // ...
  )
}

注意事項:在 HTML 中,您會將網址傳遞給 action 屬性。這個網址將會是表單資料提交的目的地(通常是一個 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'),
  };
  // Test it out:
  console.log(rawFormData);
}

提示:如果您正在處理具有許多欄位的表單,您可能會考慮使用 entries() 方法搭配 JavaScript 的 Object.fromEntries()。例如:

const rawFormData = Object.fromEntries(formData.entries())

要檢查所有內容是否已正確連接,請嘗試使用該表單。提交後,您應該會在終端機中看到您剛輸入表單的資料記錄。

現在您的資料已是物件的形狀,將更容易處理。

4. 驗證並準備資料

在將表單資料傳送到資料庫之前,您需要確保資料格式和類型正確。如果您還記得在本課程的前面部分,您的 invoices 資料表預期資料的格式如下:

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

到目前為止,您只有來自表單的 customer_idamountstatus

類型驗證和強制轉換

驗證表單資料與資料庫中預期的類型一致非常重要。例如,如果您在 action 裡面加入 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 有一個客戶端路由器快取,會將路由區段儲存在使用者瀏覽器中一段時間。搭配預取,這個快取可確保使用者在路由之間快速導航,同時減少向伺服器發出的請求次數。

由於您正在更新發票路由中顯示的資料,因此您需要清除此快取並觸發對伺服器的新請求。您可以使用 Next.js 的 revalidatePath 函式來執行此操作。

/app/lib/actions.ts
'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/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. 您應該在表格頂部看到新發票。

更新發票

更新發票的表單與建立發票的表單類似,除了您需要傳遞發票 id 以更新資料庫中的記錄。讓我們看看如何取得和傳遞發票 id

以下是更新發票的步驟:

  1. 使用發票 id 建立新的動態路由區段。
  2. 從頁面參數中讀取發票 id
  3. 從資料庫中擷取特定的發票。
  4. 使用發票資料預先填寫表單。
  5. 更新資料庫中的發票資料。

1. 使用發票 id 建立動態路由區段

當您不知道確切的區段名稱,並且想要根據資料建立路由時,Next.js 允許您建立動態路由區段。這可以是部落格文章標題、產品頁面等等。您可以透過將資料夾名稱用方括號括起來來建立動態路由區段。例如,[id][post][slug]

在您的 /invoices 資料夾中,建立一個名為 [id] 的新動態路由,然後建立一個名為 edit 的新路由,並包含一個 page.tsx 檔案。您的檔案結構應該如下所示

Invoices folder with a nested [id] folder, and an edit folder inside it

在您的 <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. 從頁面 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 { 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 預先填寫。要預先填寫表單欄位,您需要使用 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 { fetchCustomers } from '@/app/lib/data';
 
export default async function Page(props: { params: Promise<{ id: string }> }) {
  const params = await props.params;
  const id = params.id;
  // ...
}

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(props: { params: Promise<{ id: string }> }) {
  const params = await props.params;
  const id = params.id;
  const [invoice, customers] = await Promise.all([
    fetchInvoiceById(id),
    fetchCustomers(),
  ]);
  // ...
}

您會在終端機中看到 invoice 屬性的暫時 TS 錯誤,因為 invoice 可能會是 undefined。現在不用擔心,您將在下一章新增錯誤處理時解決它。

太棒了!現在,測試一下所有東西是否都正確連接。請訪問 https://127.0.0.1:3000/dashboard/invoices 並點擊鉛筆圖示以編輯發票。導覽後,您應該會看到一個預先填入發票詳細資訊的表單。

Edit invoices page with breadcrumbs and form

網址也應該使用 id 更新,如下所示:https://127.0.0.1:3000/dashboard/invoice/uuid/edit

UUID 與自動遞增鍵值

我們使用 UUID,而不是遞增鍵值(例如 1、2、3 等)。雖然這會讓網址變長;然而,UUID 可消除 ID 衝突的風險,具有全域唯一性,並降低列舉攻擊的風險,使其成為大型資料庫的理想選擇。

但是,如果您偏好更簡潔的網址,您可能會選擇使用自動遞增鍵值。

4. 將 id 傳遞給伺服器動作

最後,您需要將 id 傳遞給伺服器動作,以便更新資料庫中的正確記錄。您**不能**像這樣將 id 作為參數傳遞

/app/ui/invoices/edit-form.tsx
// Passing an id as argument won't work
<form action={updateInvoice(id)}>

相反,您可以使用 JS 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
// 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 動作類似,這裡您正在

  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 將會觸發新的伺服器請求並重新渲染表格。

延伸閱讀

在本章中,您學習了如何使用伺服器動作 (Server Actions) 來變更資料。您也學習了如何使用 revalidatePath API 來重新驗證 Next.js 快取,以及使用 redirect 將使用者重新導向到新的頁面。

您還可以閱讀更多關於 伺服器動作的安全性 以獲得更多學習資源。

您已完成此章節12

恭喜!您已學習如何使用表單和 React 伺服器動作來變更資料。

下一步

13: 錯誤處理

讓我們來探討使用表單變更資料的最佳實務,包含錯誤處理和無障礙設計。