通訊錄
本頁面內容

通訊錄

我們將建置一個小巧但功能豐富的通訊錄應用程式,讓您追蹤聯絡人。沒有資料庫或其他「生產就緒」的東西,因此我們可以專注於 React Router 為您提供的功能。如果您跟著操作,預計需要 30-45 分鐘,否則這是一篇快速閱讀的文章。

如果您喜歡 🎥,也可以觀看我們的 React Router 教學逐步解說

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

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

設定

👉 產生基本範本

npx create-react-router@latest --template remix-run/react-router/tutorials/address-book

這使用了一個非常基礎的範本,但包含我們的 css 和資料模型,因此我們可以專注於 React Router。

👉 啟動應用程式

# 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

您應該可以開啟 http://localhost:5173 並看到一個沒有樣式的畫面,看起來像這樣

根路由

請注意 app/root.tsx 中的檔案。這就是我們所謂的「根路由」。它是 UI 中第一個渲染的元件,因此它通常包含頁面的全域版面配置,以及預設的 錯誤邊界

在此展開以查看根元件程式碼
import {
  Form,
  Scripts,
  ScrollRestoration,
  isRouteErrorResponse,
} from "react-router";
import type { Route } from "./+types/root";

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

export default function App() {
  return (
    <>
      <div id="sidebar">
        <h1>React Router 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>
    </>
  );
}

// The Layout component is a special export for the root route.
// It acts as your document's "app shell" for all route components, HydrateFallback, and ErrorBoundary
// For more information, see https://reactrouter.dev.org.tw/explanation/special-files#layout-export
export function Layout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta
          name="viewport"
          content="width=device-width, initial-scale=1"
        />
        <link rel="stylesheet" href={appStylesHref} />
      </head>
      <body>
        {children}
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

// The top most error boundary for the app, rendered when your app throws an error
// For more information, see https://reactrouter.dev.org.tw/start/framework/route-module#errorboundary
export function ErrorBoundary({
  error,
}: Route.ErrorBoundaryProps) {
  let message = "Oops!";
  let details = "An unexpected error occurred.";
  let stack: string | undefined;

  if (isRouteErrorResponse(error)) {
    message = error.status === 404 ? "404" : "Error";
    details =
      error.status === 404
        ? "The requested page could not be found."
        : error.statusText || details;
  } else if (
    import.meta.env.DEV &&
    error &&
    error instanceof Error
  ) {
    details = error.message;
    stack = error.stack;
  }

  return (
    <main id="error-page">
      <h1>{message}</h1>
      <p>{details}</p>
      {stack && (
        <pre>
          <code>{stack}</code>
        </pre>
      )}
    </main>
  );
}

聯絡人路由 UI

如果您按一下其中一個側邊欄項目,您將會看到預設的 404 頁面。讓我們建立一個符合 URL /contacts/1 的路由。

👉 建立聯絡人路由模組

mkdir app/routes
touch app/routes/contact.tsx

我們可以將此檔案放在我們想要的任何位置,但為了使事情更有條理,我們將把所有路由放在 app/routes 目錄中。

如果您願意,也可以使用基於檔案的路由

👉 設定路由

我們需要告訴 React Router 關於我們的新路由。routes.ts 是一個特殊檔案,我們可以在其中設定我們所有的路由。

import type { RouteConfig } from "@react-router/dev/routes";
import { route } from "@react-router/dev/routes";

export default [
  route("contacts/:contactId", "routes/contact.tsx"),
] satisfies RouteConfig;

在 React Router 中,: 使區段動態化。我們剛剛使以下 URL 符合 routes/contact.tsx 路由模組

  • /contacts/123
  • /contacts/abc

👉 新增聯絡人元件 UI

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

import { Form } from "react-router";

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>
  );
}

function Favorite({
  contact,
}: {
  contact: Pick<ContactRecord, "favorite">;
}) {
  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,我們會得到... 沒有新東西?

巢狀路由和 Outlet

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

👉 渲染一個 <Outlet />

import {
  Form,
  Outlet,
  Scripts,
  ScrollRestoration,
  isRouteErrorResponse,
} from "react-router";

// existing imports & exports

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

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

用戶端路由

您可能已經注意到,或者沒有注意到,但是當我們按一下側邊欄中的連結時,瀏覽器正在對下一個 URL 執行完整的文件請求,而不是用戶端路由,這會完全重新掛載我們的應用程式

用戶端路由允許我們的應用程式在不重新載入整個頁面的情況下更新 URL。相反地,應用程式可以立即渲染新的 UI。讓我們使用 <Link> 實現它。

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

import {
  Form,
  Link,
  Outlet,
  Scripts,
  ScrollRestoration,
  isRouteErrorResponse,
} from "react-router";

// existing imports & exports

export default function App() {
  return (
    <>
      <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 */}
    </>
  );
}

您可以開啟瀏覽器開發人員工具中的網路標籤,以查看它不再請求文件。

載入資料

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

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

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

首先,我們將在根路由中建立並匯出 clientLoader 函式,然後渲染資料。

👉 app/root.tsx 匯出 clientLoader 函式並渲染資料

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

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

// existing exports

export async function clientLoader() {
  const contacts = await getContacts();
  return { contacts };
}

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

  return (
    <>
      <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 */}
    </>
  );
}

就這樣!React Router 現在將自動使該資料與您的 UI 保持同步。側邊欄現在應該看起來像這樣

您可能想知道為什麼我們要「用戶端」載入資料,而不是在伺服器上載入資料,以便我們可以進行伺服器端渲染 (SSR)。目前,我們的聯絡人網站是一個 單頁應用程式,因此沒有伺服器端渲染。這使得它可以非常輕鬆地部署到任何靜態託管提供商,但我們將在稍後詳細討論如何啟用 SSR,以便您可以了解 React Router 提供的所有不同 渲染策略

類型安全

您可能注意到我們沒有為 loaderData 屬性指派類型。讓我們修正它。

👉 ComponentProps 類型新增至 App 元件

// existing imports
import type { Route } from "./+types/root";
// existing imports & exports

export default function App({
  loaderData,
}: Route.ComponentProps) {
  const { contacts } = loaderData;

  // existing code
}

等等,什麼?這些類型從哪裡來的?!

我們沒有定義它們,但不知何故它們已經知道我們從 clientLoader 傳回的 contacts 屬性。

那是因為 React Router 為您應用程式中的每個路由產生類型,以提供自動類型安全。

新增 HydrateFallback

我們稍早提到我們正在開發一個沒有伺服器端渲染的 單頁應用程式。如果您查看 react-router.config.ts 內部,您會看到這是使用一個簡單的布林值設定的

import { type Config } from "@react-router/dev/config";

export default {
  ssr: false,
} satisfies Config;

您可能已經開始注意到,每當您重新整理頁面時,在應用程式載入之前,您都會看到一閃而過的白色。由於我們僅在用戶端渲染,因此在應用程式載入時沒有任何內容可以向使用者顯示。

👉 新增 HydrateFallback 匯出

我們可以提供一個後備方案,在應用程式水合之前(第一次在用戶端渲染)顯示 HydrateFallback 匯出。

// existing imports & exports

export function HydrateFallback() {
  return (
    <div id="loading-splash">
      <div id="loading-splash-spinner" />
      <p>Loading, please wait...</p>
    </div>
  );
}

現在,如果您重新整理頁面,您會在應用程式水合之前短暫地看到載入畫面。

索引路由

當您載入應用程式且尚未在聯絡人頁面時,您會注意到清單右側有一個很大的空白頁面。

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

👉 為根路由建立索引路由

touch app/routes/home.tsx
import type { RouteConfig } from "@react-router/dev/routes";
import { index, route } from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  route("contacts/:contactId", "routes/contact.tsx"),
] satisfies RouteConfig;

👉 填寫索引元件的元素

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

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

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

新增關於路由

在我們繼續處理使用者可以互動的動態資料之前,讓我們先新增一個包含我們預期很少變更的靜態內容的頁面。關於頁面將非常適合。

👉 建立關於路由

touch app/routes/about.tsx

別忘了將路由新增至 app/routes.ts

export default [
  index("routes/home.tsx"),
  route("contacts/:contactId", "routes/contact.tsx"),
  route("about", "routes/about.tsx"),
] satisfies RouteConfig;

👉 新增關於頁面 UI

這裡沒有什麼特別的,只需複製並貼上即可

import { Link } from "react-router";

export default function About() {
  return (
    <div id="about">
      <Link to="/">← Go to demo</Link>
      <h1>About React Router Contacts</h1>

      <div>
        <p>
          This is a demo application showing off some of the
          powerful features of React Router, including
          dynamic routing, nested routes, loaders, actions,
          and more.
        </p>

        <h2>Features</h2>
        <p>
          Explore the demo to see how React Router handles:
        </p>
        <ul>
          <li>
            Data loading and mutations with loaders and
            actions
          </li>
          <li>
            Nested routing with parent/child relationships
          </li>
          <li>URL-based routing with dynamic segments</li>
          <li>Pending and optimistic UI</li>
        </ul>

        <h2>Learn More</h2>
        <p>
          Check out the official documentation at{" "}
          <a href="https://reactrouter.dev.org.tw">
            reactrouter.com
          </a>{" "}
          to learn more about building great web
          applications with React Router.
        </p>
      </div>
    </div>
  );
}

👉 在側邊欄中新增關於頁面的連結

export default function App() {
  return (
    <>
      <div id="sidebar">
        <h1>
          <Link to="about">React Router Contacts</Link>
        </h1>
        {/* other elements */}
      </div>
      {/* other elements */}
    </>
  );
}

現在導航至 關於頁面,它應該看起來像這樣

版面配置路由

我們實際上不希望關於頁面巢狀在側邊欄版面配置內。讓我們將側邊欄移動到版面配置,以便我們可以避免在關於頁面上渲染它。此外,我們希望避免在關於頁面上載入所有聯絡人資料。

👉 為側邊欄建立版面配置路由

您可以命名並將此版面配置路由放在您想要的任何位置,但將其放在 layouts 目錄中將有助於為我們的簡單應用程式保持井然有序。

mkdir app/layouts
touch app/layouts/sidebar.tsx

目前只需傳回一個 <Outlet>

import { Outlet } from "react-router";

export default function SidebarLayout() {
  return <Outlet />;
}

👉 將路由定義移動到側邊欄版面配置下

我們可以定義一個 layout 路由,以自動為其中的所有符合路由渲染側邊欄。這基本上就是我們的 root,但現在我們可以將其範圍限定為特定路由。

import type { RouteConfig } from "@react-router/dev/routes";
import {
  index,
  layout,
  route,
} from "@react-router/dev/routes";

export default [
  layout("layouts/sidebar.tsx", [
    index("routes/home.tsx"),
    route("contacts/:contactId", "routes/contact.tsx"),
  ]),
  route("about", "routes/about.tsx"),
] satisfies RouteConfig;

👉 將版面配置和資料擷取移動到側邊欄版面配置

我們想要將 clientLoaderApp 元件內的所有內容移動到側邊欄版面配置。它應該看起來像這樣

import { Form, Link, Outlet } from "react-router";
import { getContacts } from "../data";
import type { Route } from "./+types/sidebar";

export async function clientLoader() {
  const contacts = await getContacts();
  return { contacts };
}

export default function SidebarLayout({
  loaderData,
}: Route.ComponentProps) {
  const { contacts } = loaderData;

  return (
    <>
      <div id="sidebar">
        <h1>
          <Link to="about">React Router Contacts</Link>
        </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>
          {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>
      <div id="detail">
        <Outlet />
      </div>
    </>
  );
}

app/root.tsx 內部,App 應該只傳回一個 <Outlet>,並且可以移除所有未使用的匯入。請確保 root.tsx 中沒有 clientLoader

// existing imports and exports

export default function App() {
  return <Outlet />;
}

完成改組後,我們的關於頁面不再載入聯絡人資料,也不再巢狀在側邊欄版面配置內

預先渲染靜態路由

如果您重新整理關於頁面,您仍然會在頁面在用戶端渲染之前看到載入微調器一秒鐘。這真的不是一個好的體驗,而且頁面只是靜態資訊,我們應該能夠在建置時將其預先渲染為靜態 HTML。

👉 預先渲染關於頁面

react-router.config.ts 內部,我們可以將 prerender 陣列新增至設定,以告知 React Router 在建置時預先渲染某些 URL。在這種情況下,我們只想預先渲染關於頁面。

import { type Config } from "@react-router/dev/config";

export default {
  ssr: false,
  prerender: ["/about"],
} satisfies Config;

現在,如果您前往 關於頁面 並重新整理,您將不會看到載入微調器!

如果您在重新整理時仍然看到微調器,請確保您已刪除 root.tsx 中的 clientLoader

伺服器端渲染

React Router 是建置 單頁應用程式 的絕佳框架。許多應用程式僅透過用戶端渲染以及可能在建置時靜態預先渲染幾個頁面來提供良好的服務。

如果您想要將伺服器端渲染引入您的 React Router 應用程式,這非常容易(還記得稍早的 ssr: false 布林值嗎?)。

👉 啟用伺服器端渲染

export default {
  ssr: true,
  prerender: ["/about"],
} satisfies Config;

現在... 沒有任何變化?我們仍然在頁面在用戶端渲染之前獲得一秒鐘的微調器?此外,我們不是正在使用 clientLoader,所以我們的資料仍然在用戶端擷取?

沒錯!使用 React Router,您仍然可以使用 clientLoader(和 clientAction)在您認為合適的地方執行用戶端資料擷取。React Router 為您提供了很大的彈性,可以使用適合工作的工具。

讓我們切換到使用 loader,它(您猜對了)用於在伺服器上擷取資料。

👉 切換到使用 loader 來擷取資料

// existing imports

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

您是否將 ssr 設定為 truefalse 取決於您和您的使用者需求。兩種策略都完全有效。在本教學的其餘部分,我們將使用伺服器端渲染,但請注意,所有渲染策略都是 React Router 中的一等公民。

Loader 中的 URL 參數

👉 按一下其中一個側邊欄連結

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

還記得 app/routes.ts 中路由定義的 :contactId 部分嗎?這些動態區段將符合 URL 中該位置的動態(變更)值。我們稱 URL 中的這些值為「URL 參數」,或簡稱為「參數」。

這些 params 會傳遞給 loader,其索引鍵與動態區段相符。例如,我們的區段命名為 :contactId,因此該值將作為 params.contactId 傳遞。

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

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

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

// existing imports
import { getContact } from "../data";
import type { Route } from "./+types/contact";

export async function loader({ params }: Route.LoaderArgs) {
  const contact = await getContact(params.contactId);
  return { contact };
}

export default function Contact({
  loaderData,
}: Route.ComponentProps) {
  const { contact } = loaderData;

  // existing code
}

// existing code

拋出 Responses

您會注意到 loaderData.contact 的類型是 ContactRecord | null。根據我們的自動類型安全,TypeScript 已經知道 params.contactId 是一個字串,但我們沒有做任何事情來確保它是一個有效的 ID。由於聯絡人可能不存在,getContact 可能會傳回 null,這就是為什麼我們有類型錯誤的原因。

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

// existing imports

export async function loader({ params }: Route.LoaderArgs) {
  const contact = await getContact(params.contactId);
  if (!contact) {
    throw new Response("Not Found", { status: 404 });
  }
  return { contact };
}

// existing code

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

資料變更

我們將在一秒鐘內建立我們的第一個聯絡人,但首先讓我們談談 HTML。

React Router 模擬 HTML 表單導航作為資料變更原始物件,這曾經是 JavaScript Cambrian 大爆發之前的唯一方法。不要被簡單性所迷惑!React Router 中的表單為您提供了用戶端渲染應用程式的 UX 功能,以及「舊式」網路模型的簡潔性。

雖然有些網路開發人員不熟悉,但 HTML form 實際上會在瀏覽器中引起導航,就像按一下連結一樣。唯一的區別在於請求:連結只能變更 URL,而 form 也可以變更請求方法(GETPOST)和請求正文(POST 表單資料)。

如果沒有用戶端路由,瀏覽器將自動序列化 form 的資料,並將其作為 POST 的請求正文以及 URLSearchParams 作為 GET 傳送到伺服器。React Router 執行相同的操作,除了不是將請求傳送到伺服器,而是使用用戶端路由並將其傳送到路由的 action 函式。

我們可以透過按一下應用程式中的「新增」按鈕來測試這一點。

React Router 會傳送 405,因為伺服器上沒有程式碼來處理此表單導航。

建立聯絡人

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

👉 app/root.tsx 匯出 action 函式

// existing imports

import { createEmptyContact } from "./data";

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

// existing code

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

createEmptyContact 方法只是建立一個沒有名稱或資料或任何東西的空聯絡人。但它仍然會建立一個記錄,我保證!

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

這就是「舊式網路」程式設計模型出現的地方。<Form> 阻止瀏覽器將請求傳送到伺服器,而是使用 fetch 將其傳送到路由的 action 函式。

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

事實上,由於這一切都只是 HTML 和 HTTP,您可以停用 JavaScript,整個功能仍然可以運作。瀏覽器不會像 React Router 將表單序列化並向您的伺服器發出 fetch 請求,而是會序列化表單並發出文件請求。然後,React Router 將在伺服器端渲染頁面並將其發送下來。最終的使用者介面 (UI) 都是相同的。

不過,我們還是會保留 JavaScript,因為我們將提供比旋轉 favicon 和靜態文件更好的使用者體驗。

更新資料

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

就像建立資料一樣,您可以使用 <Form> 來更新資料。讓我們在 app/routes/edit-contact.tsx 內建立一個新的路由模組。

👉 建立編輯聯絡人路由

touch app/routes/edit-contact.tsx

別忘了將路由新增至 app/routes.ts

export default [
  layout("layouts/sidebar.tsx", [
    index("routes/home.tsx"),
    route("contacts/:contactId", "routes/contact.tsx"),
    route(
      "contacts/:contactId/edit",
      "routes/edit-contact.tsx"
    ),
  ]),
  route("about", "routes/about.tsx"),
] satisfies RouteConfig;

👉 新增編輯頁面 UI

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

import { Form } from "react-router";
import type { Route } from "./+types/edit-contact";

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

export async function loader({ params }: Route.LoaderArgs) {
  const contact = await getContact(params.contactId);
  if (!contact) {
    throw new Response("Not Found", { status: 404 });
  }
  return { contact };
}

export default function EditContact({
  loaderData,
}: Route.ComponentProps) {
  const { contact } = loaderData;

  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 函式。React Router 將序列化 form,使用 fetch 發出 POST 請求,並自動重新驗證所有資料。

👉 action 函式新增至編輯路由

import { Form, redirect } from "react-router";
// existing imports

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

export async function action({
  params,
  request,
}: Route.ActionArgs) {
  const formData = await request.formData();
  const updates = Object.fromEntries(formData);
  await updateContact(params.contactId, updates);
  return redirect(`/contacts/${params.contactId}`);
}

// existing code

填寫表單,按下儲存,您應該會看到類似這樣的畫面! (除了更賞心悅目,並且可能有耐心切西瓜。)

變更討論

😑 功能正常運作了,但我不知道這裡發生了什麼事...

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

打開 app/routes/edit-contact.tsx 並查看 form 元素。請注意它們各自都有一個名稱

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

在沒有 JavaScript 的情況下,當表單提交時,瀏覽器將建立 FormData,並在將其發送到伺服器時將其設定為請求的 body。如前所述,React Router 會阻止這種情況,並透過使用 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 都不是由 React Router 提供的:requestrequest.formDataObject.fromEntries 都是由網路平台提供的。

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

export async function action({
  params,
  request,
}: Route.ActionArgs) {
  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 請求後重新導向,則新頁面將擷取最新的資料並渲染。正如我們之前學到的,React Router 模擬了此模型,並在 action 呼叫後自動重新驗證頁面上的資料。這就是為什麼當我們儲存表單時,側邊欄會自動更新。額外的重新驗證程式碼在沒有用戶端路由的情況下不存在,因此在 React Router 中使用用戶端路由時也不需要存在!

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

將新紀錄重新導向至編輯頁面

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

👉 重新導向至新紀錄的編輯頁面

import {
  Outlet,
  Scripts,
  ScrollRestoration,
  isRouteErrorResponse,
  redirect,
} from "react-router";
// existing imports

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

// existing code

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

現在我們有很多紀錄,因此不清楚我們在側邊欄中正在查看哪個紀錄。我們可以使用 NavLink 來修正此問題。

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

import { Form, Link, NavLink, Outlet } from "react-router";

// existing imports and exports

export default function SidebarLayout({
  loaderData,
}: Route.ComponentProps) {
  const { contacts } = loaderData;

  return (
    <>
      <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 */}
    </>
  );
}

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

全域待處理 UI

當使用者瀏覽應用程式時,React Router 會在載入下一個頁面的資料時保留舊頁面。您可能已經注意到,當您在列表之間點擊時,應用程式感覺有點沒有反應。讓我們向使用者提供一些回饋,讓應用程式感覺不會沒有反應。

React Router 正在幕後管理所有狀態,並揭示您需要建置動態 Web 應用程式的部分。在本例中,我們將使用 useNavigation Hook。

👉 使用 useNavigation 新增全域待處理 UI

import {
  Form,
  Link,
  NavLink,
  Outlet,
  useNavigation,
} from "react-router";

export default function SidebarLayout({
  loaderData,
}: Route.ComponentProps) {
  const { contacts } = loaderData;
  const navigation = useNavigation();

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

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 中渲染的,因此當點擊具有 destroy 的相對 action 時,表單將提交到 contacts/:contactId/destroy

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

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

👉 設定「destroy」路由模組

touch app/routes/destroy-contact.tsx
export default [
  // existing routes
  route(
    "contacts/:contactId/destroy",
    "routes/destroy-contact.tsx"
  ),
  // existing routes
] satisfies RouteConfig;

👉 新增 destroy action

import { redirect } from "react-router";
import type { Route } from "./+types/destroy-contact";

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

export async function action({ params }: Route.ActionArgs) {
  await deleteContact(params.contactId);
  return redirect("/");
}

好了,導覽至一個紀錄並點擊「刪除」按鈕。它運作了!

😅 我仍然不明白這一切是如何運作的

當使用者點擊提交按鈕時

  1. <Form> 防止了瀏覽器的預設行為,即向伺服器發送新的文件 POST 請求,而是透過用戶端路由和 fetch 來模擬瀏覽器,建立 POST 請求
  2. <Form action="destroy"> 匹配 contacts/:contactId/destroy 的新路由,並將請求發送給它
  3. action 重新導向後,React Router 會呼叫頁面上所有資料的 loader,以取得最新值(這是「重新驗證」)。routes/contact.tsx 中的 loaderData 現在有了新值,並導致元件更新!

新增 Form,新增 action,React Router 會完成剩下的工作。

取消按鈕

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

我們需要在按鈕上新增點擊處理常式以及 useNavigate

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

import { Form, redirect, useNavigate } from "react-router";
// existing imports & exports

export default function EditContact({
  loaderData,
}: Route.ComponentProps) {
  const { contact } = loaderData;
  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 現在將您的查詢包含在 URL 中,作為 URLSearchParams

http://localhost:5173/?q=ryan

由於它不是 <Form method="post">,React Router 會透過將 FormData 序列化為 URLSearchParams 而不是請求 body 來模擬瀏覽器。

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

👉 如果存在 URLSearchParams,則篩選列表

// existing imports & exports

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

// existing code

因為這是 GET,而不是 POST,所以 React Router 不會呼叫 action 函式。提交 GET form 與點擊連結相同:只會變更 URL。

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

將 URL 與表單狀態同步

這裡有一些使用者體驗 (UX) 問題,我們可以快速處理。

  1. 如果您在搜尋後點擊上一頁,即使列表不再篩選,表單欄位仍然具有您輸入的值。
  2. 如果您在搜尋後重新整理頁面,即使列表已篩選,表單欄位也不再具有值

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

讓我們首先解決 (2) 並使用 URL 中的值啟動輸入。

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

// existing imports & exports

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

export default function SidebarLayout({
  loaderData,
}: Route.ComponentProps) {
  const { contacts, q } = loaderData;
  const navigation = useNavigation();

  return (
    <>
      <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 */}
    </>
  );
}

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

現在解決問題 (1),點擊上一頁按鈕並更新輸入。我們可以從 React 引入 useEffect 以直接操作 DOM 中的輸入值。

👉 將輸入值與 URLSearchParams 同步

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

// existing imports & exports

export default function SidebarLayout({
  loaderData,
}: Route.ComponentProps) {
  const { contacts, q } = loaderData;
  const navigation = useNavigation();

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

  // existing code
}

🤔 您不應該使用受控元件和 React State 來執行此操作嗎?

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

展開以查看它的外觀
// existing imports
import { useEffect, useState } from "react";

// existing imports & exports

export default function SidebarLayout({
  loaderData,
}: Route.ComponentProps) {
  const { contacts, q } = loaderData;
  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 (
    <>
      <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 */}
    </>
  );
}

好了,您現在應該能夠點擊上一頁/下一頁/重新整理按鈕,並且輸入的值應該與 URL 和結果同步。

提交 FormonChange

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

我們已經看過 useNavigate 了,我們將使用它的近親 useSubmit 來完成此操作。

import {
  Form,
  Link,
  NavLink,
  Outlet,
  useNavigation,
  useSubmit,
} from "react-router";
// existing imports & exports

export default function SidebarLayout({
  loaderData,
}: Route.ComponentProps) {
  const { contacts, q } = loaderData;
  const navigation = useNavigation();
  const submit = useSubmit();

  // existing code

  return (
    <>
      <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 */}
    </>
  );
}

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

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

新增搜尋微調器

在生產環境應用程式中,此搜尋很可能會在資料庫中尋找紀錄,該資料庫太大而無法一次發送和在用戶端篩選。這就是為什麼此示範有一些偽造的網路延遲。

在沒有任何載入指示器的情況下,搜尋感覺有點遲緩。即使我們可以讓我們的資料庫更快,我們始終會受到使用者網路延遲的影響,而這是我們無法控制的。

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

👉 新增一個變數以了解我們是否正在搜尋

// existing imports & exports

export default function SidebarLayout({
  loaderData,
}: Route.ComponentProps) {
  const { contacts, q } = loaderData;
  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 SidebarLayout({
  loaderData,
}: Route.ComponentProps) {
  // existing code

  return (
    <>
      <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 */}
    </>
  );
}

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

// existing imports & exports

export default function SidebarLayout({
  loaderData,
}: Route.ComponentProps) {
  // existing code

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

您現在應該在搜尋輸入的左側有一個漂亮的微調器。

管理歷史堆疊

由於表單是為每個按鍵提交的,因此輸入字元「alex」然後使用退格鍵刪除它們會導致巨大的歷史堆疊 😂。我們絕對不希望這樣

我們可以透過替換歷史堆疊中的目前項目與下一個頁面,而不是推入它來避免這種情況。

👉 submit 中使用 replace

// existing imports & exports

export default function SidebarLayout({
  loaderData,
}: Route.ComponentProps) {
  // existing code

  return (
    <>
      <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 */}
    </>
  );
}

在快速檢查這是否是第一次搜尋之後,我們決定替換。現在第一次搜尋將新增一個新項目,但之後的每個按鍵都會替換目前的項目。使用者只需點擊一次上一頁即可移除搜尋,而不是點擊 7 次。

沒有導覽的 Form

到目前為止,我們所有的表單都變更了 URL。雖然這些使用者流程很常見,但同樣常見的是希望在引起導覽的情況下提交表單。

對於這些情況,我們有 useFetcher。它允許我們在不引起導覽的情況下與 actionloader 進行通訊。

聯絡人頁面上的 ★ 按鈕對此很有意義。我們沒有建立或刪除新紀錄,而且我們不想變更頁面。我們只是想變更我們正在查看的頁面上的資料。

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

import { Form, useFetcher } from "react-router";

// existing imports & exports

function Favorite({
  contact,
}: {
  contact: Pick<ContactRecord, "favorite">;
}) {
  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>
  );
}

此表單將不再引起導覽,而只是 fetch 到 action。說到 action ... 在我們建立 action 之前,這將無法運作。

👉 建立 action

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

export async function action({
  params,
  request,
}: Route.ActionArgs) {
  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

function Favorite({
  contact,
}: {
  contact: Pick<ContactRecord, "favorite">;
}) {
  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>
  );
}

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


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

文件和範例 CC 4.0