欢迎来到教程!我们将构建一个小型但功能丰富的应用程序,用于跟踪您的联系人。如果您跟着做,预计需要 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
您应该能够访问终端中打印的 URL。
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 中做了一些我们通常不会做的事情,以便本教程中的标记尽可能简洁。)
👉 将教程 CSS 复制/粘贴到这里 到 src/index.css
中
本教程将创建、读取、搜索、更新和删除数据。一个典型的 Web 应用程序可能会与 Web 服务器上的 API 进行通信,但我们将使用浏览器存储并模拟一些网络延迟,以使本教程保持重点。这些代码与 React Router 无关,因此只需复制/粘贴即可。
👉 将教程数据模块 复制/粘贴到这里 到 src/contacts.js
中
您在 src 文件夹中只需要 contacts.js
、main.jsx
和 index.css
。您可以删除其他任何文件(例如 App.js
和 assets
等)。
👉 删除 src/
中未使用的文件,以便只剩下这些文件:
src
├── contacts.js
├── index.css
└── main.jsx
如果您的应用程序正在运行,它可能会短暂崩溃,请继续操作 😋。有了这些,我们就可以开始了!
首先,创建一个 浏览器路由器 并配置我们的第一个路由。这将为我们的 Web 应用程序启用客户端路由。
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 中的默认错误屏幕,由于我们在应用程序中对根元素的 flex 盒样式,它变得更糟了 😂。
无论何时您的应用程序在渲染、加载数据或执行数据变异时抛出错误,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
。我们将在本教程的后面看到一些其他错误,并对其进行更详细的讨论。
现在,知道几乎所有错误都将由此页面处理,而不是无限的加载动画、无响应的页面或空白屏幕,就足够了 🙌
我们希望在链接到的 URL 上实际渲染一些内容,而不是 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,而无需从服务器请求另一个文档。相反,应用程序可以立即渲染新的 UI。让我们使用 <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
。首先,我们在根模块中创建并导出一个加载器函数,然后将其连接到路由。最后,我们将访问并渲染数据。
👉 从 root.jsx
导出加载器
import { Outlet, Link } from "react-router-dom";
import { getContacts } from "../contacts";
export async function loader() {
const contacts = await getContacts();
return { contacts };
}
👉 在路由上配置加载器
/* 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 现在将自动将该数据与您的 UI 保持同步。我们还没有任何数据,因此您可能正在获取这样的空白列表。
我们将在下一秒创建第一个联系人,但首先让我们谈谈 HTML。
React Router 模仿 HTML 表单导航作为数据变异原语,根据 JavaScript 寒武纪大爆发之前的 Web 开发。它为您提供了客户端渲染应用程序的用户体验功能,以及“老式”Web 模型的简单性。
虽然一些 Web 开发人员不熟悉,但 HTML 表单实际上会导致浏览器中的导航,就像单击链接一样。唯一的区别在于请求:链接只能更改 URL,而表单还可以更改请求方法(GET 与 POST)和请求主体(POST 表单数据)。
如果没有客户端路由,浏览器将自动序列化表单的数据,并将其作为 POST 的请求主体发送到服务器,以及作为 GET 的 URLSearchParams。React Router 做同样的事情,只是它不会将请求发送到服务器,而是使用客户端路由并将请求发送到路由 action
。
我们可以通过单击应用程序中的“新建”按钮来测试这一点。该应用程序应该崩溃,因为 Vite 服务器没有配置为处理 POST 请求(它发送 404,尽管它可能应该是 405 🤷)。
与其将该 POST 发送到 Vite 服务器以创建新的联系人,不如使用客户端路由。
我们将通过在根路由中导出一个 action
来创建新的联系人,将其连接到路由配置,并将我们的 <form>
更改为 React Router <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
方法只是创建一个没有名称或数据或任何内容的空联系人。但它确实创建了一个记录,承诺!
🧐 等一下... 侧边栏是如何更新的?我们在哪里调用了
action
?在哪里编写了重新获取数据的代码?useState
、onSubmit
和useEffect
在哪里?
这就是“老式 Web”编程模型出现的地方。正如我们之前讨论的那样,<Form>
阻止浏览器将请求发送到服务器,而是将其发送到您的路由 action
。在 Web 语义中,POST 通常意味着某些数据正在更改。按照惯例,React Router 使用此作为提示,在操作完成后自动重新验证页面上的数据。这意味着您所有的 useLoaderData
钩子都会更新,并且 UI 会自动与您的数据保持同步!太酷了。
👉 单击“无名称”记录
我们应该再次看到我们旧的静态联系人页面,但有一个区别:URL 现在具有记录的真实 ID。
查看路由配置,路由如下所示
[
{
path: "contacts/:contactId",
element: <Contact />,
},
];
注意 :contactId
URL 段。冒号 (:
) 具有特殊含义,将其转换为“动态段”。动态段将匹配 URL 中该位置的动态(更改)值,例如联系人 ID。我们将 URL 中的这些值称为“URL 参数”,或简称为“参数”。
这些 params
将传递给加载器,其键与动态段匹配。例如,我们的段名为 :contactId
,因此该值将作为 params.contactId
传递。
这些参数最常用于通过 ID 查找记录。让我们试一试。
👉 向联系人页面添加加载器,并使用 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
}
👉 在路由上配置加载器
/* 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 */
我们希望它在根路由的出口中呈现,因此我们将其设置为现有子路由的同级。
(您可能会注意到我们为该路由重复使用了 contactLoader
。这仅仅是因为我们在教程中偷懒。没有理由尝试在路由之间共享加载器,它们通常有自己的加载器。)
好的,单击“编辑”按钮会给我们这个新的 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
都由 Web 平台提供。
在我们完成操作后,请注意最后面的 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
帮助程序只是使返回一个 response 更容易,该 response 告诉应用程序更改位置。
如果没有客户端路由,如果服务器在 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 大会上的一系列杰出演讲者 😁
现在我们有了很多记录,但我们无法清楚地知道在侧边栏中查看的是哪一个。我们可以使用 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
将为真。当它即将处于活动状态(数据仍在加载)时,isPending
将为真。这使我们能够轻松地指示用户的位置,以及提供对已点击但我们仍在等待数据加载的链接的即时反馈。
当用户浏览应用程序时,React Router 会保留旧页面,因为正在加载下一页的数据。您可能已经注意到,当您在列表之间点击时,应用程序感觉有点反应迟钝。让我们为用户提供一些反馈,这样应用程序就不会感觉反应迟钝。
React Router 在幕后管理所有状态,并揭示您构建动态 Web 应用程序所需的片段。在这种情况下,我们将使用 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 会完成剩下的工作。
为了好玩,在 destroy 操作中抛出一个错误
export async function action({ params }) {
throw new Error("oh dang!");
await deleteContact(params.contactId);
return redirect("/");
}
认出那个屏幕了吗?这是我们之前使用的 errorElement
。但是,用户除了刷新页面之外,实际上无法做任何事情来从这个屏幕中恢复。
让我们为 destroy 路由创建一个上下文错误消息
[
/* other routes */
{
path: "contacts/:contactId/destroy",
action: destroyAction,
errorElement: <div>Oops! There was an error.</div>,
},
];
现在再试一次
我们的用户现在有了比猛烈刷新更多的选择,他们可以继续与页面中没有问题的部分进行交互 🙌
因为 destroy 路由有自己的 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.com.cn">
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
提交表单我们在这里需要做出一个产品决策。对于此 UI,我们可能更希望在每次按键时进行过滤,而不是在显式提交表单时进行过滤。
我们已经看到了 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
函数将序列化并提交您传递给它的任何表单。
在生产应用程序中,此搜索很可能是在数据库中查找记录,而数据库太大,无法一次性发送所有记录并在客户端进行过滤。这就是为什么此演示有一些模拟的网络延迟。
如果没有加载指示器,搜索感觉有点迟钝。即使我们可以让我们的数据库更快,我们仍然会受到用户网络延迟的影响,而且我们无法控制。为了获得更好的 UX,让我们为搜索添加一些即时的 UI 反馈。为此,我们将再次使用 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>
表单更改为 fetcher 表单
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
属性的字段。此表单将发送 formData
,其中包含一个 favorite
键,其值为 "true" | "false"
。由于它具有 method="post"
,它将调用操作。由于没有 <fetcher.Form action="...">
属性,它将发布到渲染表单的路由。
👉 创建操作
// 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”的策略。
fetcher 知道提交到操作的表单数据,因此您可以在 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>
);
}
如果您现在点击按钮,您应该会看到星星立即更改为新状态。我们不是总是渲染实际数据,而是检查 fetcher 是否有任何正在提交的 formData
,如果有,我们将使用它。当操作完成时,fetcher.formData
将不再存在,我们将恢复使用实际数据。因此,即使您在乐观 UI 代码中编写了错误,它最终也会恢复到正确状态 🥹
如果我们尝试加载的联系人不存在会发生什么?
我们的根 errorElement
在我们尝试渲染一个 null
联系人时捕获了这个意外错误。很好,错误被正确处理了,但我们可以做得更好!
每当您在加载器或操作中遇到预期的错误情况(例如数据不存在)时,您可以 throw
。调用堆栈将中断,React Router 将捕获它,并且将渲染错误路径。我们甚至不会尝试渲染一个 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 };
}
我们避免了完全渲染组件,而是渲染了错误路径,并告诉用户更具体的信息,而不是遇到 Cannot read properties of 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 😀