我們將建構一個小型但功能豐富的應用程式,讓您可以追蹤您的聯絡人。沒有資料庫或其他「生產就緒」的東西,因此我們可以專注於 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>
);
}
links
新增樣式表雖然有多種方式可以為你的 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 🙏)。
如果你點擊其中一個側邊欄項目,你會看到預設的 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
,我們得到的結果...沒有任何新東西?
由於 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 渲染。
你可能已經注意到,當我們點擊側邊欄中的連結時,瀏覽器正在針對下一個 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 來載入資料,loader
和 useLoaderData
。首先,我們將在根路由中建立並匯出一個 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
}
👉 點擊其中一個側邊欄連結
我們應該會再次看到我們舊的靜態聯絡人頁面,但有一個差異: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
函式?在哪裡可以重新擷取資料?useState
、onSubmit
和useEffect
在哪裡?!
這就是「老派網路」程式設計模型出現的地方。<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
,使用 fetch
以 POST
方式發送,並自動重新驗證所有資料。
👉 在編輯路由中新增 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 提供:request
、request.formData
、Object.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}`);
};
action
和 loader
函式都可以返回一個 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。這讓我們可以輕鬆地指出使用者所在的位置,並在點擊連結但需要載入資料時提供即時回饋。
當使用者瀏覽應用程式時,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
。
此時,您應該知道讓刪除按鈕運作所需的一切。也許在繼續之前嘗試一下?您將需要
action
app/data.ts
的 deleteContact
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("/");
};
好的,瀏覽到一個記錄並點擊「刪除」按鈕。它運作了!
😅 我仍然不明白為什麼這一切都可行
當使用者點擊提交按鈕時
<Form>
會阻止瀏覽器預設的將新文件 POST
請求發送到伺服器的行為,而是透過建立具有客戶端路由和 fetch
的 POST
請求來模擬瀏覽器<Form action="destroy">
會比對 contacts.$contactId_.destroy.tsx
中的新路由,並將請求發送到該路由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 防止按鈕提交其表單的方式。
還剩下兩個功能要處理。我們即將到達終點!
URLSearchParams
和 GET
提交到目前為止,我們所有的互動式 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。
這也表示它是一個正常的頁面瀏覽。您可以點擊返回按鈕回到您之前的位置。
這裡有一些我們可以快速處理的 UX 問題。
換句話說,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 和結果同步。
Form
的 onChange
我們這裡有一個產品決策需要做。有時候您希望使用者提交 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.currentTarget
。currentTarget
是事件附加到的 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
。它允許我們與 action
和 loader
通訊,而不會觸發導覽。
聯絡人頁面上的 ★ 按鈕很適合這種情況。我們沒有建立或刪除新的記錄,而且我們不想更改頁面。我們只是想變更我們正在查看的頁面上的資料。
👉 將 <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 不會變更,而且歷史堆疊也不會受到影響。
您可能注意到,當我們從最後一個章節點擊最愛按鈕時,應用程式感覺有點反應遲鈍。再一次,我們新增了一些網路延遲,因為您在真實世界中會遇到這種情況。
為了給使用者一些回饋,我們可以將星星放入使用 fetcher.state
的載入狀態(很像之前的 navigation.state
),但這次我們可以做得更好。我們可以採用一種稱為「樂觀 UI」的策略。
Fetcher 知道要提交給 action
的 FormData
,因此它在 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 😀