歡迎來到教學!我們將建立一個小而功能齊全的應用程式,讓您可以追蹤您的聯絡人。如果您正在照著做,我們預期這將花費 30-60 分鐘的時間。
👉 每當您看到這個時,表示您需要在應用程式中執行某件事!
其餘的只是提供給您參考和進一步了解。讓我們開始吧。
我們將會在本教學中使用 Vite 作為我們的打包器和開發伺服器。你必須安裝 Node.js 以便使用 npm
指命行工具。
👉 打開終端機,使用 Vite 建立一個新的 React 應用程式:
npm create vite@latest name-of-your-project -- --template react
# follow prompts
cd <your new project directory>
npm install react-router-dom # always need this!
npm install localforage match-sorter sort-by # only for this tutorial.
npm run dev
你應該能夠造訪終端機中顯示的網址
VITE v3.0.7 ready in 175 ms
➜ Local: http://127.0.0.1:5173/
➜ Network: use --host to expose
我們準備了一些預先寫好的 CSS 供本教學使用,才能專注於 React Router。你可以盡情批評或自行撰寫😅(我們在 CSS 中做了一些正常情況下並不會做的事,所以本教學中的標記才能盡可能地精簡。)
👉 複製/貼上本教學的 CSS 按此處查看 到 src/index.css
本教學將會建立、讀取、搜尋、更新和刪除資料。典型的網頁應用程式可能會與你的網頁伺服器上的 API 對話,但我們將會使用瀏覽器儲存和偽造一些網路延遲,讓你能專注於此。以下程式碼與 React Router 無關,所以請直接複製/貼上。
👉 複製/貼上本教學的資料模組 按此處查看 到 src/contacts.js
你只需要在 src 資料夾中放置有 contacts.js
、main.jsx
及 index.css
。你可以刪除其他所有東西(例如 App.js
、assets
等)。
👉 刪除 src/
中未使用的檔案,讓其僅剩以下檔案:
src
├── contacts.js
├── index.css
└── main.jsx
如果你的應用程式正在執行,它可能會在短時間內崩潰,請持續進行 😋。これで準備好了開始!
首先我們要建立一個 瀏覽器路由器 並設定我們的首個路由。這將為我們的網路應用程式啟用用戶端端路由。
main.jsx
檔案是進入點。開啟這個檔案,我們將 React Router 放到網頁上。
👉 在 main.jsx
中建立並呈現一個 瀏覽器路由器
import * as React from "react";
import * as ReactDOM from "react-dom/client";
import {
createBrowserRouter,
RouterProvider,
} from "react-router-dom";
import "./index.css";
const router = createBrowserRouter([
{
path: "/",
element: <div>Hello world!</div>,
},
]);
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>
);
第一個路由通常稱為「根路由」,因為我們其他的路由會在其中呈現。它會作為 UI 的根層級,我們將在走得更深入的同時建立巢狀層級。
我們來新增這個應用程式的全域層級。
👉 建立 src/routes
和 src/routes/root.jsx
mkdir src/routes
touch src/routes/root.jsx
(如果你不想成為命令列達人,請使用你的編輯器,而不是這些命令 🤓)
👉 建立根層級元件
export default function Root() {
return (
<>
<div id="sidebar">
<h1>React Router Contacts</h1>
<div>
<form id="search-form" role="search">
<input
id="q"
aria-label="Search contacts"
placeholder="Search"
type="search"
name="q"
/>
<div
id="search-spinner"
aria-hidden
hidden={true}
/>
<div
className="sr-only"
aria-live="polite"
></div>
</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>
<div id="detail"></div>
</>
);
}
還沒有任何 React Router 特有的部分,所以你可以複製/貼上所有內容。
👉 設定 <Root>
作為根路由的 element
/* existing imports */
import Root from "./routes/root";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
},
]);
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>
);
現在應用程式看起來應該類似於這樣。如果有一個既可以設計又能寫 CSS 的設計師,這真是太好了,不是嗎?(感謝 Jim 🙏)。
了解應用程式在專案早期如何回應錯誤始終是個好主意,因為我們在建構新應用程式時撰寫的錯誤遠比功能多!這樣做時,您的使用者不僅能獲得良好的體驗,還能對您的開發有所助益。
我們為此應用程式新增了一些連結,讓我們看看當我們按一下它們時會發生什麼事?
👉 按一下其中一個側欄名稱
好噁心!這是 React Router 中的預設錯誤畫面,而這個應用程式中根元素的彈性盒子樣式讓情況更糟 😂。
每當您的應用程式在執行、載入資料或執行資料變更時發生錯誤,React Router 會捕獲錯誤並執行錯誤畫面。讓我們建立自己的錯誤頁面。
👉 建立錯誤頁面組件
touch src/error-page.jsx
import { useRouteError } from "react-router-dom";
export default function ErrorPage() {
const error = useRouteError();
console.error(error);
return (
<div id="error-page">
<h1>Oops!</h1>
<p>Sorry, an unexpected error has occurred.</p>
<p>
<i>{error.statusText || error.message}</i>
</p>
</div>
);
}
👉 將 <ErrorPage>
設定為根路由上的 errorElement
/* previous imports */
import ErrorPage from "./error-page";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
},
]);
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>
);
錯誤頁面現在應該看起來像這樣
(好吧,好不到哪裡去。也許有人忘了請設計師製作錯誤頁面。也許每個人都忘了請設計師製作錯誤頁面,然後怪罪設計師沒想過 😆)
請注意,useRouteError
提供拋出的錯誤。當使用者導覽至不存在的路由時,您會收到一則「找不到」statusText
的 錯誤回應。我們稍後將在教學課程中看到其他錯誤,並進一步討論它們。
現在,只需要知道您幾乎所有的錯誤都將由此頁面處理,而不是無限旋轉、無回應頁面或空白畫面 🙌
我們要實際在已連結的網址上執行,而不是 404「找不到」頁面。為此,我們需要建立一個新的路由。
👉 建立聯絡資訊路由模組
touch src/routes/contact.jsx
👉 新增聯絡資訊組件 UI
它只是一堆元素,請自行複製/貼上。
import { Form } from "react-router-dom";
export default function Contact() {
const contact = {
first: "Your",
last: "Name",
avatar: "https://robohash.org/you.png?size=200x200",
twitter: "your_handle",
notes: "Some notes",
favorite: true,
};
return (
<div id="contact">
<div>
<img
key={contact.avatar}
src={
contact.avatar ||
`https://robohash.org/${contact.id}.png?size=200x200`
}
/>
</div>
<div>
<h1>
{contact.first || contact.last ? (
<>
{contact.first} {contact.last}
</>
) : (
<i>No Name</i>
)}{" "}
<Favorite contact={contact} />
</h1>
{contact.twitter && (
<p>
<a
target="_blank"
href={`https://twitter.com/${contact.twitter}`}
>
{contact.twitter}
</a>
</p>
)}
{contact.notes && <p>{contact.notes}</p>}
<div>
<Form action="edit">
<button type="submit">Edit</button>
</Form>
<Form
method="post"
action="destroy"
onSubmit={(event) => {
if (
!confirm(
"Please confirm you want to delete this record."
)
) {
event.preventDefault();
}
}}
>
<button type="submit">Delete</button>
</Form>
</div>
</div>
</div>
);
}
function Favorite({ contact }) {
const favorite = contact.favorite;
return (
<Form method="post">
<button
name="favorite"
value={favorite ? "false" : "true"}
aria-label={
favorite
? "Remove from favorites"
: "Add to favorites"
}
>
{favorite ? "★" : "☆"}
</button>
</Form>
);
}
現在我們已經有一個組件,讓我們將它連接到新的路由。
👉 匯入聯絡資訊組件並建立新路由
/* existing imports */
import Contact from "./routes/contact";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
},
{
path: "contacts/:contactId",
element: <Contact />,
},
]);
/* existing code */
現在,如果我們按一下其中一個連結或拜訪 /contacts/1
,我們就能獲得新的組件!
不過,它不在我們的根版面配置中 😠
我們希望 `<Root>` 配置中如以下所示,在內部 渲染連絡人元件。
我們將連絡人路由設為根路由的子物件即可達成。
👉 將連絡人路由移至根路由的子物件
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
children: [
{
path: "contacts/:contactId",
element: <Contact />,
},
],
},
]);
現在,你會再看到根配置,不過右側會是空白頁面。我們需要告訴根路由在何處 想要渲染其子路由。我們使用 <Outlet>
便可達成。
加入 `<div id="detail">`,並在其內部新增一個出口
👉 呈現 <Outlet>
import { Outlet } from "react-router-dom";
export default function Root() {
return (
<>
{/* all the other elements */}
<div id="detail">
<Outlet />
</div>
</>
);
}
你可能已經注意到,當我們按一下側邊欄中的連結時,瀏覽器會對下一個 URL 執行完整文件要求,而非使用 React Router。
用戶端路由讓我們的應用程式在不向伺服器要求其他文件的情況下更新 URL。相反地,應用程式能夠立即呈現新的使用者介面。讓我們使用 <Link>
達成。
👉 將側邊欄中的 <a href>
變更為 <Link to>
import { Outlet, Link } from "react-router-dom";
export default function Root() {
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>
{/* other elements */}
</div>
</>
);
}
你可以開啟瀏覽器開發工具中的網路標籤,這樣你便會知道它不再要求文件了。
URL 片段、配置與資料通常會同時(三合一?)出現。我們已經可以在這個應用程式中看到:
URL 片段 | 元件 | 資料 |
---|---|---|
/ | <Root> |
連絡人清單 |
contacts/:id | <Contact> |
個別連絡人 |
由於這種自然結合,React Router 具有資料慣例,讓你能夠輕鬆地將資料放入路由元件中。
我們將使用兩個 API 來載入資料,loader
和 useLoaderData
。首先,我們在根模組中新增並匯出一個 loader 函式,再將其連結至路由。最後,我們將存取並呈現資料。
👉 從 root.jsx
匯出一個 loader
import { Outlet, Link } from "react-router-dom";
import { getContacts } from "../contacts";
export async function loader() {
const contacts = await getContacts();
return { contacts };
}
在路由中設定 loader
/* other imports */
import Root, { loader as rootLoader } from "./routes/root";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
loader: rootLoader,
children: [
{
path: "contacts/:contactId",
element: <Contact />,
},
],
},
]);
👉 存取並呈現資料
import {
Outlet,
Link,
useLoaderData,
} from "react-router-dom";
import { getContacts } from "../contacts";
/* other code */
export default function Root() {
const { contacts } = useLoaderData();
return (
<>
<div id="sidebar">
<h1>React Router Contacts</h1>
{/* other code */}
<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>}
</Link>
</li>
))}
</ul>
) : (
<p>
<i>No contacts</i>
</p>
)}
</nav>
{/* other code */}
</div>
</>
);
}
就是這樣!React Router 現在會自動讓資料與你的使用者介面保持同步。我們目前還沒有任何資料,因此你可能會取得一個空清單,如下所示
我們將在稍後建立第一個聯絡人,但我們先來討論 HTML。
React Router 模擬 HTML 表單導覽功能作為資料變異原語,這符合 JavaScript 寒武紀大爆發前的網頁開發方式。它讓你能夠結合用戶端呈現式應用程式的 UX 功能,以及「舊式」網頁模型的易用性。
對於某些網頁開發人員來說並不熟悉,但 HTML 表單實際上會造成瀏覽器內的導覽,就像按一下連結一樣。唯一不同的是在於要求:連結只能更改網址,而表單則可以變更要求方法 (GET 與 POST) 和要求主體 (POST 表單資料)。
在沒有客戶端路由的情況下,瀏覽器會自動序列化表單資料並將其傳送給伺服器作為 POST 的要求主體、以及 GET 的 URLSearchParams。React 路由執行相同的動作,只不過並非將要求傳送給伺服器,而是使用客戶端路由並將其傳送到路徑 動作
。
我們可以透過按一下應用程式中的「新增」按鈕來測試此動作。應用程式應該會炸掉,因為 Vite 伺服器並未設定為處理 POST 要求 (它會傳送 404,儘管它應該可能是 405 🤷)。
不要將 POST 傳送給 Vite 伺服器來建立新聯絡人,改用客戶端路由吧。
我們將透過在根路由中匯出 動作
、將其連接到路由組態,並將我們的 <form>
變更為 React 路由的 <Form>
來建立新的聯絡人。
👉 建立動作並將 <form>
改成 <Form>
import {
Outlet,
Link,
useLoaderData,
Form,
} from "react-router-dom";
import { getContacts, createContact } from "../contacts";
export async function action() {
const contact = await createContact();
return { contact };
}
/* other code */
export default function Root() {
const { contacts } = useLoaderData();
return (
<>
<div id="sidebar">
<h1>React Router Contacts</h1>
<div>
{/* other code */}
<Form method="post">
<button type="submit">New</button>
</Form>
</div>
{/* other code */}
</div>
</>
);
}
👉 匯入並在路由上設定動作
/* other imports */
import Root, {
loader as rootLoader,
action as rootAction,
} from "./routes/root";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
loader: rootLoader,
action: rootAction,
children: [
{
path: "contacts/:contactId",
element: <Contact />,
},
],
},
]);
完成了!繼續並按一下「新增」按鈕,你應該會看到串列中彈出新的記錄 🥳
createContact
方法只建立一個沒有名稱、資料或其他元素的空聯絡人。但它確實還是建立了一個記錄,有保證!
🧐 等一下......側欄是如何更新的?我們在哪裡呼叫
動作
?重新擷取資料的程式碼在哪裡?useState
、onSubmit
和useEffect
又在哪裡?!
這就是「舊式網路」程式設計模型出現的地方。正如我們稍早討論的,<Form>
會阻止瀏覽器將要求傳送給伺服器,並改為傳送給你的路由 動作
。在網路語意中,POST 通常表示某些資料正在變更。依慣例,React 路由使用這項暗示,在動作結束後自動重新驗證頁面上的資料。這表示你的所有 useLoaderData
鉤子都會更新,而且使用者介面會自動與你的資料保持同步!還不錯吧。
👉 按一下「無名稱」記錄
我們應該會再次看到舊的靜態聯絡人頁面,只有一個不同:網址現在有該記錄的真實 ID。
檢閱路由組態,路由如下所示
[
{
path: "contacts/:contactId",
element: <Contact />,
},
];
請注意 :contactId
網址區段。冒號 (:
) 有特別的意義,將其變更為「動態區段」。動態區段會比對網址中該位置的動態 (變更) 值,例如聯絡人 ID。我們在網址中將這些值稱為「網址參數」,或簡稱「參數」。
這些params
傳遞給 loader,並使用對應動態區段的 key。例如,我們的區段命名為:contactId
,因此值會傳遞為params.contactId
。
這些 params 最常使用來透過 ID 搜尋記錄。我們試試看。
👉 於聯絡人頁面新增 loader,並使用useLoaderData
存取資料
import { Form, useLoaderData } from "react-router-dom";
import { getContact } from "../contacts";
export async function loader({ params }) {
const contact = await getContact(params.contactId);
return { contact };
}
export default function Contact() {
const { contact } = useLoaderData();
// existing code
}
在路由中設定 loader
/* existing code */
import Contact, {
loader as contactLoader,
} from "./routes/contact";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
loader: rootLoader,
action: rootAction,
children: [
{
path: "contacts/:contactId",
element: <Contact />,
loader: contactLoader,
},
],
},
]);
/* existing code */
如同建立資料,可以使用<Form>
來更新資料。讓我們在contacts/:contactId/edit
新增一個路由。我們同樣先從元件開始,然後將其連結到路由設定。
👉 建立編輯元件
touch src/routes/edit.jsx
👉 新增編輯頁面 UI
沒有什麼我們沒看過的,可以自由複製/貼上
import { Form, useLoaderData } from "react-router-dom";
export default function EditContact() {
const { contact } = useLoaderData();
return (
<Form method="post" id="contact-form">
<p>
<span>Name</span>
<input
placeholder="First"
aria-label="First name"
type="text"
name="first"
defaultValue={contact?.first}
/>
<input
placeholder="Last"
aria-label="Last name"
type="text"
name="last"
defaultValue={contact?.last}
/>
</p>
<label>
<span>Twitter</span>
<input
type="text"
name="twitter"
placeholder="@jack"
defaultValue={contact?.twitter}
/>
</label>
<label>
<span>Avatar URL</span>
<input
placeholder="https://example.com/avatar.jpg"
aria-label="Avatar URL"
type="text"
name="avatar"
defaultValue={contact?.avatar}
/>
</label>
<label>
<span>Notes</span>
<textarea
name="notes"
defaultValue={contact?.notes}
rows={6}
/>
</label>
<p>
<button type="submit">Save</button>
<button type="button">Cancel</button>
</p>
</Form>
);
}
👉 新增新的編輯路由
/* existing code */
import EditContact from "./routes/edit";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
loader: rootLoader,
action: rootAction,
children: [
{
path: "contacts/:contactId",
element: <Contact />,
loader: contactLoader,
},
{
path: "contacts/:contactId/edit",
element: <EditContact />,
loader: contactLoader,
},
],
},
]);
/* existing code */
我們希望在根路由的 outlet 中呈現,因此我們讓它成為現有子層路由的兄弟元件。
(您可能會注意到,我們對此路由重複使用contactLoader
。這只因為我們在教學中比較懶惰。沒必要嘗試在路由間共用 loader,它們通常有各自的。)
好了,點選「編輯」按鈕,會顯示這個新的 UI
我們剛剛建立的編輯路由已經呈現表單。我們只需要將動作連結到路由,就能更新記錄。表單會將資料提交給動作,而資料會自動重新驗證。
👉 於編輯模組中新增動作
import {
Form,
useLoaderData,
redirect,
} from "react-router-dom";
import { updateContact } from "../contacts";
export async function action({ request, params }) {
const formData = await request.formData();
const updates = Object.fromEntries(formData);
await updateContact(params.contactId, updates);
return redirect(`/contacts/${params.contactId}`);
}
/* existing code */
👉 將動作連結到路由
/* existing code */
import EditContact, {
action as editAction,
} from "./routes/edit";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
loader: rootLoader,
action: rootAction,
children: [
{
path: "contacts/:contactId",
element: <Contact />,
loader: contactLoader,
},
{
path: "contacts/:contactId/edit",
element: <EditContact />,
loader: contactLoader,
action: editAction,
},
],
},
]);
/* existing code */
填寫表格,點選儲存,您應該會看到類似這樣的情況!(不過比較賞心悅目,而且毛髮可能比較少。)
😑 是有作用,但我完全不知道發生什麼事了...
我們來稍微深入探討一下...
開啟src/routes/edit.jsx
並查看表單元素。請注意它們各自都有名稱
<input
placeholder="First"
aria-label="First name"
type="text"
name="first"
defaultValue={contact.first}
/>
沒有 JavaScript 時,當提交表單時,瀏覽器將建立FormData
將其設定為請求主體,然後在將其傳送至伺服器時。如同之前所述,React Router 會阻止此行為,並將請求連同FormData
一起傳送至您的動作。
表單中的每個欄位可以使用formData.get(name)
存取。例如,根據上述輸入欄位,您可以像這樣存取姓名和姓氏
export async function action({ request, params }) {
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 提供: request
、 request.formData
、 Object.fromEntries
都由網頁平臺提供。
我們完成動作後,請注意最後的 redirect
export async function action({ request, params }) {
const formData = await request.formData();
const updates = Object.fromEntries(formData);
await updateContact(params.contactId, updates);
return redirect(`/contacts/${params.contactId}`);
}
載入程式和動作都可以 傳回 Response
(有道理,因為它們收到了 Request
!)。 redirect
輔助程式讓傳回的 回應 告訴應用程式變更位置變得更為容易。
如果在 POST 要求後伺服器進行重新導向,沒有用戶端路由,新的網頁會擷取最新資料並渲染。正如我們先前所學,React Router 模擬此模型,並在動作後自動重新驗證網頁上的資料。這就是為什麼儲存表單時側邊欄會自動更新。沒有用戶端路由,就不會有額外的重新驗證程式碼,因此,在用戶端路由中也不需要!
現在我們知道如何重新導向了,讓我們更新建立新聯絡人的動作,並重新導向到編輯頁面
👉 重新導向到新記錄的編輯頁面
import {
Outlet,
Link,
useLoaderData,
Form,
redirect,
} from "react-router-dom";
import { getContacts, createContact } from "../contacts";
export async function action() {
const contact = await createContact();
return redirect(`/contacts/${contact.id}/edit`);
}
現在當我們按一下「新增」時,我們應該會到編輯頁面
👉 加入一些記錄
我將使用第一個 Remix Conference 中傑出的演講者陣容 😁
現在我們有了很多記錄,在側邊欄中,不清楚我們正在檢視哪一個。我們可以使用 NavLink
來修正它。
👉 在側邊欄中使用 NavLink
import {
Outlet,
NavLink,
useLoaderData,
Form,
redirect,
} from "react-router-dom";
export default function Root() {
return (
<>
<div id="sidebar">
{/* other code */}
<nav>
{contacts.length ? (
<ul>
{contacts.map((contact) => (
<li key={contact.id}>
<NavLink
to={`contacts/${contact.id}`}
className={({ isActive, isPending }) =>
isActive
? "active"
: isPending
? "pending"
: ""
}
>
{/* other code */}
</NavLink>
</li>
))}
</ul>
) : (
<p>{/* other code */}</p>
)}
</nav>
</div>
</>
);
}
請注意,我們會傳遞一個函數給 className
。當使用者處於 NavLink
中的 URL 時,isActive
將會為 true。當 it 即將 被啟用(資料仍正在載入)時,isPending
將會為 true。這讓我們可以輕鬆地顯示使用者的所在位置,以及在按了一下連結但我們仍在等待資料載入時,立即提供意見回饋。
當使用者瀏覽應用程式時,React Router 會保留舊頁面並顯示,因為正在為下一個頁面載入資料。當你在資料清單之間點選時,你可能注意到應用程式有點沒有反應。讓我們提供使用者一些回饋,這樣應用程式才不會讓人感覺沒有反應。
React Router 幕後管理所有狀態,並透露你可以用來建構動態網頁應用程式的部分。在這個情況中,我們將使用 useNavigation
鉤子。
👉 useNavigation
來加入全域進行中 UI
import {
// existing code
useNavigation,
} from "react-router-dom";
// existing code
export default function Root() {
const { contacts } = useLoaderData();
const navigation = useNavigation();
return (
<>
<div id="sidebar">{/* existing code */}</div>
<div
id="detail"
className={
navigation.state === "loading" ? "loading" : ""
}
>
<Outlet />
</div>
</>
);
}
useNavigation
回傳目前的導航狀態:可能是 "idle" | "submitting" | "loading"
之一。
在我們的案例中,如果我們沒有處於閒置狀態,我們會在應用程式的主要部分加入 "loading"
類別。然後 CSS 會在短暫延遲後加入一個漂亮的淡入效果(為了避免快速載入時 UI 閃爍)。你可以做任何你想要做的事,例如在上方顯示一個旋轉圖示或載入條。
請注意,我們的資料模型 (src/contacts.js
) 有個客戶端快取,所以第二次導航到同一個聯絡人時速度會很快。這個行為不是 React Router,它會為不同的路由重新載入資料,不管你先前是否曾到過該路由。不過,它會避免在導航中為不變動的路由(例如清單)呼叫載入器。
如果我們檢閱聯絡人路由中的程式碼,我們可以看到刪除按鈕如下所述
<Form
method="post"
action="destroy"
onSubmit={(event) => {
if (
!confirm(
"Please confirm you want to delete this record."
)
) {
event.preventDefault();
}
}}
>
<button type="submit">Delete</button>
</Form>
請注意,action
指向 "destroy"
。就像 <Link to>
,<Form action>
可以接收一個相對值。因為表格是在 contact/:contactId
中渲染,所以當使用 destroy
的相對動作時,會在點選時將表格提交至 contact/:contactId/destroy
。
到此,你應該知道製作刪除按鈕時需要的一切知識了。在繼續下一個部分之前,或許你可以試著製作看看?你需要
action
src/contacts.js
中的 deleteContact
👉 建立「destroy」路由模組
touch src/routes/destroy.jsx
👉 加入 destroy 動作
import { redirect } from "react-router-dom";
import { deleteContact } from "../contacts";
export async function action({ params }) {
await deleteContact(params.contactId);
return redirect("/");
}
👉 將 destroy 路由加入路由組態
/* existing code */
import { action as destroyAction } from "./routes/destroy";
const router = createBrowserRouter([
{
path: "/",
/* existing root route props */
children: [
/* existing routes */
{
path: "contacts/:contactId/destroy",
action: destroyAction,
},
],
},
]);
/* existing code */
好啦,導航到一筆記錄並點選「刪除」按鈕。它運作囉!
😅 我還是不知道為什麼這全部都能運作
當使用者點選送出按鈕時
<Form>
會阻止瀏覽器預設將新的 POST 要求傳送至伺服器的行為,而是使用客戶端路由模擬瀏覽器,建立 POST 要求<Form action="destroy">
與位於 "contacts/:contactId/destroy"
的新路由相符,並將要求傳送給該路由useLoaderData
會傳回新值,並導致元件更新!新增表單,新增動作,React Router 會處理剩下的部分。
只是為了試試看,將錯誤擲回清空動作
export async function action({ params }) {
throw new Error("oh dang!");
await deleteContact(params.contactId);
return redirect("/");
}
認出那個畫面了嗎?那正是我們之前的errorElement
。不過,使用者無法實際執行任何操作來復原這個畫面,除非按一下重新整理。
讓我們建立清空路線的「情境」錯誤訊息
[
/* other routes */
{
path: "contacts/:contactId/destroy",
action: destroyAction,
errorElement: <div>Oops! There was an error.</div>,
},
];
現在再試一次
我們的使用者現在有比按一下重新整理更棒的選項,他們可以繼續與頁面上沒有發生問題的部分互動 🙌
因為清空路線有自己的 errorElement
而且是根路線的子項目,所以錯誤會在這裡呈現,而不是在根項目。正如你可能已注意到的,這些錯誤會浮現到最近的 errorElement
。可以新增或減少任意數量,只要你在根目錄中有一個就行了。
當我們載入應用程式時,你會注意到清單的右邊有一個空白頁面。
當路線有子項目,而你處於父路線路徑中,<Outlet>
沒有任何內容可以呈現,因為沒有任何子項目相符。你可以將首頁路線視為預設的子項目路線,用來填補那個位置。
👉 建立首頁路線模組
touch src/routes/index.jsx
👉 填入首頁元件的元素
你可以隨意複製貼上,這裡沒有什麼特別的。
export default function Index() {
return (
<p id="zero-state">
This is a demo for React Router.
<br />
Check out{" "}
<a href="https://reactrouter.dev.org.tw">
the docs at reactrouter.com
</a>
.
</p>
);
}
👉 設定首頁路線
// existing code
import Index from "./routes/index";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
loader: rootLoader,
action: rootAction,
children: [
{ index: true, element: <Index /> },
/* existing routes */
],
},
]);
請注意 { index:true }
,而不是 { path: "" }
。這樣可以指示路由器當使用者處於父路線的精確路徑中時,比對並呈現此路線,所以 <Outlet>
中沒有其他子路線可以呈現。
哇啦!沒有空白空間了。將儀表板、統計資料、新聞來源等內容放在首頁路線中,是很常見的。它們也可以參與資料載入。
在編輯頁面上有一個取消按鈕,但現在它還沒有任何作用。我們希望它可以執行與瀏覽器的返回按鈕相同的功能。
我們需要在按鈕上設定點擊處理常式,以及 React Router 的 useNavigate
。
👉 使用 useNavigate
新增取消按鈕的點擊處理常式
import {
Form,
useLoaderData,
redirect,
useNavigate,
} from "react-router-dom";
export default function EditContact() {
const { contact } = useLoaderData();
const navigate = useNavigate();
return (
<Form method="post" id="contact-form">
{/* existing code */}
<p>
<button type="submit">Save</button>
<button
type="button"
onClick={() => {
navigate(-1);
}}
>
Cancel
</button>
</p>
</Form>
);
}
現在當使用者按一下「取消」時,他們會被傳送到瀏覽器歷史記錄中的前一個項目。
🧐 為什麼按鈕上沒有
event.preventDefault
?
一個 <button type="button">
,表面上看起來很多餘,但是這是 HTML 用來防止按鈕傳送其表單的方式。
還有兩個功能。我們已經快完成了!
到目前為止,所有我們的互動式 UI 都是改變 URL 的連結或將資料貼至動作的表單。搜尋欄位很有趣,因為它是兩者的組合:它是一個表單,但它只會改變 URL,而不會改變資料。
現在它只是一個正常的 HTML <form>
,而不是 React Router <Form>
。讓我們看看瀏覽器預設如何處理它
👉 在搜尋欄位中輸入名稱,然後按下 Enter 鍵
請注意,瀏覽器的 URL 現在包含您在 URL 中的查詢,如 URLSearchParams
http://127.0.0.1:5173/?q=ryan
如果我們檢閱搜尋表單,它看起來像這樣
<form id="search-form" role="search">
<input
id="q"
aria-label="Search contacts"
placeholder="Search"
type="search"
name="q"
/>
<div id="search-spinner" aria-hidden hidden={true} />
<div className="sr-only" aria-live="polite"></div>
</form>
正如我們之前所見,瀏覽器可以透過輸入元素的 name
屬性來序列化表單。此輸入項目的名稱為 q
,這就是為什麼 URL 有 ?q=
的原因。如果我們將其命名為 search
,則 URL 將會是 ?search=
。
請注意,此表單與我們使用過的其他表單不同,它沒有 <form method="post">
。預設的 method
是 "get"
。這表示當瀏覽器為下一個文件建立要求時,它不會將表單資料放入請求 POST 主體中,而是放入 GET 請求的 URLSearchParams
中。
讓我們使用用戶端端路由來傳送此表單,並在現有的載入器中篩選清單。
👉 將 <form>
變更為 <Form>
<Form id="search-form" role="search">
<input
id="q"
aria-label="Search contacts"
placeholder="Search"
type="search"
name="q"
/>
<div id="search-spinner" aria-hidden hidden={true} />
<div className="sr-only" aria-live="polite"></div>
</Form>
👉 如果有 URLSearchParams,則篩選清單
export async function loader({ request }) {
const url = new URL(request.url);
const q = url.searchParams.get("q");
const contacts = await getContacts(q);
return { contacts };
}
因為這是 GET 而不是 POST,所以 React Router 不會 呼叫 action
。提交 GET 表單與按一下連結是一樣的:只有 URL 會改變。這就是為什麼我們為篩選新增的程式碼在 loader
中,而不是此路由的 action
中。
這也表示它是一般的頁面導覽。您可以按一下返回按鈕以返回原來的頁面。
這裡有幾個 UX 問題,我們可以快速處理。
換句話說,URL 和我們的表單狀態不同步。
👉 從您的載入器傳回 q
,並將其設定為搜尋欄位的預設值
// existing code
export async function loader({ request }) {
const url = new URL(request.url);
const q = url.searchParams.get("q");
const contacts = await getContacts(q);
return { contacts, q };
}
export default function Root() {
const { contacts, q } = useLoaderData();
const navigation = useNavigation();
return (
<>
<div id="sidebar">
<h1>React Router Contacts</h1>
<div>
<Form id="search-form" role="search">
<input
id="q"
aria-label="Search contacts"
placeholder="Search"
type="search"
name="q"
defaultValue={q}
/>
{/* existing code */}
</Form>
{/* existing code */}
</div>
{/* existing code */}
</div>
{/* existing code */}
</>
);
}
這樣就能解決問題 (2)。如果你現在重新整理頁面,輸入欄位就會顯示查詢。
現在來解決問題 (1),點擊返回按鈕並更新輸入。我們可以使用 React 提供的 useEffect
來直接在 DOM 中控制表單狀態。
👉 將輸入值同步到 URL 搜尋參數
import { useEffect } from "react";
// existing code
export default function Root() {
const { contacts, q } = useLoaderData();
const navigation = useNavigation();
useEffect(() => {
document.getElementById("q").value = q;
}, [q]);
// existing code
}
🤔 不應該使用受控元件和 React 狀態來處理嗎?
當然可以將其作為受控元件,但為了讓行為保持一致,你將會需要增加更多複雜度。你無法控制 URL,使用者可以用瀏覽器的前進/後退按鈕。受控元件會帶來更多同步時間點。
請注意,現在控制輸入需要三個同步點,而不再只有一個。行為是相同的,但程式碼更複雜。
import { useEffect, useState } from "react";
// existing code
export async function loader({ request }) {
const url = new URL(request.url);
const q = url.searchParams.get("q") || "";
const contacts = await getContacts(q);
return { contacts, q };
}
// existing code
export default function Root() {
const { contacts, q } = useLoaderData();
const [query, setQuery] = useState(q);
const navigation = useNavigation();
useEffect(() => {
setQuery(q);
}, [q]);
return (
<>
<div id="sidebar">
<h1>React Router Contacts</h1>
<div>
<Form id="search-form" role="search">
<input
id="q"
aria-label="Search contacts"
placeholder="Search"
type="search"
name="q"
value={query}
onChange={(e) => {
setQuery(e.target.value);
}}
/>
{/* existing code */}
</Form>
{/* existing code */}
</div>
{/* existing code */}
</div>
</>
);
}
onChange
提交表單我們在這裡需要做出產品決策。對於這個使用者介面,我們可能希望在每次按鍵時就進行篩選,而不是在明確提交表單時才動作。
我們已經見識過 useNavigate
了,我們會使用它的表親:useSubmit
來處理此事。
// existing code
import {
// existing code
useSubmit,
} from "react-router-dom";
export default function Root() {
const { contacts, q } = useLoaderData();
const navigation = useNavigation();
const submit = useSubmit();
return (
<>
<div id="sidebar">
<h1>React Router Contacts</h1>
<div>
<Form id="search-form" role="search">
<input
id="q"
aria-label="Search contacts"
placeholder="Search"
type="search"
name="q"
defaultValue={q}
onChange={(event) => {
submit(event.currentTarget.form);
}}
/>
{/* existing code */}
</Form>
{/* existing code */}
</div>
{/* existing code */}
</div>
{/* existing code */}
</>
);
}
現在,當你輸入時,表單會自動提交!
請注意,submit
的引數。我們傳入的是 event.currentTarget.form
。currentTarget
是事件所附上的 DOM 節點,而 currentTarget.form
是輸入的父表單節點。submit
函式會將你傳遞給它的任何表單序列化並提交。
在實際應用中,這個搜尋可能需要在一個所有記錄都一次發送並於客戶端篩選過大的資料庫中執行。這就是為什麼這個範例有一些偽造的網路延遲。
沒有任何載入指示器,搜尋會感覺有點遲鈍。即使我們可以讓我們的資料庫更快,我們仍然無法控制使用者的網路延遲。為了獲得更好的使用者體驗,我們可以為搜尋增加一些立即的使用者介面回饋。我們會再次使用 useNavigation
。
👉 加入搜尋指示器
// existing code
export default function Root() {
const { contacts, q } = useLoaderData();
const navigation = useNavigation();
const submit = useSubmit();
const searching =
navigation.location &&
new URLSearchParams(navigation.location.search).has(
"q"
);
useEffect(() => {
document.getElementById("q").value = q;
}, [q]);
return (
<>
<div id="sidebar">
<h1>React Router Contacts</h1>
<div>
<Form id="search-form" role="search">
<input
id="q"
className={searching ? "loading" : ""}
// existing code
/>
<div
id="search-spinner"
aria-hidden
hidden={!searching}
/>
{/* existing code */}
</Form>
{/* existing code */}
</div>
{/* existing code */}
</div>
{/* existing code */}
</>
);
}
當應用程式導航至新的 URL 並載入資料時,navigation.location
會顯示。當不再有待處理的導航時,它就會消失。
由於現在表單會在每次按鍵時提交,因此,如果我們輸入字元「seba」,然後使用退格鍵刪除它們,我們最終會在堆疊中得到 7 個新條目 😂。我們絕對不希望這樣
我們可以透過取代目前紀錄堆疊中的當前項目為下一頁,而不是將它推進後避免此狀況。
👉 在submit
中使用replace
// existing code
export default function Root() {
// existing code
return (
<>
<div id="sidebar">
<h1>React Router Contacts</h1>
<div>
<Form id="search-form" role="search">
<input
id="q"
// existing code
onChange={(event) => {
const isFirstSearch = q == null;
submit(event.currentTarget.form, {
replace: !isFirstSearch,
});
}}
/>
{/* existing code */}
</Form>
{/* existing code */}
</div>
{/* existing code */}
</div>
{/* existing code */}
</>
);
}
我們只想要取代搜尋結果,而不是我們開始搜尋之前的頁面,因此我們先做一個快速檢查,查看這是不是第一次搜尋或其他,之後再決定是否要取代。
每次按下按鍵都不會再產生新項目,因此使用者可以點選離開搜尋結果,而不必點選 7 次 😅。
到目前為止,我們的所有異動(我們變更資料的時候)都使用會進行導覽的表單,並在紀錄堆疊中產生新的項目。儘管這些使用者流程很常見,想要變更資料(同時不導覽)也同樣常見。
對於這些案例,我們有 useFetcher
掛勾。它允許我們與載入器和動作溝通,而不進行導覽。
連絡人頁面上的 ★ 按鈕很適合使用這個功能。我們不是要建立或刪除新紀錄,我們不想變更頁面,我們只要變更所正在檢視的頁面上的資料即可。
👉 變更<Favorite>
表單為載入器表單
import {
useLoaderData,
Form,
useFetcher,
} from "react-router-dom";
// existing code
function Favorite({ contact }) {
const fetcher = useFetcher();
const favorite = contact.favorite;
return (
<fetcher.Form method="post">
<button
name="favorite"
value={favorite ? "false" : "true"}
aria-label={
favorite
? "Remove from favorites"
: "Add to favorites"
}
>
{favorite ? "★" : "☆"}
</button>
</fetcher.Form>
);
}
趁我們還在這裡時,你可能想要查看該表單。和以往一樣,我們的表單中有附帶name
props 的欄位。這個表單將傳送formData
,其中包含值為"true" | "false"
的favorite
金鑰。由於它有method="post"
,它會呼叫動作。因為沒有<fetcher.Form action="...">
props,它將會貼到表單進行呈現的路由。
👉 建立動作
// existing code
import { getContact, updateContact } from "../contacts";
export async function action({ request, params }) {
const formData = await request.formData();
return updateContact(params.contactId, {
favorite: formData.get("favorite") === "true",
});
}
export default function Contact() {
// existing code
}
非常簡單。從要求中拉取表單資料,並傳送至資料模式。
👉 設定路由的新動作
// existing code
import Contact, {
loader as contactLoader,
action as contactAction,
} from "./routes/contact";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
loader: rootLoader,
action: rootAction,
children: [
{ index: true, element: <Index /> },
{
path: "contacts/:contactId",
element: <Contact />,
loader: contactLoader,
action: contactAction,
},
/* existing code */
],
},
]);
好啦,我們準備好按一下使用者名稱旁邊的星星了!
看一下,兩個星星都會自動更新。我們新的<fetcher.Form method="post">
運作的方式幾乎與我們一直使用的<Form>
完全一樣:它會呼叫動作,然後所有資料都會自動重新驗證,即使錯誤也會用相同的方式捕捉到。
不過有一個關鍵差異,它不是導覽-URL 沒有變更,紀錄堆疊不受影響。
你可能注意到,當我們按一下上一個分節中的最愛按鈕時,應用程式的反應有點遲鈍。由於現實世界中你會遇到此狀況,因此我們再次加入了一些網路延遲!
為了給使用者一些回饋,我們可以用 fetcher.state
(很像之前提過的navigation.state
)將星星放入載入狀態,但這次我們可以做些更好的事。我們可以使用一種稱為「樂觀的 UI」的策略
調用程式知道提交到 action 的表單資料,因此可以在 fetcher.formData
中取得。我們會用它來立即更新星形的狀態,即使網路尚未完成。如果最終更新失敗,UI 將會回復為真實資料。
👉 從 fetcher.formData
讀取樂觀值
// existing code
function Favorite({ contact }) {
const fetcher = useFetcher();
const favorite = fetcher.formData
? fetcher.formData.get("favorite") === "true"
: contact.favorite;
return (
<fetcher.Form method="post">
<button
name="favorite"
value={favorite ? "false" : "true"}
aria-label={
favorite
? "Remove from favorites"
: "Add to favorites"
}
>
{favorite ? "★" : "☆"}
</button>
</fetcher.Form>
);
}
如果您現在按一下按鈕,您應該會看到星星立即變更為新狀態。我們不會總是呈現實際資料,而是檢查調用程式是否有任何正在提交的 formData
,如有,則會使用它。當動作完成後,fetcher.formData
將不再存在,而我們會回復為使用實際資料。因此,即使您的樂觀式 UI 程式碼寫入錯誤,它最終仍會回復為正確狀態 🥹
如果我們正在嘗試載入的連絡人不存在,會發生什麼事?
當我們嘗試呈現 null
連絡人時,我們的根 errorElement
會捕捉這個意外錯誤。很好,錯誤已被正確處理,但我們做得更好!
當載入器或動作發生預期的錯誤時,就像資料不存在,您可以在throw
。呼叫堆疊將中斷,React 路由器會捕捉它,然後呈現錯誤路徑。我們甚至不會嘗試呈現 null
連絡人。
👉 在載入器中擲回 404 回應
export async function loader({ params }) {
const contact = await getContact(params.contactId);
if (!contact) {
throw new Response("", {
status: 404,
statusText: "Not Found",
});
}
return { contact };
}
我們可以不發生 無法讀取 null 屬性
的呈現錯誤,而是完全避免組件,並改為呈現錯誤路徑,告訴使用者更具體的訊息。
這讓您的快樂之路,保持快樂。您的路徑元素不用考慮錯誤和載入狀態。
最後一件事。我們看到的最後一個錯誤頁面,會在根輸出內呈現會更好,而非整個頁面。事實上,所有子路徑中的每個錯誤在輸出中會更好,然後使用者除了按一下重新整理外,有更多選項。
我們希望它看起來像這樣
我們可以在每個子路徑中新增錯誤元素,但由於這些都是相同的錯誤頁面,因此不建議這麼做。
有更簡潔的方法。路徑可以使用沒有路徑的方式,這讓它們參與 UI 配置,而不用在 URL 中要求新的路徑區段。看看吧
👉 用沒有路徑的路徑包住子路徑
createBrowserRouter([
{
path: "/",
element: <Root />,
loader: rootLoader,
action: rootAction,
errorElement: <ErrorPage />,
children: [
{
errorElement: <ErrorPage />,
children: [
{ index: true, element: <Index /> },
{
path: "contacts/:contactId",
element: <Contact />,
loader: contactLoader,
action: contactAction,
},
/* the rest of the routes */
],
},
],
},
]);
當任何錯誤在子路徑中擲回時,我們新的沒有路徑的路徑會捕捉它並呈現,保留根路徑的 UI!
最後,很多人偏好使用 JSX 來設定他們的路線。你可以使用 createRoutesFromElements
來達成。在設定路線時,JSX 和物件之間沒有任何功能上的差別,這只不過是一種風格上的偏好。
import {
createRoutesFromElements,
createBrowserRouter,
Route,
} from "react-router-dom";
const router = createBrowserRouter(
createRoutesFromElements(
<Route
path="/"
element={<Root />}
loader={rootLoader}
action={rootAction}
errorElement={<ErrorPage />}
>
<Route errorElement={<ErrorPage />}>
<Route index element={<Index />} />
<Route
path="contacts/:contactId"
element={<Contact />}
loader={contactLoader}
action={contactAction}
/>
<Route
path="contacts/:contactId/edit"
element={<EditContact />}
loader={contactLoader}
action={editAction}
/>
<Route
path="contacts/:contactId/destroy"
action={destroyAction}
/>
</Route>
</Route>
)
);
這就是全部了!感謝您試用 React Router。我們希望這個教學能夠幫助您踏出穩健的第一步,打造出色的使用者體驗。React Router 還有更多功能,歡迎進一步探索所有 API 😀