React Router v7 已發布。 查看文件
教學 (30 分鐘)
本頁面內容

Remix 教學

剛開始使用 Remix 嗎?最新版本的 Remix 現在是 React Router v7。如果您想使用最新的框架功能,您可以按照 React Router 文件中的相同教學進行操作。

我們將建構一個小型但功能豐富的應用程式,讓您可以追蹤您的聯絡人。沒有資料庫或其他「生產就緒」的東西,因此我們可以專注於 Remix。如果您跟著操作,我們預期大約需要 30 分鐘,否則這是一篇快速的閱讀。

👉 每次看到這個,就表示您需要在應用程式中執行某些動作!

其餘的內容僅供您參考和更深入的理解。讓我們開始吧。

設定

👉 產生一個基本範本

npx create-remix@latest --template remix-run/remix/templates/remix-tutorial

這使用了一個相當簡陋的範本,但包含我們的 CSS 和資料模型,因此我們可以專注於 Remix。快速入門可以讓您熟悉 Remix 專案的基本設定(如果您想了解更多)。

👉 啟動應用程式

# cd into the app directory
cd {wherever you put the app}

# install dependencies if you haven't already
npm install

# start the server
npm run dev

您應該可以開啟 https://127.0.0.1:5173 並看到一個未設定樣式的畫面,看起來像這樣

根路由

請注意 app/root.tsx 中的檔案。這就是我們所說的「根路由」。它是 UI 中第一個渲染的元件,因此通常包含頁面的全域佈局。

展開此處以查看根元件程式碼
import {
  Form,
  Links,
  Meta,
  Scripts,
  ScrollRestoration,
} from "@remix-run/react";

export default function App() {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta
          name="viewport"
          content="width=device-width, initial-scale=1"
        />
        <Meta />
        <Links />
      </head>
      <body>
        <div id="sidebar">
          <h1>Remix Contacts</h1>
          <div>
            <Form id="search-form" role="search">
              <input
                aria-label="Search contacts"
                id="q"
                name="q"
                placeholder="Search"
                type="search"
              />
              <div
                aria-hidden
                hidden={true}
                id="search-spinner"
              />
            </Form>
            <Form method="post">
              <button type="submit">New</button>
            </Form>
          </div>
          <nav>
            <ul>
              <li>
                <a href={`/contacts/1`}>Your Name</a>
              </li>
              <li>
                <a href={`/contacts/2`}>Your Friend</a>
              </li>
            </ul>
          </nav>
        </div>

        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

雖然有多種方式可以為你的 Remix 應用程式設定樣式,但為了將重點放在 Remix 上,我們將使用已經寫好的純樣式表。

你可以直接將 CSS 檔案匯入 JavaScript 模組。Vite 會為該資源加上指紋,將其儲存到你建置的客戶端目錄,並為你的模組提供可公開存取的 href。

👉 匯入應用程式樣式

import type { LinksFunction } from "@remix-run/node";
// existing imports

import appStylesHref from "./app.css?url";

export const links: LinksFunction = () => [
  { rel: "stylesheet", href: appStylesHref },
];

每個路由都可以匯出一個 links 函式。它們將被收集並渲染到我們在 app/root.tsx 中渲染的 <Links /> 元件中。

現在應用程式應該看起來像這樣。有個也會寫 CSS 的設計師真是太好了,不是嗎?(感謝 Jim 🙏)。

聯絡人路由 UI

如果你點擊其中一個側邊欄項目,你會看到預設的 404 頁面。讓我們建立一個符合網址 /contacts/1 的路由。

👉 建立 app/routes 目錄和聯絡人路由模組

mkdir app/routes
touch app/routes/contacts.\$contactId.tsx

在 Remix 路由檔案慣例中,. 將在 URL 中建立一個 /,而 $ 會將區段設為動態。我們剛建立了一個路由,它將符合看起來像這樣的 URL

  • /contacts/123
  • /contacts/abc

👉 新增聯絡人元件 UI

這只是一堆元素,請隨意複製/貼上。

import { Form } from "@remix-run/react";
import type { FunctionComponent } from "react";

import type { ContactRecord } from "../data";

export default function Contact() {
  const contact = {
    first: "Your",
    last: "Name",
    avatar: "https://placecats.com/200/200",
    twitter: "your_handle",
    notes: "Some notes",
    favorite: true,
  };

  return (
    <div id="contact">
      <div>
        <img
          alt={`${contact.first} ${contact.last} avatar`}
          key={contact.avatar}
          src={contact.avatar}
        />
      </div>

      <div>
        <h1>
          {contact.first || contact.last ? (
            <>
              {contact.first} {contact.last}
            </>
          ) : (
            <i>No Name</i>
          )}{" "}
          <Favorite contact={contact} />
        </h1>

        {contact.twitter ? (
          <p>
            <a
              href={`https://twitter.com/${contact.twitter}`}
            >
              {contact.twitter}
            </a>
          </p>
        ) : null}

        {contact.notes ? <p>{contact.notes}</p> : null}

        <div>
          <Form action="edit">
            <button type="submit">Edit</button>
          </Form>

          <Form
            action="destroy"
            method="post"
            onSubmit={(event) => {
              const response = confirm(
                "Please confirm you want to delete this record."
              );
              if (!response) {
                event.preventDefault();
              }
            }}
          >
            <button type="submit">Delete</button>
          </Form>
        </div>
      </div>
    </div>
  );
}

const Favorite: FunctionComponent<{
  contact: Pick<ContactRecord, "favorite">;
}> = ({ contact }) => {
  const favorite = contact.favorite;

  return (
    <Form method="post">
      <button
        aria-label={
          favorite
            ? "Remove from favorites"
            : "Add to favorites"
        }
        name="favorite"
        value={favorite ? "false" : "true"}
      >
        {favorite ? "" : ""}
      </button>
    </Form>
  );
};

現在如果我們點擊其中一個連結或造訪 /contacts/1,我們得到的結果...沒有任何新東西?

contact route with blank main content

巢狀路由和 Outlet

由於 Remix 是建立在 React Router 之上的,它支援巢狀路由。為了使子路由能夠在父層版面配置內渲染,我們需要在父層中渲染一個 Outlet。讓我們修正它,開啟 app/root.tsx 並在內部渲染一個 outlet。

👉 渲染一個 <Outlet />

// existing imports
import {
  Form,
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
} from "@remix-run/react";

// existing imports & code

export default function App() {
  return (
    <html lang="en">
      {/* other elements */}
      <body>
        <div id="sidebar">{/* other elements */}</div>
        <div id="detail">
          <Outlet />
        </div>
        {/* other elements */}
      </body>
    </html>
  );
}

現在子路由應該會透過 outlet 渲染。

contact route with the main content

客戶端路由

你可能已經注意到,當我們點擊側邊欄中的連結時,瀏覽器正在針對下一個 URL 執行完整的檔案請求,而不是客戶端路由。

客戶端路由允許我們的應用程式更新 URL,而無需從伺服器請求另一個檔案。相反地,應用程式可以立即渲染新的 UI。讓我們使用 <Link> 實現它。

👉 將側邊欄的 <a href> 變更為 <Link to>

// existing imports
import {
  Form,
  Link,
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
} from "@remix-run/react";

// existing imports & exports

export default function App() {
  return (
    <html lang="en">
      {/* other elements */}
      <body>
        <div id="sidebar">
          {/* other elements */}
          <nav>
            <ul>
              <li>
                <Link to={`/contacts/1`}>Your Name</Link>
              </li>
              <li>
                <Link to={`/contacts/2`}>Your Friend</Link>
              </li>
            </ul>
          </nav>
        </div>
        {/* other elements */}
      </body>
    </html>
  );
}

你可以開啟瀏覽器開發人員工具中的網路索引標籤,查看它是否不再請求檔案。

載入資料

URL 區段、版面配置和資料通常會耦合在一起(三重?)。我們已經可以在這個應用程式中看到它了

URL 區段 元件 資料
/ <Root> 聯絡人清單
contacts/:contactId <Contact> 個別聯絡人

由於這種自然的耦合,Remix 具有資料慣例,可以輕鬆地將資料載入到你的路由元件中。

我們將使用兩個 API 來載入資料,loaderuseLoaderData。首先,我們將在根路由中建立並匯出一個 loader 函式,然後渲染資料。

👉 app/root.tsx 匯出一個 loader 函式並渲染資料

下列程式碼中存在類型錯誤,我們將在下一節中修正它

// existing imports
import { json } from "@remix-run/node";
import {
  Form,
  Link,
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
  useLoaderData,
} from "@remix-run/react";

// existing imports
import { getContacts } from "./data";

// existing exports

export const loader = async () => {
  const contacts = await getContacts();
  return json({ contacts });
};

export default function App() {
  const { contacts } = useLoaderData();

  return (
    <html lang="en">
      {/* other elements */}
      <body>
        <div id="sidebar">
          {/* other elements */}
          <nav>
            {contacts.length ? (
              <ul>
                {contacts.map((contact) => (
                  <li key={contact.id}>
                    <Link to={`contacts/${contact.id}`}>
                      {contact.first || contact.last ? (
                        <>
                          {contact.first} {contact.last}
                        </>
                      ) : (
                        <i>No Name</i>
                      )}{" "}
                      {contact.favorite ? (
                        <span>★</span>
                      ) : null}
                    </Link>
                  </li>
                ))}
              </ul>
            ) : (
              <p>
                <i>No contacts</i>
              </p>
            )}
          </nav>
        </div>
        {/* other elements */}
      </body>
    </html>
  );
}

就是這樣!Remix 現在會自動保持該資料與你的 UI 同步。側邊欄現在應該看起來像這樣

類型推斷

你可能已經注意到 TypeScript 在 map 內的 contact 類型中發出抱怨。我們可以新增一個快速註解,使用 typeof loader 取得有關我們資料的類型推斷。

// existing imports & exports

export default function App() {
  const { contacts } = useLoaderData<typeof loader>();

  // existing code
}

Loader 中的 URL 參數

👉 點擊其中一個側邊欄連結

我們應該會再次看到我們舊的靜態聯絡人頁面,但有一個差異:URL 現在具有記錄的真實 ID。

還記得 app/routes/contacts.$contactId.tsx 中檔案名稱的 $contactId 部分嗎?這些動態區段會符合 URL 中該位置的動態(變更)值。我們將 URL 中的這些值稱為「URL 參數」,或簡稱「參數」。

這些 params 會傳遞到 loader,其索引鍵符合動態區段。例如,我們的區段命名為 $contactId,因此該值將以 params.contactId 的形式傳遞。

這些參數最常被用於依 ID 尋找記錄。讓我們試試看。

👉 loader 函式新增至聯絡人頁面,並使用 useLoaderData 存取資料

下列程式碼中存在類型錯誤,我們將在下一節中修正它們

import { json } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
// existing imports

import { getContact } from "../data";

export const loader = async ({ params }) => {
  const contact = await getContact(params.contactId);
  return json({ contact });
};

export default function Contact() {
  const { contact } = useLoaderData<typeof loader>();

  // existing code
}

// existing code

驗證參數並擲回回應

TypeScript 對我們非常不滿,讓我們讓它開心,並看看這會迫使我們考慮什麼

import type { LoaderFunctionArgs } from "@remix-run/node";
// existing imports
import invariant from "tiny-invariant";

// existing imports

export const loader = async ({
  params,
}: LoaderFunctionArgs) => {
  invariant(params.contactId, "Missing contactId param");
  const contact = await getContact(params.contactId);
  return json({ contact });
};

// existing code

這突顯的第一個問題是,我們可能在檔案名稱和程式碼之間弄錯了參數的名稱(也許你變更了檔案的名稱!)。Invariant 是一個方便的函式,當你預期程式碼中可能會出現問題時,它會擲回具有自訂訊息的錯誤。

接下來,useLoaderData<typeof loader>() 現在知道我們取得了一個聯絡人或 null(也許沒有具有該 ID 的聯絡人)。這個潛在的 null 對我們的元件程式碼來說很麻煩,而且 TS 錯誤仍在四處飛舞。

我們可以在元件程式碼中考慮找不到聯絡人的可能性,但網路作法是傳送正確的 404。我們可以在 loader 中執行此動作,並一次解決我們所有的問題。

// existing imports

export const loader = async ({
  params,
}: LoaderFunctionArgs) => {
  invariant(params.contactId, "Missing contactId param");
  const contact = await getContact(params.contactId);
  if (!contact) {
    throw new Response("Not Found", { status: 404 });
  }
  return json({ contact });
};

// existing code

現在,如果找不到使用者,則此路徑下的程式碼執行將會停止,而 Remix 會改為渲染錯誤路徑。Remix 中的元件可以只專注於快樂的路徑 😁

資料變更

我們稍後會建立我們的第一個聯絡人,但首先讓我們談談 HTML。

Remix 將 HTML 表單導覽模擬為資料變更基本元素,這在 JavaScript 寒武紀大爆發之前是唯一的方法。別被它的簡單性所迷惑!Remix 中的表單為你提供客戶端渲染應用程式的 UX 功能,以及「老派」網路模型的簡單性。

雖然有些網路開發人員不熟悉,但 HTML form 實際上會在瀏覽器中產生導覽,就像點擊連結一樣。唯一的差異在於請求:連結只能變更 URL,而 form 也可以變更請求方法 (GET vs. POST) 和請求主體 (POST 表單資料)。

如果沒有客戶端路由,瀏覽器會自動序列化 form 的資料,並將其以 POST 的請求主體和 URLSearchParams 的形式傳送至伺服器,用於 GET。Remix 也會執行相同的動作,但不是將請求傳送至伺服器,而是使用客戶端路由並將其傳送至路由的 action 函式。

我們可以點擊我們應用程式中的「新增」按鈕來測試此功能。

Remix 會傳送 405,因為伺服器上沒有程式碼可處理此表單導覽。

建立聯絡人

我們將在根路由中匯出一個 action 函式來建立新的聯絡人。當使用者點擊「新增」按鈕時,表單會 POST 至根路由動作。

👉 app/root.tsx 匯出一個 action 函式

// existing imports

import { createEmptyContact, getContacts } from "./data";

export const action = async () => {
  const contact = await createEmptyContact();
  return json({ contact });
};

// existing code

就是這樣!繼續點擊「新增」按鈕,你應該會在清單中看到一個新的記錄彈出 🥳

createEmptyContact 方法只會建立一個沒有名稱、資料或任何內容的空聯絡人。但它仍然會建立記錄,請相信我!

🧐 等一下 ... 側邊欄是如何更新的?我們在哪裡呼叫 action 函式?在哪裡可以重新擷取資料?useStateonSubmituseEffect 在哪裡?!

這就是「老派網路」程式設計模型出現的地方。<Form> 會防止瀏覽器將請求傳送至伺服器,而是透過 fetch 將其傳送至你的路由的 action 函式。

在網路語義中,POST 通常表示某些資料正在變更。依照慣例,Remix 會使用此作為提示,以便在 action 完成後自動重新驗證頁面上的資料。

事實上,由於這一切都只是 HTML 和 HTTP,你可以停用 JavaScript,而整個過程仍然有效。瀏覽器不是序列化表單並向你的伺服器發出 fetch 請求,而是序列化表單並發出檔案請求。從那裡,Remix 會在伺服器端渲染頁面並將其傳送下來。無論如何,最終的 UI 都相同。

不過,我們將保留 JavaScript,因為我們將提供比旋轉 Favicon 和靜態檔案更好的使用者體驗。

更新資料

讓我們新增一種方法來填寫我們新記錄的資訊。

就像建立資料一樣,你可以使用 <Form> 更新資料。讓我們在 app/routes/contacts.$contactId_.edit.tsx 建立一個新的路由。

👉 建立編輯元件

touch app/routes/contacts.\$contactId_.edit.tsx

請注意 $contactId_ 中奇怪的 _。依預設,路由會自動巢狀在具有相同前綴名稱的路由內。新增尾隨的 _ 會告知路由不要巢狀在 app/routes/contacts.$contactId.tsx 內。請參閱 路由檔案命名指南以了解更多資訊。

👉 新增編輯頁面 UI

沒有什麼我們以前沒看過的,請隨意複製/貼上

import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
import invariant from "tiny-invariant";

import { getContact } from "../data";

export const loader = async ({
  params,
}: LoaderFunctionArgs) => {
  invariant(params.contactId, "Missing contactId param");
  const contact = await getContact(params.contactId);
  if (!contact) {
    throw new Response("Not Found", { status: 404 });
  }
  return json({ contact });
};

export default function EditContact() {
  const { contact } = useLoaderData<typeof loader>();

  return (
    <Form key={contact.id} id="contact-form" method="post">
      <p>
        <span>Name</span>
        <input
          aria-label="First name"
          defaultValue={contact.first}
          name="first"
          placeholder="First"
          type="text"
        />
        <input
          aria-label="Last name"
          defaultValue={contact.last}
          name="last"
          placeholder="Last"
          type="text"
        />
      </p>
      <label>
        <span>Twitter</span>
        <input
          defaultValue={contact.twitter}
          name="twitter"
          placeholder="@jack"
          type="text"
        />
      </label>
      <label>
        <span>Avatar URL</span>
        <input
          aria-label="Avatar URL"
          defaultValue={contact.avatar}
          name="avatar"
          placeholder="https://example.com/avatar.jpg"
          type="text"
        />
      </label>
      <label>
        <span>Notes</span>
        <textarea
          defaultValue={contact.notes}
          name="notes"
          rows={6}
        />
      </label>
      <p>
        <button type="submit">Save</button>
        <button type="button">Cancel</button>
      </p>
    </Form>
  );
}

現在點擊你的新記錄,然後點擊「編輯」按鈕。我們應該會看到新的路由。

使用 FormData 更新聯絡人

我們剛建立的編輯路由已經渲染了一個 form。我們只需要添加 action 函式。Remix 將序列化 form,使用 fetchPOST 方式發送,並自動重新驗證所有資料。

👉 在編輯路由中新增 action 函式

import type {
  ActionFunctionArgs,
  LoaderFunctionArgs,
} from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
// existing imports

import { getContact, updateContact } from "../data";

export const action = async ({
  params,
  request,
}: ActionFunctionArgs) => {
  invariant(params.contactId, "Missing contactId param");
  const formData = await request.formData();
  const updates = Object.fromEntries(formData);
  await updateContact(params.contactId, updates);
  return redirect(`/contacts/${params.contactId}`);
};

// existing code

填寫表單,按下儲存,您應該會看到類似這樣的畫面!(只是看起來更順眼,而且可能沒那麼多毛。)

變更討論

😑 雖然成功了,但我不知道這裡到底發生了什麼...

讓我們深入探討一下...

開啟 contacts.$contactId_.edit.tsx 並查看 form 元素。請注意它們都有一個名稱

<input
  aria-label="First name"
  defaultValue={contact.first}
  name="first"
  placeholder="First"
  type="text"
/>

在沒有 JavaScript 的情況下,當表單提交時,瀏覽器將會建立 FormData,並在將其發送到伺服器時,將其設定為請求的主體。如前所述,Remix 會阻止這種情況,並透過使用 fetch 將請求發送到您的 action 函式來模擬瀏覽器,其中也包含了 FormData

可以使用 formData.get(name) 存取 form 中的每個欄位。例如,以上述輸入欄位為例,您可以像這樣存取名字和姓氏

export const action = async ({
  params,
  request,
}: ActionFunctionArgs) => {
  const formData = await request.formData();
  const firstName = formData.get("first");
  const lastName = formData.get("last");
  // ...
};

由於我們有許多表單欄位,我們使用了 Object.fromEntries 將它們全部收集到一個物件中,這正是我們的 updateContact 函式所需要的。

const updates = Object.fromEntries(formData);
updates.first; // "Some"
updates.last; // "Name"

除了 action 函式之外,我們正在討論的這些 API 皆非由 Remix 提供:requestrequest.formDataObject.fromEntries 皆由 Web 平台提供。

在我們完成 action 之後,請注意結尾的 redirect

export const action = async ({
  params,
  request,
}: ActionFunctionArgs) => {
  invariant(params.contactId, "Missing contactId param");
  const formData = await request.formData();
  const updates = Object.fromEntries(formData);
  await updateContact(params.contactId, updates);
  return redirect(`/contacts/${params.contactId}`);
};

actionloader 函式都可以返回一個 Response(這很合理,因為它們接收到了一個 Request!)。redirect 輔助函式只是讓返回一個 Response 更容易,該 Response 會告訴應用程式變更位置。

如果伺服器在 POST 請求之後重新導向,則在沒有客戶端路由的情況下,新頁面會擷取最新的資料並進行渲染。正如我們之前所學到的,Remix 模擬了此模型,並在 action 呼叫之後自動重新驗證頁面上的資料。這就是為什麼當我們儲存表單時,側邊欄會自動更新的原因。如果沒有客戶端路由,則不會有額外的重新驗證程式碼,因此在 Remix 中使用客戶端路由時也不需要存在!

最後一件事。如果沒有 JavaScript,redirect 將會是一個正常的重新導向。但是,在有 JavaScript 的情況下,它會是客戶端重新導向,因此使用者不會遺失像是捲動位置或元件狀態等客戶端狀態。

將新記錄重新導向到編輯頁面

既然我們知道如何重新導向,讓我們更新建立新聯絡人的 action,使其重新導向到編輯頁面

👉 重新導向到新記錄的編輯頁面

// existing imports
import { json, redirect } from "@remix-run/node";
// existing imports

export const action = async () => {
  const contact = await createEmptyContact();
  return redirect(`/contacts/${contact.id}/edit`);
};

// existing code

現在,當我們點擊「新增」時,應該會進入編輯頁面

既然我們有了一堆記錄,就不清楚我們正在側邊欄中查看哪個記錄。我們可以利用 NavLink 來修正這個問題。

👉 將側邊欄中的 <Link> 替換為 <NavLink>

// existing imports
import {
  Form,
  Links,
  Meta,
  NavLink,
  Outlet,
  Scripts,
  ScrollRestoration,
  useLoaderData,
} from "@remix-run/react";

// existing imports and exports

export default function App() {
  const { contacts } = useLoaderData<typeof loader>();

  return (
    <html lang="en">
      {/* existing elements */}
      <body>
        <div id="sidebar">
          {/* existing elements */}
          <ul>
            {contacts.map((contact) => (
              <li key={contact.id}>
                <NavLink
                  className={({ isActive, isPending }) =>
                    isActive
                      ? "active"
                      : isPending
                      ? "pending"
                      : ""
                  }
                  to={`contacts/${contact.id}`}
                >
                  {/* existing elements */}
                </NavLink>
              </li>
            ))}
          </ul>
          {/* existing elements */}
        </div>
        {/* existing elements */}
      </body>
    </html>
  );
}

請注意,我們將一個函式傳遞給 className。當使用者位於與 <NavLink to> 相符的 URL 時,isActive 將會為 true。當它即將處於活動狀態(資料仍在載入)時,isPending 將會為 true。這讓我們可以輕鬆地指出使用者所在的位置,並在點擊連結但需要載入資料時提供即時回饋。

全域待處理 UI

當使用者瀏覽應用程式時,Remix 會在載入下一個頁面的資料時「保留舊頁面」。您可能已經注意到,當您在清單之間點擊時,應用程式的反應有點遲鈍。讓我們為使用者提供一些回饋,讓應用程式不會感覺反應遲鈍。

Remix 會在幕後管理所有狀態,並顯示您建置動態 Web 應用程式所需的元件。在這種情況下,我們將使用 useNavigation hook。

👉 使用 useNavigation 加入全域待處理 UI

// existing imports
import {
  Form,
  Links,
  Meta,
  NavLink,
  Outlet,
  Scripts,
  ScrollRestoration,
  useLoaderData,
  useNavigation,
} from "@remix-run/react";

// existing imports & exports

export default function App() {
  const { contacts } = useLoaderData<typeof loader>();
  const navigation = useNavigation();

  return (
    <html lang="en">
      {/* existing elements */}
      <body>
        {/* existing elements */}
        <div
          className={
            navigation.state === "loading" ? "loading" : ""
          }
          id="detail"
        >
          <Outlet />
        </div>
        {/* existing elements */}
      </body>
    </html>
  );
}

useNavigation 會返回目前的瀏覽狀態:它可以是 "idle""loading""submitting" 其中之一。

在我們的例子中,如果我們不處於閒置狀態,我們會將 "loading" 類別新增至應用程式的主要部分。然後,CSS 會在短暫的延遲後新增一個漂亮的淡入效果(以避免在快速載入時閃爍 UI)。不過,您可以執行任何您想執行的操作,像是顯示頂部的微調器或載入列。

刪除記錄

如果我們查看聯絡人路由中的程式碼,我們可以找到看起來像這樣的刪除按鈕

<Form
  action="destroy"
  method="post"
  onSubmit={(event) => {
    const response = confirm(
      "Please confirm you want to delete this record."
    );
    if (!response) {
      event.preventDefault();
    }
  }}
>
  <button type="submit">Delete</button>
</Form>

請注意,action 指向 "destroy"。與 <Link to> 一樣,<Form action> 可以接受一個「相對」值。由於表單會在 contacts.$contactId.tsx 中渲染,因此當點擊時,帶有 destroy 的相對 action 會將表單提交到 contacts.$contactId.destroy

此時,您應該知道讓刪除按鈕運作所需的一切。也許在繼續之前嘗試一下?您將需要

  1. 一個新的路由
  2. 該路由中的 action
  3. 來自 app/data.tsdeleteContact
  4. redirect 到某個地方之後

👉 建立「destroy」路由模組

touch app/routes/contacts.\$contactId_.destroy.tsx

👉 新增 destroy action

import type { ActionFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import invariant from "tiny-invariant";

import { deleteContact } from "../data";

export const action = async ({
  params,
}: ActionFunctionArgs) => {
  invariant(params.contactId, "Missing contactId param");
  await deleteContact(params.contactId);
  return redirect("/");
};

好的,瀏覽到一個記錄並點擊「刪除」按鈕。它運作了!

😅 我仍然不明白為什麼這一切都可行

當使用者點擊提交按鈕時

  1. <Form> 會阻止瀏覽器預設的將新文件 POST 請求發送到伺服器的行為,而是透過建立具有客戶端路由和 fetchPOST 請求來模擬瀏覽器
  2. <Form action="destroy"> 會比對 contacts.$contactId_.destroy.tsx 中的新路由,並將請求發送到該路由
  3. action 重新導向後,Remix 會呼叫頁面上所有資料的 loader,以取得最新的值(這是「重新驗證」)。useLoaderData 會返回新的值,並導致元件更新!

新增一個 Form,新增一個 action,Remix 會處理剩餘的事項。

索引路由

當我們載入應用程式時,您會注意到在我們清單的右側有一個很大的空白頁面。

當一個路由有子路由,並且您位於父路由的路徑時,<Outlet> 沒有任何可渲染的內容,因為沒有子路由與其相符。您可以將索引路由視為填滿該空間的預設子路由。

👉 為根路由建立索引路由

touch app/routes/_index.tsx

👉 填入索引元件的元素

隨意複製/貼上,這裡沒有什麼特別之處。

export default function Index() {
  return (
    <p id="index-page">
      This is a demo for Remix.
      <br />
      Check out{" "}
      <a href="https://remix.dev.org.tw">the docs at remix.run</a>.
    </p>
  );
}

路由名稱 _index 很特別。它會告訴 Remix,當使用者位於父路由的確切路徑時,比對並渲染此路由,因此 <Outlet /> 中沒有其他子路由可以渲染。

瞧!不再有空白空間。將儀表板、統計資訊、動態饋給等放在索引路由中是很常見的。它們也可以參與資料載入。

取消按鈕

在編輯頁面上,我們有一個目前沒有任何作用的取消按鈕。我們希望它執行與瀏覽器返回按鈕相同的功能。

我們將需要在按鈕上設定一個點擊處理常式,以及 useNavigate

👉 使用 useNavigate 新增取消按鈕的點擊處理常式

// existing imports
import {
  Form,
  useLoaderData,
  useNavigate,
} from "@remix-run/react";
// existing imports & exports

export default function EditContact() {
  const { contact } = useLoaderData<typeof loader>();
  const navigate = useNavigate();

  return (
    <Form key={contact.id} id="contact-form" method="post">
      {/* existing elements */}
      <p>
        <button type="submit">Save</button>
        <button onClick={() => navigate(-1)} type="button">
          Cancel
        </button>
      </p>
    </Form>
  );
}

現在,當使用者點擊「取消」時,他們將會被送回瀏覽器歷程記錄中的一個項目。

🧐 為什麼按鈕上沒有 event.preventDefault()

<button type="button"> 雖然看似多餘,但它是 HTML 防止按鈕提交其表單的方式。

還剩下兩個功能要處理。我們即將到達終點!

URLSearchParamsGET 提交

到目前為止,我們所有的互動式 UI 要不就是變更 URL 的連結,要不就是將資料發布到 action 函式的 form。搜尋欄位很有趣,因為它是兩者的混合:它是一個 form,但它只會變更 URL,不會變更資料。

讓我們看看當我們提交搜尋表單時會發生什麼

👉 在搜尋欄位中輸入名稱,然後按下 Enter 鍵

請注意,瀏覽器的 URL 現在以 URLSearchParams 的形式包含您在 URL 中的查詢

https://127.0.0.1:5173/?q=ryan

由於它不是 <Form method="post">,因此 Remix 會透過將 FormData 序列化為 URLSearchParams(而不是請求主體)來模擬瀏覽器。

loader 函式可以從 request 存取搜尋參數。讓我們使用它來篩選清單

👉 如果有 URLSearchParams,則篩選清單

import type {
  LinksFunction,
  LoaderFunctionArgs,
} from "@remix-run/node";

// existing imports & exports

export const loader = async ({
  request,
}: LoaderFunctionArgs) => {
  const url = new URL(request.url);
  const q = url.searchParams.get("q");
  const contacts = await getContacts(q);
  return json({ contacts });
};

// existing code

由於這是一個 GET,而不是 POST,因此 Remix「不會」呼叫 action 函式。提交 GET form 與點擊連結相同:只會變更 URL。

這也表示它是一個正常的頁面瀏覽。您可以點擊返回按鈕回到您之前的位置。

將 URL 同步到表單狀態

這裡有一些我們可以快速處理的 UX 問題。

  1. 如果在搜尋後點擊返回,即使清單不再被篩選,表單欄位仍然會保留您輸入的值。
  2. 如果在搜尋後重新整理頁面,即使清單被篩選,表單欄位也不再具有該值

換句話說,URL 和我們輸入的狀態不同步。

讓我們首先解決 (2) 並從 URL 開始輸入值。

👉 從您的 loader 返回 q,將其設定為輸入的預設值

// existing imports & exports

export const loader = async ({
  request,
}: LoaderFunctionArgs) => {
  const url = new URL(request.url);
  const q = url.searchParams.get("q");
  const contacts = await getContacts(q);
  return json({ contacts, q });
};

export default function App() {
  const { contacts, q } = useLoaderData<typeof loader>();
  const navigation = useNavigation();

  return (
    <html lang="en">
      {/* existing elements */}
      <body>
        <div id="sidebar">
          {/* existing elements */}
          <div>
            <Form id="search-form" role="search">
              <input
                aria-label="Search contacts"
                defaultValue={q || ""}
                id="q"
                name="q"
                placeholder="Search"
                type="search"
              />
              {/* existing elements */}
            </Form>
            {/* existing elements */}
          </div>
          {/* existing elements */}
        </div>
        {/* existing elements */}
      </body>
    </html>
  );
}

如果您在搜尋後重新整理頁面,輸入欄位現在會顯示查詢。

現在處理問題 (1),點擊返回按鈕並更新輸入。我們可以從 React 引入 useEffect 來直接操作 DOM 中的輸入值。

👉 將輸入值與 URLSearchParams 同步

// existing imports
import { useEffect } from "react";

// existing imports & exports

export default function App() {
  const { contacts, q } = useLoaderData<typeof loader>();
  const navigation = useNavigation();

  useEffect(() => {
    const searchField = document.getElementById("q");
    if (searchField instanceof HTMLInputElement) {
      searchField.value = q || "";
    }
  }, [q]);

  // existing code
}

🤔 不應該使用受控制的元件和 React 狀態來處理嗎?

您當然可以將其作為受控制的元件來執行。您將會有更多同步點,但這取決於您。

展開此項目以查看其外觀
// existing imports
import { useEffect, useState } from "react";

// existing imports & exports

export default function App() {
  const { contacts, q } = useLoaderData<typeof loader>();
  const navigation = useNavigation();
  // the query now needs to be kept in state
  const [query, setQuery] = useState(q || "");

  // we still have a `useEffect` to synchronize the query
  // to the component state on back/forward button clicks
  useEffect(() => {
    setQuery(q || "");
  }, [q]);

  return (
    <html lang="en">
      {/* existing elements */}
      <body>
        <div id="sidebar">
          {/* existing elements */}
          <div>
            <Form id="search-form" role="search">
              <input
                aria-label="Search contacts"
                id="q"
                name="q"
                // synchronize user's input to component state
                onChange={(event) =>
                  setQuery(event.currentTarget.value)
                }
                placeholder="Search"
                type="search"
                // switched to `value` from `defaultValue`
                value={query}
              />
              {/* existing elements */}
            </Form>
            {/* existing elements */}
          </div>
          {/* existing elements */}
        </div>
        {/* existing elements */}
      </body>
    </html>
  );
}

好的,您現在應該能夠點擊返回/前進/重新整理按鈕,並且輸入的值應該與 URL 和結果同步。

提交 FormonChange

我們這裡有一個產品決策需要做。有時候您希望使用者提交 form 來篩選一些結果,有時候您希望使用者在輸入時就進行篩選。我們已經實現了第一種情況,所以來看看第二種情況會是什麼樣子。

我們已經看過 useNavigate 了,這次我們會使用它的兄弟,useSubmit

// existing imports
import {
  Form,
  Links,
  Meta,
  NavLink,
  Outlet,
  Scripts,
  ScrollRestoration,
  useLoaderData,
  useNavigation,
  useSubmit,
} from "@remix-run/react";
// existing imports & exports

export default function App() {
  const { contacts, q } = useLoaderData<typeof loader>();
  const navigation = useNavigation();
  const submit = useSubmit();

  // existing code

  return (
    <html lang="en">
      {/* existing elements */}
      <body>
        <div id="sidebar">
          {/* existing elements */}
          <div>
            <Form
              id="search-form"
              onChange={(event) =>
                submit(event.currentTarget)
              }
              role="search"
            >
              {/* existing elements */}
            </Form>
            {/* existing elements */}
          </div>
          {/* existing elements */}
        </div>
        {/* existing elements */}
      </body>
    </html>
  );
}

現在,當您輸入時,form 會自動提交!

請注意傳遞給 submit 的參數。submit 函數會序列化並提交您傳遞給它的任何表單。我們傳遞的是 event.currentTargetcurrentTarget 是事件附加到的 DOM 節點(即 form)。

新增搜尋載入指示器

在實際的應用程式中,這個搜尋很可能會在資料庫中查找記錄,而資料庫可能太大,無法一次全部傳送並在客戶端進行篩選。這就是為什麼這個範例會有一些偽造的網路延遲。

在沒有任何載入指示器的情況下,搜尋感覺有點遲緩。即使我們可以加快資料庫的速度,我們也永遠會受到使用者網路延遲的阻礙,而且我們無法控制它。

為了提供更好的使用者體驗,讓我們為搜尋新增一些立即的 UI 回饋。我們會再次使用 useNavigation

👉 新增一個變數來判斷是否正在搜尋

// existing imports & exports

export default function App() {
  const { contacts, q } = useLoaderData<typeof loader>();
  const navigation = useNavigation();
  const submit = useSubmit();
  const searching =
    navigation.location &&
    new URLSearchParams(navigation.location.search).has(
      "q"
    );

  // existing code
}

當沒有任何事情發生時,navigation.location 會是 undefined,但是當使用者導覽時,它會在使用資料載入時填入下一個位置。然後我們用 location.search 來檢查他們是否正在搜尋。

👉 使用新的 searching 狀態,為搜尋表單元素新增類別

// existing imports & exports

export default function App() {
  // existing code

  return (
    <html lang="en">
      {/* existing elements */}
      <body>
        <div id="sidebar">
          {/* existing elements */}
          <div>
            <Form
              id="search-form"
              onChange={(event) =>
                submit(event.currentTarget)
              }
              role="search"
            >
              <input
                aria-label="Search contacts"
                className={searching ? "loading" : ""}
                defaultValue={q || ""}
                id="q"
                name="q"
                placeholder="Search"
                type="search"
              />
              <div
                aria-hidden
                hidden={!searching}
                id="search-spinner"
              />
            </Form>
            {/* existing elements */}
          </div>
          {/* existing elements */}
        </div>
        {/* existing elements */}
      </body>
    </html>
  );
}

額外加分,避免在搜尋時淡出主畫面

// existing imports & exports

export default function App() {
  // existing code

  return (
    <html lang="en">
      {/* existing elements */}
      <body>
        {/* existing elements */}
        <div
          className={
            navigation.state === "loading" && !searching
              ? "loading"
              : ""
          }
          id="detail"
        >
          <Outlet />
        </div>
        {/* existing elements */}
      </body>
    </html>
  );
}

您現在應該在搜尋輸入框的左側看到一個漂亮的載入指示器。

管理歷史堆疊

由於表單在每次擊鍵時都會提交,所以輸入字元「alex」然後使用退格鍵刪除它們會導致一個龐大的歷史堆疊 😂。我們絕對不希望這樣。

我們可以透過取代歷史堆疊中的當前條目來避免這種情況,而不是將其推入堆疊。

👉 submit 中使用 replace

// existing imports & exports

export default function App() {
  // existing code

  return (
    <html lang="en">
      {/* existing elements */}
      <body>
        <div id="sidebar">
          {/* existing elements */}
          <div>
            <Form
              id="search-form"
              onChange={(event) => {
                const isFirstSearch = q === null;
                submit(event.currentTarget, {
                  replace: !isFirstSearch,
                });
              }}
              role="search"
            >
              {/* existing elements */}
            </Form>
            {/* existing elements */}
          </div>
          {/* existing elements */}
        </div>
        {/* existing elements */}
      </body>
    </html>
  );
}

在快速檢查這是否是第一次搜尋之後,我們決定取代。現在第一次搜尋會新增一個新條目,但之後的每次擊鍵都會取代目前的條目。使用者不再需要點擊返回 7 次才能移除搜尋,而是只需要點擊一次返回。

不需導覽的 Form

到目前為止,我們所有的表單都更改了 URL。雖然這些使用者流程很常見,但同樣常見的是希望提交表單而無需觸發導覽。

對於這些情況,我們有 useFetcher。它允許我們與 actionloader 通訊,而不會觸發導覽。

聯絡人頁面上的 ★ 按鈕很適合這種情況。我們沒有建立或刪除新的記錄,而且我們不想更改頁面。我們只是想變更我們正在查看的頁面上的資料。

👉 <Favorite> 表單變更為 fetcher 表單

// existing imports
import {
  Form,
  useFetcher,
  useLoaderData,
} from "@remix-run/react";
// existing imports & exports

// existing code

const Favorite: FunctionComponent<{
  contact: Pick<ContactRecord, "favorite">;
}> = ({ contact }) => {
  const fetcher = useFetcher();
  const favorite = contact.favorite;

  return (
    <fetcher.Form method="post">
      <button
        aria-label={
          favorite
            ? "Remove from favorites"
            : "Add to favorites"
        }
        name="favorite"
        value={favorite ? "false" : "true"}
      >
        {favorite ? "" : ""}
      </button>
    </fetcher.Form>
  );
};

此表單將不再觸發導覽,而只是提取到 action。說到這裡…在我們建立 action 之前,這不會起作用。

👉 建立 action

import type {
  ActionFunctionArgs,
  LoaderFunctionArgs,
} from "@remix-run/node";
// existing imports

import { getContact, updateContact } from "../data";
// existing imports

export const action = async ({
  params,
  request,
}: ActionFunctionArgs) => {
  invariant(params.contactId, "Missing contactId param");
  const formData = await request.formData();
  return updateContact(params.contactId, {
    favorite: formData.get("favorite") === "true",
  });
};

// existing code

好了,我們準備好點擊使用者名稱旁邊的星星了!

看看這個,兩個星星都會自動更新。我們新的 <fetcher.Form method="post"> 工作方式幾乎與我們一直在使用的 <Form> 完全相同:它會呼叫 action,然後所有資料都會自動重新驗證 — 即使您的錯誤也會以相同的方式被捕獲。

不過,有一個主要的差異,它不是導覽,所以 URL 不會變更,而且歷史堆疊也不會受到影響。

樂觀 UI

您可能注意到,當我們從最後一個章節點擊最愛按鈕時,應用程式感覺有點反應遲鈍。再一次,我們新增了一些網路延遲,因為您在真實世界中會遇到這種情況。

為了給使用者一些回饋,我們可以將星星放入使用 fetcher.state 的載入狀態(很像之前的 navigation.state),但這次我們可以做得更好。我們可以採用一種稱為「樂觀 UI」的策略。

Fetcher 知道要提交給 actionFormData,因此它在 fetcher.formData 上可用。我們會使用它來立即更新星星的狀態,即使網路尚未完成。如果更新最終失敗,UI 將還原為真實資料。

👉 fetcher.formData 讀取樂觀值

// existing code

const Favorite: FunctionComponent<{
  contact: Pick<ContactRecord, "favorite">;
}> = ({ contact }) => {
  const fetcher = useFetcher();
  const favorite = fetcher.formData
    ? fetcher.formData.get("favorite") === "true"
    : contact.favorite;

  return (
    <fetcher.Form method="post">
      <button
        aria-label={
          favorite
            ? "Remove from favorites"
            : "Add to favorites"
        }
        name="favorite"
        value={favorite ? "false" : "true"}
      >
        {favorite ? "" : ""}
      </button>
    </fetcher.Form>
  );
};

現在,當您點擊星星時,星星會立即變更為新狀態。


就這樣!感謝您嘗試使用 Remix。我們希望本教學能為您建立出色的使用者體驗奠定堅實的基礎。您還可以做很多事情,因此請務必查看所有 API 😀

文件和範例依據 MIT