我们将构建一个小型但功能丰富的地址簿应用,让您能够跟踪联系人。这里没有数据库或其他“生产就绪”的东西,这样我们就可以专注于 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
现在您应该可以打开 https://: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.com.cn/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.com.cn/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>
);
}
如果您点击侧边栏中的任何一项,您会看到默认的 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 段变为动态的。我们刚刚让以下 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
,我们……没有看到任何新东西?
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 会为您应用中的每个路由生成类型,以提供自动的类型安全。
我们之前提到,我们正在开发一个没有服务器端渲染的单页应用。如果您查看 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.com.cn">
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.com.cn">
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;
👉 将布局和数据获取移至侧边栏布局
我们想把 clientLoader
和 App
组件里的所有东西都移到侧边栏布局中。它应该看起来像这样:
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` 设置为 `true` 还是 `false` 取决于您和您用户的需求。两种策略都是完全有效的。在本教程的剩余部分,我们将使用服务器端渲染,但请知晓,所有渲染策略在 React Router 中都是一等公民。
👉 点击侧边栏中的一个链接
我们应该会再次看到我们旧的静态联系人页面,但有一个不同之处:URL 现在有了一个真实的记录 ID。
还记得 app/routes.ts
中路由定义的 :contactId
部分吗?这些动态段将匹配 URL 中该位置的动态(变化的)值。我们称 URL 中的这些值为“URL 参数”,或者简称“params”。
这些 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
你会注意到 `loaderData.contact` 的类型是 `ContactRecord | null`。基于我们的自动类型安全,TypeScript 已经知道 `params.contactId` 是一个字符串,但我们还没有做任何事情来确保它是一个有效的 ID。由于联系人可能不存在,`getContact` 可能会返回 `null`,这就是我们出现类型错误的原因。
我们可以在组件代码中考虑联系人未找到的可能性,但符合 web 习惯的做法是发送一个正确的 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 寒武纪大爆发之前曾是唯一的方式。不要被其简单性所迷惑!React Router 中的表单让您拥有客户端渲染应用的 UX 能力,同时又具备“老派”Web 模型的简单性。
虽然有些 Web 开发者可能不熟悉,但 HTML 的 form
实际上会在浏览器中引起导航,就像点击链接一样。唯一的区别在于请求:链接只能改变 URL,而 form
还可以改变请求方法(GET
vs. POST
)和请求体(POST
表单数据)。
在没有客户端路由的情况下,浏览器会自动序列化 form
的数据,并将其作为 POST
请求的请求体发送到服务器,或者对于 GET
请求,作为 URLSearchParams
发送。React Router 做的也是同样的事情,只不过它不是将请求发送到服务器,而是使用客户端路由并将其发送到路由的 action
函数。
我们可以通过点击应用中的“新建”按钮来测试这一点。
React Router 发送了一个 405 错误,因为服务器上没有代码来处理这个表单导航。
我们将通过在根路由中导出一个 action
函数来创建新联系人。当用户点击“新建”按钮时,表单将 POST
到根路由的 action。
👉 从 app/root.tsx
导出一个 action
函数
// existing imports
import { createEmptyContact } from "./data";
export async function action() {
const contact = await createEmptyContact();
return { contact };
}
// existing code
就是这样!去点击“新建”按钮,你应该会看到一条新记录出现在列表中 🥳
createEmptyContact
方法只是创建了一个没有姓名、数据等内容的空联系人。但它确实创建了一条记录,我保证!
🧐 等一下……侧边栏是怎么更新的?我们是在哪里调用
action
函数的?重新获取数据的代码在哪里?useState
、onSubmit
和useEffect
呢?!
这就是“老派 Web”编程模型的体现。<Form>
阻止了浏览器向服务器发送请求,而是通过 fetch
将其发送到您的路由的 action
函数。
在 Web 语义中,POST
通常意味着某些数据正在发生改变。按照惯例,React Router 以此为提示,在 action
完成后自动重新验证页面上的数据。
事实上,由于这一切都只是 HTML 和 HTTP,你可以禁用 JavaScript,整个应用仍然可以工作。此时,浏览器会序列化表单并发起一个文档请求,而不是由 React Router 序列化表单并发起一个 fetch
请求到你的服务器。然后,React Router 会在服务器端渲染页面并将其发送下来。无论哪种方式,最终的 UI 都是一样的。
不过,我们还是会保留 JavaScript,因为我们要创造比旋转的网站图标和静态文档更好的用户体验。
让我们添加一个方法来为新记录填写信息。
与创建数据一样,你使用 <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>
);
}
现在点击你的新记录,然后点击“Edit”按钮。我们应该能看到新的路由页面。
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
元素。注意它们各自都有一个 name 属性。
<input
aria-label="First name"
defaultValue={contact.first}
name="first"
placeholder="First"
type="text"
/>
在没有 JavaScript 的情况下,当表单被提交时,浏览器会创建一个 FormData
对象,并将其作为请求体发送到服务器。如前所述,React Router 阻止了这一行为,并通过 fetch
将请求发送给你的 action
函数来模拟浏览器的行为,其中也包含了 FormData
。
form
中的每个字段都可以通过 formData.get(name)
来访问。例如,对于上面的输入字段,你可以这样访问姓和名:
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 提供的:request
、request.formData
、Object.fromEntries
都由 Web 平台提供。
在 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}`);
}
action
和 loader
函数都可以返回一个 Response
(这很合理,因为它们都接收一个 Request
!)。redirect
辅助函数只是让返回一个告知应用改变位置的 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
现在当我们点击“New”时,我们应该会跳转到编辑页面。
现在我们有了很多记录,侧边栏中不清楚我们正在查看哪一个。我们可以使用 NavLink
来解决这个问题。
👉 在侧边栏中用 <NavLink>
替换 <Link>
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。这使我们能轻松地指示用户所在的位置,并在链接被点击但数据需要加载时提供即时反馈。
当用户在应用中导航时,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
。
到此,你应该已经掌握了让删除按钮工作所需的所有知识。在继续之前,不妨自己尝试一下?你将需要:
action
app/data.ts
的 deleteContact
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("/");
}
好了,导航到一个记录并点击“Delete”按钮。它成功了!
😅 我还是不明白这一切为什么能行
当用户点击提交按钮时:
<Form>
阻止了浏览器发送新的文档 POST
请求到服务器的默认行为,而是通过客户端路由和 fetch
创建一个 POST
请求来模拟浏览器的行为。<Form action="destroy">
匹配了位于 contacts/:contactId/destroy
的新路由,并将请求发送给它。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>
);
}
现在当用户点击“Cancel”时,他们将被带回浏览器历史记录中的上一个条目。
🧐 为什么按钮上没有
event.preventDefault()
?
一个 <button type="button">
,虽然看起来多余,但这是 HTML 中防止按钮提交其表单的方式。
还剩两个功能。我们已经进入最后冲刺阶段了!
URLSearchParams
和 GET
提交到目前为止,我们所有的交互式 UI 要么是改变 URL 的链接,要么是向 action
函数提交数据的 form
。搜索字段很有趣,因为它是两者的结合:它是一个 form
,但它只改变 URL,不改变数据。
让我们看看提交搜索表单时会发生什么。
👉 在搜索字段中输入一个名字并按回车键
注意浏览器的 URL 现在包含了你的查询,格式为 URLSearchParams
。
https://:5173/?q=ryan
由于它不是 <Form method="post">
,React Router 模拟浏览器行为,将 FormData
序列化到 URLSearchParams
中,而不是请求体中。
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 和我们输入框的状态不同步了。
让我们先解决问题(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 和结果保持同步。
onChange
事件中提交 Form
我们在这里需要做一个产品决策。有时你希望用户提交 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.currentTarget
。currentTarget
是事件所附加的 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
。它允许我们与 action
和 loader
通信,而不会引起导航。
联系人页面上的 ★ 按钮很适合这种情况。我们不是在创建或删除新记录,也不想切换页面。我们只是想改变我们正在查看的页面上的数据。
👉 将 <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>
);
}
这个表单将不再引起导航,而只是向 action
发起 fetch 请求。说到这个……在我们创建 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 不会改变,历史记录栈也不受影响。
你可能注意到了,当我们点击上一节中的收藏按钮时,应用感觉有点迟钝。我们再次添加了一些网络延迟,因为在现实世界中你总会遇到它。
为了给用户一些反馈,我们可以用 fetcher.state
(很像之前的 navigation.state
)将星星置于加载状态,但这次我们可以做得更好。我们可以使用一种名为“乐观 UI”的策略。
fetcher 知道正在提交给 action
的 FormData
,所以你可以在 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 😀