資料變更

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

什麼是伺服器動作 (Server Actions)?

React 伺服器動作 (Server Actions) 讓您可以直接在伺服器上執行非同步程式碼。它們消除了建立 API 端點來變更資料的需求,取而代之的是您可以撰寫在伺服器上執行的非同步函數,並從您的客戶端或伺服器元件呼叫。

安全性是網頁應用程式的首要考量,因為它們可能面臨各種威脅。這就是伺服器動作的用武之地。它們包含加密閉包、嚴格輸入檢查、錯誤訊息雜湊、主機限制等功能,共同顯著提升您的應用程式安全性。

在表單中使用伺服器動作

在 React 中,您可以使用 <form> 元素中的 action 屬性來呼叫動作。該動作會自動接收原生的 FormData 物件,其中包含擷取的資料。

例如:

// 伺服器元件
export default function Page() {
  // 動作
  async function create(formData: FormData) {
    'use server';
 
    // 變更資料的邏輯...
  }
 
  // 使用 "action" 屬性呼叫動作
  return <form action={create}>...</form>;
}

在伺服器元件中呼叫伺服器動作的一個優勢是漸進增強 (progressive enhancement) — 即使在客戶端尚未載入 JavaScript 的情況下,表單仍然可以運作。例如,在網路連線較慢的情況下。

Next.js 與伺服器動作

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

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

建立發票

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

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

1. 建立新路由和表單

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

包含嵌套 create 資料夾的 Invoices 資料夾,其中有一個 page.tsx 檔案

您將使用此路由來建立新發票。在您的 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>(下拉式選單)元素,其中包含客戶清單。
  • 有一個 <input> 元素用於輸入金額,其 type="number"
  • 有兩個 <input> 元素用於狀態,其 type="radio"
  • 有一個按鈕,其 type="submit"

http://localhost:3000/dashboard/invoices/create,您應該會看到以下使用者介面:

建立發票頁面,包含麵包屑導覽和表單

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 中,您會將 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()

要檢查一切是否正確連接,請試用表單。提交後,您應該會在終端機(而非瀏覽器)中看到您剛剛輸入到表單中的資料記錄。

現在您的資料以物件的形式存在,處理起來會容易得多。

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 並定義一個與表單物件形狀相符的結構描述。此結構描述將在將 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 postgres from 'postgres';
 
const sql = postgres(process.env.POSTGRES_URL!, { ssl: 'require' });
 
// ...
 
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 有一個客戶端路由快取 (client-side router cache),會將路由片段暫存在使用者的瀏覽器中一段時間。結合預取 (prefetching) 功能,這個快取機制能確保使用者在路由間快速導航,同時減少向伺服器發送的請求數量。

由於您正在更新發票路由中顯示的資料,您會希望清除這個快取並觸發一個新的伺服器請求。您可以使用 Next.js 的 revalidatePath 函式來達成:

/app/lib/actions.ts
'use server';
 
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import postgres from 'postgres';
 
const sql = postgres(process.env.POSTGRES_URL!, { ssl: 'require' });
 
// ...
 
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 { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import postgres from 'postgres';
 
const sql = postgres(process.env.POSTGRES_URL!, { ssl: 'require' });
 
// ...
 
export async function createInvoice(formData: FormData) {
  // ...
 
  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}

恭喜!您剛剛實作了第一個伺服器動作 (Server Action)。試著新增一張發票來測試看看,如果一切運作正常:

  1. 提交後您應該會被重新導向到 /dashboard/invoices 路由。
  2. 您應該會在表格頂端看到新增的發票。

更新發票

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

以下是更新發票的步驟:

  1. 建立一個帶有發票 id 的新動態路由片段 (dynamic route segment)。
  2. 從頁面參數 (page params) 讀取發票 id
  3. 從資料庫中取得特定的發票。
  4. 預先填入表單中的發票資料。
  5. 更新資料庫中的發票資料。

1. 建立帶有發票 id 的動態路由片段

Next.js 允許您在不知道確切片段名稱時建立動態路由片段 (Dynamic Route Segments),並根據資料建立路由。這可以用於部落格文章標題、產品頁面等。您可以透過將資料夾名稱用方括號包起來來建立動態路由片段,例如 [id][post][slug]

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

Invoices 資料夾內有一個巢狀的 [id] 資料夾,裡面還有一個 edit 資料夾

在您的 <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 prop。您可以使用模板字面值 (template literals) 來連結到動態路由片段:

/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: '發票', href: '/dashboard/invoices' },
          {
            label: '編輯發票',
            href: `/dashboard/invoices/${id}/edit`,
            active: true,
          },
        ]}
      />
      <Form invoice={invoice} customers={customers} />
    </main>
  );
}

請注意它與您的 /create 發票頁面相似,只是它匯入了一個不同的表單(來自 edit-form.tsx 檔案)。這個表單應該會預先填入客戶名稱、發票金額和狀態的 defaultValue。要預先填入表單欄位,您需要使用 id 來取得特定的發票。

除了 searchParams 之外,頁面元件也接受一個名為 params 的 prop,您可以用它來存取 id。更新您的 <Page> 元件以接收這個 prop:

/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(),
  ]);
  // ...
}

您會在終端機中看到一個暫時的 TypeScript 錯誤,針對 invoice prop,因為 invoice 可能為 undefined。現在不用擔心,您將在下一章節加入錯誤處理時解決這個問題。

太好了!現在測試一下所有功能是否正確連接。造訪 http://localhost:3000/dashboard/invoices 並點擊鉛筆圖示來編輯一張發票。導覽後,您應該會看到一個預先填入發票詳細資料的表單:

編輯發票頁面,包含麵包屑導覽和表單

網址也應該會更新為包含 id,如下所示:http://localhost:3000/dashboard/invoice/uuid/edit

UUID 與自動遞增鍵的比較

我們使用 UUID 而非遞增鍵(例如 1、2、3 等)。這會讓網址變長;然而,UUID 消除了 ID 衝突的風險,具有全域唯一性,並降低了枚舉攻擊 (enumeration attacks) 的風險,這使得它們非常適合大型資料庫。

不過,如果您偏好更簡潔的網址,您可能會傾向使用自動遞增鍵。

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}>{/* ... */}</form>;
}

**注意:**在表單中使用隱藏的 input 欄位也可以達成(例如 <input type="hidden" name="id" value={invoice.id} />)。然而,這些值會以純文字形式出現在 HTML 原始碼中,這對於敏感資料來說並不理想。

接著,在您的 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">刪除</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 來將使用者重新導向到新頁面。

您也可以閱讀更多關於伺服器動作的安全性來進一步學習。