我们将构建一个小型但功能丰富的地址簿应用,用于管理您的联系人。其中不包含数据库或其他“生产就绪”的内容,因此我们可以专注于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.remix.org.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.remix.org.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匹配 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 */}
</>
);
}
您可以打开浏览器开发者工具的网络(Network)选项卡,查看它不再请求文档了。
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)。目前我们的联系人网站是一个单页应用(SPA),所以没有服务器端渲染。这使得部署到任何静态托管提供商都非常容易,但稍后我们会详细讨论如何启用SSR,这样您就可以了解React Router提供的各种渲染策略。
您可能注意到了,我们没有为 loaderData
属性指定类型。让我们来修复它。
👉 为 App
组件添加 ComponentProps
类型
// 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
我们之前提到过,我们正在构建一个没有服务器端渲染的单页应用(SPA)。如果您查看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.remix.org.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.remix.org.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
路由来自动为该布局内的所有匹配路由渲染侧边栏。这基本上就是我们根路由的功能,但现在我们可以将其作用范围限定到特定的路由。
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是构建单页应用(SPA)的优秀框架。许多应用仅通过客户端渲染就能很好地服务,或者也许在构建时静态预渲染几个页面。
如果您想在您的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 Form导航模拟为数据变动原语,在JavaScript大爆发之前,这是唯一的方法。不要被其简单性所迷惑!React Router中的Form为您提供了客户端渲染应用的UX能力,同时保留了“老派”Web模型的简单性。
尽管对一些Web开发者来说可能不熟悉,但HTML form
实际上会在浏览器中触发导航,就像点击链接一样。唯一的区别在于请求:链接只能改变URL,而 form
还可以改变请求方法(GET
vs. POST
)和请求体(POST
表单数据)。
如果没有客户端路由,浏览器会自动序列化 form
的数据,并将其作为请求体发送给服务器(用于 POST
),或作为URLSearchParams
发送(用于 GET
)。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>
);
}
现在点击您的新记录,然后点击“编辑”按钮。我们应该会看到新的路由。
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
。
表单中的每个字段都可以通过 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
现在当我们点击“新建”时,我们应该会跳转到编辑页面:
现在我们有很多记录,在侧边栏中不清楚我们正在查看哪一个。我们可以使用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。这使我们能够轻松指示用户当前的位置,并在点击链接但需要加载数据时提供即时反馈。
当用户在应用中导航时,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
👉 配置“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("/");
}
好了,导航到一个记录并点击“删除”按钮。它工作了!
😅 我仍然不明白为什么这一切都工作了
当用户点击提交按钮时:
<Form>
阻止了浏览器向服务器发送新的文档 POST
请求的默认行为,而是通过客户端路由和fetch
模拟浏览器创建了一个 POST
请求<Form action="destroy">
匹配 contacts/:contactId/destroy
的新路由并将请求发送到该路由action
重定向后,React Router会调用页面上所有获取数据的 loader
函数来获取最新值(这就是“重新验证”)。routes/contact.tsx
中的 loaderData
现在有了新值,并导致组件更新!添加一个 Form
,添加一个 action
,剩下的事情就交给React Router了。
在编辑页面上,我们有一个取消按钮,它目前没有任何功能。我们希望它能实现与浏览器返回按钮相同的功能。
我们需要在按钮上添加一个点击处理函数,并且使用useNavigate
Hook。
👉 使用 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 标准中阻止按钮提交其表单的方式。
还有两个功能就完成了。我们已经快到终点了!
URLSearchParams
和 GET
提交到目前为止,我们所有的交互式 UI 要么是改变 URL 的链接,要么是向 action
函数提交数据的 form
。搜索字段很有趣,因为它两者都有:它是一个 form
,但它只改变 URL,不改变数据。
我们来看看提交搜索表单时会发生什么
👉 在搜索字段中输入一个名字并按下回车键
注意浏览器 URL 现在包含您的查询,格式为 URLSearchParams
http://localhost: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 */}
</>
);
}
现在当您输入时,表单会自动提交!
注意传递给 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。然后我们通过 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
发送请求。说到这个... 在我们创建 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 😀