构建项目前提是你的电脑已经安装过node.js并且你对react已经有一定的基础知识如果不明白的话可以移步专栏:React全家桶学习,学习一下基础知识
- createBrowserRouter 结合action,loader,element,errorElement,path,childern实现交互配置,路由嵌套
- 同时使用部分Hooks:userouteerror、useloaderdata、usenavigation、usenavigate、usesubmit、usefetcher方法的不同用途
- 组件包含
<outlet/>
、<Link/>
、<NavLink/>
、<Form/>
等- 还涉及一些react中受控和非受控组件以及react中一些Hooks用法
效果源码地址

一、安装
shell
npm create vite@latest 项目名字 -- --template react
cd 项目名字
npm install
同时安装四个包当然你也可以分开安装
npm install react-router-dom localforage match-sorter sort-by
npm run dev
补充说明
0.1 localForage库
目前使用率最高的当之无愧为
Web Storage API
,也就是localStorage
和sessionStorage
,API简单,读取效率高。然后是indexedDB
。indexedDB
的优势为存储空间大,且支持各种数据结构,性能也没有问题。在5M内的存储领域,indexedDB
并非首选。另外WebSQL
已被H5标准放弃,而元老级的Cookie
也不再适合现代的客户端存储任务
- 用法上类似 Web Storage API
js
// 读取项
localforage.getItem(key, successCallback)
// 设置项,支持ArrayBuffer、Blob等数据
localforage.setItem(key, value, successCallback)
// 移除项
localforage.removeItem(key, successCallback)
// 移除所有项
localforage.clear(successCallback)
match-sorter------前端数组处理库
sort-by 排序
1.1创建文件
css
src
├── contacts.js
├── index.css
└── main.jsx
到这里项目会进行暂时崩溃,是因为我们删除了App.jsx文件,但是main.jsx入口文件中使用了App.jsx文件导致崩溃
二、新增根路由
2.1 添加 Router
在 main.jsx
中创建并渲染浏览器路由
jsx
import * as React from "react";
import * as ReactDOM from "react-dom/client";
// 虽然上文安装了react-router但是我们使用路由的包需要我们引入react-router-dom
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>
);
2.2 添加根路由
创建src/routes
文件夹和src/routes/root.jsx
文件
创建根布局组件 src/routes/root.jsx
jsx
//这里是react的函数组件布局用法
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" style={{fontSize:"14px"}}>新增</button>
</form>
</div>
<nav>
<ul>
<li>
<a href={`/contacts/1`}>你的名字</a>
</li>
<li>
<a href={`/contacts/2`}>你的朋友</a>
</li>
</ul>
</nav>
</div>
<div id="detail"></div>
</>
);
}
将<Root>
设置为根路由element
src/main.jsx
还记得我们上文的Hello world!吗对我们现在不需他了我们现在引入组件来渲染作为根路由
jsx
/* existing imports */
import Root from "./routes/root";
const router = createBrowserRouter([
{
path: "/",
element: <Root/>,
},
]);
//...

2.3 处理错误模板
学习过vue的项目都知道我们遇到一些其他路径会进行拦截或者是404界面处理但是在这里我们同样也需要进行处理
项目到这里我们随意点击会发现页面会抛出一些错误,因为我们到目前为止只创建了一个根路由并且访问其他的必然是会找不到路径路由下面进行修改
创建错误页面组件
记得新建文件错误页面展示src/error-page.jsx并且使用Hooks中的useRouteError
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
src/main.jsx
jsx
/* previous imports */
import ErrorPage from "./error-page";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,//新增的在这里
},
]);
//...
2.4 联系人路由用户界面
到现在雏形差不多了已经有了根路由错误处理也已经拿下了,下面继续新增界面做到页面点击显示数据,添加路由找回缺失的界面冲啊!!!
添加联系人组件用户界面
jsx
import { Form } from "react-router-dom";
export default function Contact() {
const contact = {
first: "Your",
last: "Name",
avatar: "https://placekitten.com/g/200/200",
twitter: "your_handle",
notes: "Some notes",
favorite: true,
};
return (
<div id="contact">
<div>
<img
key={contact.avatar}
src={contact.avatar || null}
/>
</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 }) {
// yes, this is a `let` for later
let 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>
);
}
当然了要添加路由才可以使用 导入联系人组件并创建新路由 src/main.jsx
jsx
/* existing imports */
import Contact from "./routes/contact";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
},
//记得新增
{
path: "contacts/:contactId",
element: <Contact />,
},
]);
/* existing code */
到这里点击跳转会发现我们页面直接没有左边的布局,不是我们需要的效果这里我们要用到嵌套路由!继续
三、 嵌套路由
移动联系人路由,使其成为根路由的子路由 src/main.jsx
jsx
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
children: [
{
path: "contacts/:contactId",
element: <Contact />,
},
],
},
]);
现在你将再次看到根布局,但右侧是一个空白页。我们需要告诉根路由在哪里呈现子路由。我们可以通过<Outlet>
来实现。
渲染<Outlet>
src/routes/root.jsx
jsx
import { Outlet } from "react-router-dom";
export default function Root() {
return (
<>
{/* all the other elements */}
<div id="detail">
<Outlet />
</div>
</>
);
}
四、路由懒加载
4.1 客户端路由
你可能注意到,也可能没有注意到,当我们点击侧边栏中的链接时,浏览器会对下一个 URL 进行完整的文档请求,而不是使用 React Router。 F12 查看 Network 请求
原生请求时,会导致强制刷新,相当于重新请求一次后端,但使用react-router就可以减少请求。
客户端路由允许我们的应用程序更新 URL,而无需从服务器请求另一个文档。相反,应用程序可以立即呈现新的用户界面。让我们通过<Link>
实现这一点。
将侧边栏<a href>
更改为<Link to>
src/routes/root.jsx
jsx
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>
</>
);
}
这里已经实现了无需请求服务,就能呈现新的 UI 的路由。
4.2 加载数据
URL、布局和数据往往耦合在一起组合成一个urlhttp://localhost:5173/contacts/1
,难以分辨解读,并且To 参数还是写死的,这其中还存在耦合性。
React Router 具有数据约定,可以帮助开发者轻松地将数据导入路由组件。
我们可以使用两个API
来加载这些数据,loader
和useLoaderData
。
在根模块文件中创建并导出一个加载器函数,然后将其配置到路由。最后,我们将调用useLoaderData
来访问并呈现数据。
从root.jsx
导出 loader
src/routes/root.jsx
jsx
import { Outlet, Link } from "react-router-dom";
import { getContacts } from "../contacts";
export async function loader() {
const contacts = await getContacts();
return { contacts };
}
在路由上配置loader
src/main.jsx
jsx
/* 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 />,
},
],
},
]);
访问并渲染数据
src/routes/root.jsx
jsx
import {
Outlet,
Link,
useLoaderData,
} from "react-router-dom";
import { getContacts } from "../contacts";
/* other code */
export default function Root() {
//使用hooks都要在顶层使用
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现在会自动保持数据与用户界面同步。我们还没有任何数据,所以会看到这样一个空白列表
五、动态路由
5.1 数据写入 + HTML 表单
- React Router模拟了HTML表单导航作为数据变更的原始操作,这是在JavaScript爆发前的Web开发中的做法
在HTML中,表单通常用于收集用户的输入数据,并将其提交到服务器进行处理或更新。React Router借鉴了这种模式,并使用类似的机制来处理数据的变更。
- 模仿 forms 表单导航的模型,实现一个简单的客户端渲染功能,forms 表单实际上会在浏览器中引起导航事件,就像我们点击某个链接一样。而链接和提交表单的唯一的区别在于请求:链接只能更改 URL,而表单还可以更改请求方式(GET 与 POST)和请求体(POST 表单数据)。
- 如果没有客户端路由(也就是
react-router
这类的client-side routing),浏览器会自动序列化表单数据,并以不同的方式发送给服务器。对于POST请求,数据会作为请求体(request body)发送给服务器;而对于GET请求,数据会作为URLSearchParams(URL查询参数)的形式附加在URL上发送给服务器。React Router的行为与此类似,但它不会将请求发送到服务器,而是使用客户端路由并将请求发送到路由操作action
进行处理。
可以点击应用程序中的 新增
按钮来测试一下。由于 Vite 服务器未配置为处理 POST 请求,因此应用程序应该会崩溃(它会发送 404,不过可能应该是 405 🤷)。
5.2 创建联系人
在根路由文件中编写并导出
action
,然后将其连接到路由配置文件中,并将<form>
更改为 React Router封装的<Form>
,从而创建新的联系人。
创建操作,并将<form>
更改为<Form>
src/routes/root.jsx
jsx
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>
</>
);
}
导入并设置路由上的action
src/main.jsx
jsx
/* 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 />,
},
],
},
]);
就是这样!点击 "新增"按钮,你就会看到一条新记录No Name出现在列表中
createContact
方法只创建了一个没有姓名、数据或其他任何东西的空联系人。不过它还是创建了一条记录
侧边栏是怎么更新的?我们在哪里调用了
action
?重新获取数据的代码在哪里?useState
、onSubmit
和useEffect
在哪里?
正如5.1所介绍的,<Form>
阻止了浏览器发送请求到服务器,而是将其发送到您指定的路由操作action
中 。这种方式类似于传统Web应用程序中的数据提交行为。根据 Web 语义,使用POST方法通常表示对数据进行更改。React Router利用这一点,自动触发数据重新验证的过程。这意味着所有使用 useLoaderData 钩子的组件都会更新,并且UI会自动与数据保持同步
5.3 Loaders 中的 URL 参数
点击 新增的表单 No Name 发现右边显示的数据并不一样
查看路由配置,路由看起来是这样的:path: "contacts/:contactId",
注意 :contactId
URL 段。冒号 ( :
) 具有特殊含义,将其转换为"动态段"。动态段将匹配 URL 该位置上的动态(变化)值,如联系人 ID。我们将 URL 中的这些值称为"URL 参数",简称 "params"。
这些params
将作为键值对传递给加载器loader
,其键与动态段匹配。例如,我们的分段名定义为为 :contactId
,因此值将作为 params.contactId
传递(可以再loader上使用)。
这些参数最常用于通过 ID 查找记录
在联系人页面添加一个loader
, 并使用useLoaderData
访问数据
src/routes/contact.jsx
jsx
import { Form, useLoaderData } from "react-router-dom";
import { getContact } from "../contacts";
export async function loader({ params }) {
const contact = await getContact(params.contactId);
return { contact };
}
export default function Contact() {
const { contact } = useLoaderData();
// existing code
}
在路由上配置loader
src/main.jsx
jsx
/* 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 */
六、实现新增编辑路由
6.1 更新数据
与创建数据一样,您也可以通过
Form
更新数据。让我们在contacts/:contactId/edit
创建一个新路由。同样,我们先从组件开始,然后将其连接到路由配置。
添加编辑组件页面UI
src/routes/edit.jsx
jsx
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>
);
}
添加新的编辑路由
src/main.jsx
jsx
/* existing code */
import EditContact from "./routes/edit";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
loader: rootLoader,
action: rootAction,
children: [
{
path: "contacts/:contactId",
element: <Contact />,
loader: contactLoader,
},
{
path: "contacts/:contactId/edit",
element: <EditContact />,
loader: contactLoader,
},
],
},
]);
/* existing code */
希望它在根路由的outlet
中呈现,因此我们将它设为现有子路由的同级路由
我们在这条路由中重复使用了 contactLoader
。没有试图在路由之间共享loader
,它们通常都有自己的loader
点击 "Edit "按钮,我们就会看到这个新的用户界面:
6.2 使用 FormData 更新联系人
刚刚创建的编辑路由已经渲染了一个表单。要更新记录,我们只需为路由连接一个动作。表单将发布到动作,数据将自动重新验证
为编辑模块中添加操作
src/routes/edit.jsx
jsx
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 */
将操作连接到路由上
src/main.jsx
jsx
/* 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 */
七、路由重定向-这里已经实现了新增修改编辑功能
7.1 分析
打开 src/routes/edit.jsx
,查看表单元素。注意它们都有一个name:
src/routes/edit.jsx
jsx
<input
placeholder="First"
aria-label="First name"
type="text"
name="first"
defaultValue={contact.first}
/>
如果没有 JavaScript,当提交表单时,浏览器会创建FormData
对象,并将其设置为请求的主体(body),然后将请求发送到服务器。如前所述,React Router <Link/>
阻止了这种默认行为,而是将请求发送到您的操作对应根路由action
中,同时包括FormData
。
表单中的每个字段都可以通过 formData.get(name)
访问。例如,在上面的输入字段中,您可以通过name属性这样访问姓和名:
jsx
export async function action({ request, params }) {
const formData = await request.formData();
const firstName = formData.get("first");
const lastName = formData.get("last");
// ...
}
由于我们有很多表单字段,因此我们使用Object.fromEntries
将它们全部收集到一个对象中,这正 updateContact
函数想要的。
jsx
const updates = Object.fromEntries(formData);
updates.first; // "Some"
updates.last; // "Name"
除了 action
之外,我们讨论的这些 API 都不是由 React Router 提供的:request
, request.formData
,Object.fromEntries
都是由web
平台提供的。
我们完成动作后,请注意结尾处的redirect
(重定向):
src/routes/edit.jsx
jsx
//**将参数转给调用接口同时更新路由参数** 更新之后通过 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}`);
}
loader
和action
都可以返回Response
(这是有道理的,因为它们都收到了Request
!)。redirect
辅助函数只是为了更方便地返回response
,告诉应用程序更改位置。
如果没有客户端路由,如果服务器在 POST 请求后重定向,新页面将获取最新数据并渲染。正如我们之前所学到的,React 路由器会模拟这种模式,并在执行操作后自动重新验证页面上的数据。这就是为什么当我们保存表单时,侧边栏会自动更新。如果没有客户端路由,就不会有额外的重新验证代码,所以客户端路由也不需要有额外的重新验证代码!
7.2 将新记录重定向到编辑页面
src/routes/root.jsx
jsx
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`);
}
点击 "新建 "时,就会进入编辑页面
八、使用**NavLink
**选中菜单侧边栏样式操作
不清楚我们在侧边栏中正在查看的是哪一条。我们可以使用
NavLink
来解决这个问题。
将跟路由的 src/routes/root.jsx 中的 <Link/>
换成 <NavLink />
jsx
import { Outlet, NavLink, useLoaderData, Form, redirect} from "react-router-dom";
export default function Root() {
return (
<>
<div id="sidebar">
{/* other code */}
<nav>
{contacts.length ? (
<ul>
{contacts.map((contact) => (
<li key={contact.id}>
<NavLink
to={`contacts/${contact.id}`}
className={({ isActive, isPending }) =>
isActive
? "active"
: isPending
? "pending"
: ""
}
>
{/* other code */}
</NavLink>
</li>
))}
</ul>
) : (
<p>{/* other code */}</p>
)}
</nav>
</div>
</>
);
}
注意,我们正在向 className
传递一个函数。当用户访处于NavLink指定的URL时,isActive将为true。当链接即将被激活时(数据仍在加载中), isPending
将为 true。这样,我们就可以轻松显示用户所在的位置,并对已点击但仍在等待数据加载时,提供即时反馈。
九、useNavigation
全局挂载UI
- 当切换的时候我们会发现会有一点卡顿:那是因为当用户浏览应用时,React Router 会在为下一页加载数据时保留旧页面
- 因此我们使用使用
useNavigation
。来解决,在数据加载完成之前保持旧页面,加载完成后跳转
src/routes/root.jsx
jsx
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界面。第二次就会很快,这和我们引用的存储有关
数据模型 ( src/contacts.js
) 具有客户端缓存,因此第二次导航到同一联系人时速度会很快。这种行为不是 React Router,无论您之前是否去过那里,它都会为变化的路由重新加载数据。不过,它确实避免了在导航过程中调用不变路由(如列表)的loader
。
十、实现删除记录
10.1删除逻辑分析
查看一下联系人路由中的代码,就会发现删除按钮看起来是这样的:
src/routes/contact.jsx

注意 action
指向 "destroy"
。与 <Link to>
一样
删除原理:<Form action>
也可以接收一个相关标记值。由于表单是在 contact/:contactId
中呈现的,因此点击 destroy
的相对操作将把表单提交到删除路由 contact/:contactId/destroy
。 添加销毁操作
src/routes/destory.jsx
jsx
import { redirect } from "react-router-dom";
import { deleteContact } from "../contacts";
export async function action({ params }) {
await deleteContact(params.contactId);
return redirect("/");
}
将销毁路由添加到路由配置中
src/main.jsx
jsx
/* 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"
,并向其发送请求- 在操作重定向后,React Router 会调用页面上所有数据的
loader
,以获取最新值(这就是 "重新验证")。useLoaderData
返回新值,并导致组件更新!
10.2 删除抛出错误上下文
在删除操作中抛出错误
src/routes/destory.jsx
jsx
export async function action({ params }) {
throw new Error("oh dang!");
await deleteContact(params.contactId);
return redirect("/");
}

认识这个屏幕吗?然而,用户除了点击刷新之外,根本无法从 认识这个屏幕吗?它就是我们之前的errorelement 。 这个屏幕中恢复过来。
为删除路由创建一条上下文错误信息:
src/main.jsx
jsx
[
/* other routes */
{
path: "contacts/:contactId/destroy",
action: destroyAction,
errorElement: <div>哎呀!出现了一个错误,干嘛删除!!!.</div>,
},
];
删除路由有自己的errorElement
,并且是根路由的子路由,因此错误会在这里而不是根路由上呈现。这些错误会以最近的 errorElement
冒泡。只要在根路径上有一个,添加多少都行
十一、 新增子路由默认模板,返回路由
11.1新增子路由默认模板
我们加载应用程序时,列表右侧有一个很大的空白页。
当路由有子路由时,如果你在父路由的路径上, <Outlet>
由于没有子路由匹配,所以没有任何内容可以呈现。你可以把索引路由看作是填补这一空白的默认子路由
配置索子路由 src/main.jsx
jsx
// existing code
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
loader: rootLoader,
action: rootAction,
children: [
//这里你也可以新建一个函数组件作为子路由组件展示,这里我不再新建子路由了
{ index: true, element: <h1>我是默认子路由</h1> },
/* existing routes */
],
},
]);
注意是{ index:true }
而不是{ path: "" }
。这将告诉路由,当用户位于父路由的确切路径时,路由器将匹配并呈现此路由,因此在 <Outlet>
中没有其他子路由需要呈现。
11.2 取消按钮实现返回路由
在编辑页面上,我们有一个取消按钮,但它还没有任何作用。我们希望它的功能与浏览器的返回按钮相同。
使用Hooks中useNavigate
添加取消按钮的点击处理程序
src/routes/edit.jsx
jsx
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 行为。
使用useNavigate
添加取消按钮的点击处理程序
十二、新增搜索功能
12.1 URL 搜索参数和 GET 提交
目前,我们所有的交互式用户界面要么是改变 URL 的链接,要么是将数据发送给操作的表单。搜索功能也是如此,它是两者的混合体:它是一个表单,但只改变 URL,不改变数据。
现在搜索框是一个普通的 HTML <form>
,而不是 React Router <Form>
。让我们看看浏览器在默认情况下是如何处理它的:
👉在搜索框中输入名称,然后按回车键
js
http://localhost:5173/?q=ceshi
查看一下搜索表单src/routes/root.jsx
jsx
<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
中。
12.2 使用客户端路由的 GET 提交
改为使用客户端路由来提交此表单并在现有的加载器中过滤列表。
**将<form>
更改为<Form>
**不再使用html中的from而是使用React Router的From
src/routes/root.jsx
jsx
<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
,则过滤列表
src/routes/root.jsx
jsx
//loader函数中加入
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
中。
这也意味着这是一个正常的页面导航。您可以点击返回按钮,回到原来的位置。
12.3 将 URL 同步到表单状态
存在问题:
- 如果您在搜索后点击返回,虽然列表已不再过滤,但表单字段仍保留您输入的值。
- 如果在搜索后刷新页面,表单字段中就不再有值,即使列表已被过滤
换句话说,URL 和我们的表单状态不同步。
解决问题2
从你的loader
中返回 q
并将其设置为搜索字段的默认值 src/routes/root.jsx
jsx
// 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 };//添加q
}
export default function Root() {
const { contacts, q } = useLoaderData();//使用q
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}//使用q
/>
{/* existing code */}
</Form>
{/* existing code */}
</div>
{/* existing code */}
</div>
{/* existing code */}
</>
);
}
这就解决了问题(2)。现在刷新页面,输入框就会显示查询结果。

现在解决问题(1),点击返回按钮并更新输入。我们可以从 React 中引入 useEffect
,直接在 DOM 中操作表单的状态。
解决问题1:方法一:
将输入值与 URL 搜索参数同步
src/routes/root.jsx
jsx
import { useEffect } from "react";//添加useEffect
// existing code
export default function Root() {
const { contacts, q } = useLoaderData();
const navigation = useNavigation();
useEffect(() => {
document.getElementById("q").value = q;
}, [q]);//添加useEffect
// existing code
}
解决问题1:方法二:
🤔 是否可以使用受控组件和 React State 来实现这一点吗?
当然可以将其作为一个受控组件来使用,但同样的行为最终会变得更加复杂。URL 并不是由你来控制的,而是由用户通过后退/前进按钮来控制的。使用受控组件会导致更多的同步点。(同步点指的是在使用受控组件时,需要确保组件的状态与URL的状态保持同步的位置或时刻。)
请注意,现在控制输入需要三个同步点,而不是一个。行为相同,但代码更复杂了。
src/routes/root.jsx
jsx
import { useEffect, useState } from "react";//添加useState
// 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);//添加useState
const navigation = useNavigation();
useEffect(() => {
setQuery(q);
}, [q]);//添加useEffect
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);
}}//添加onChange,使用setQuery
/>
{/* existing code */}
</Form>
{/* existing code */}
</div>
{/* existing code */}
</div>
</>
);
}
比较一下可以发现,使用useEffect的代码更简洁,而且不需要额外的状态管理。
12.4 提交表格onChange
在使用是可能会觉得卡顿,输如完成回车键之后要等待次才会出现结果因此我们需要修改内容 在每次按键时进行过滤,而不是在表单明确提交时进行筛选。
我们已经了解了 useNavigate
,现在我们将使用它的类似功能,即useSubmit
。
src/routes/root.jsx
jsx
// existing code
import {
// existing code
useSubmit,//添加useSubmit
} from "react-router-dom";
export default function Root() {
const { contacts, q } = useLoaderData();
const navigation = useNavigation();
const submit = useSubmit();//添加submit
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);
}}//添加onChange
/>
{/* existing code */}
</Form>
{/* existing code */}
</div>
{/* existing code */}
</div>
{/* existing code */}
</>
);
}
现在,当你输入时,表格就会自动提交!这样可以达到预期,如果是有服务端存在情况的话最好还是添加一个节流功能
注意submit
的参数。我们传递的是 event.currentTarget.form
。 currentTarget
是事件附加到的 DOM 节点, currentTarget.form
是输入的父表单节点。 submit
函数将序列化并提交您传递给它的任何表单。
12.5 添加搜索旋转器可以解决搜索卡顿问题
在生产应用程序中,这种搜索很可能要查找数据库中的记录,而数据库太大,无法一次性全部发送并在客户端进行过滤。这就是为什么这个演示有一些模拟的网络延迟。
在没有任何加载指示器的情况下,搜索感觉有点迟钝。即使我们能让数据库变得更快,但用户的网络延迟始终是我们无法控制的。为了获得更好的用户体验,让我们为搜索添加一些即时的用户界面反馈。为此,我们将再次使用useNavigation
。
添加搜索旋转器
src/routes/root.jsx
jsx
// 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"
);//添加searching
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" : ""}//添加className
// existing code
/>
<div
id="search-spinner"
aria-hidden
hidden={!searching}//添加hidden
/>
{/* existing code */}
</Form>
{/* existing code */}
</div>
{/* existing code */}
</div>
{/* existing code */}
</>
);
}
当应用程序正在导航到一个新的 URL 并为其加载数据时, navigation.location
就会显示出来。当没有待定导航时,它就会消失
十三、replace
管理历史堆栈
每次按键都会提交表单,所以如果我们输入历史输入的字符,然后用退格键删除它们,历史堆栈中就会出现多个新条目😂(长按回退按钮可以查看)。我们肯定不希望出现这种情况 我们可以通过将历史记录堆栈中的当前条目replace为下一页,而不是推入下一页,来避免这种情况。
在submit
中使用replace
src/routes/root.jsx
jsx
// 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) => {
//修改:添加replace
const isFirstSearch = q == null;
submit(event.currentTarget.form, {
replace: !isFirstSearch,
});
}}
/>
{/* existing code */}
</Form>
{/* existing code */}
</div>
{/* existing code */}
</div>
{/* existing code */}
</>
);
}
我们只想替换搜索结果,而不是开始搜索之前的页面,因此我们要快速检查这是否是第一次搜索,然后决定是否进行替换。
现在,每次按键都不再创建新条目,因此用户可以点击退出搜索结果,而无需点击 多 次😅
十四、 无导航更新
14.1使用useFetcher进行通讯
我们所有的突变(更改数据)都是使用表单导航,在历史堆栈中创建新条目。虽然这些用户流程很常见,但想要在不引起导航的情况下更改数据也同样常见。
针对这些情况,我们有useFetcher
钩子函数。它允许我们与loaders
和actions
进行通信,而不会导致导航。
联系人页面上的★按钮就可以实现这一点。我们不是要创建或删除新记录,也不是要更改页面,我们只是要更改我们正在查看的页面上的数据。
将<Favorite>
表单更改为fetcher
表单
src/routes/contact.jsx
jsx
import {
useLoaderData,
Form,
//修改:添加useFetcher
useFetcher,
} from "react-router-dom";
// existing code
function Favorite({ contact }) {
//修改:添加useFetcher
const fetcher = useFetcher();
let favorite = contact.favorite;
return (
//修改:添加fetcher.Form
<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
属性。该表单将发送带有 favorite
键的 formData
,该键的值要么是 "true",要么是 "false"。既然有 method="post"
,它就会调用action
。由于没有提供 <fetcher.Form action="...">
属性,它将提交到呈现表单的路由。
创建 action
src/routes/contact.jsx
jsx
// existing code
//修改:添加action
import { getContact, updateContact } from "../contacts";
//修改:添加action
export async function action({ request, params }) {
let formData = await request.formData();
return updateContact(params.contactId, {
favorite: formData.get("favorite") === "true",
});
}
export default function Contact() {
// existing code
}
非常简单。从请求中提取表单数据并将其发送到数据模型。
配置路由的新action
src/main.jsx
jsx
// 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>
几乎一模一样:它会调用action,然后自动重新验证所有数据--即使是错误也会以同样的方式被捕获。
但有一个关键区别,它不会导航--URL 不会改变,历史堆栈也不受影响。
14.2 优化的用户界面
当我们点击收藏按钮时,应用程序感觉有点反应迟钝。我们再次添加了一些网络延迟,因为在现实世界中会出现这种情况!
为了给用户提供一些反馈,我们可以通过fetcher.state
让星形进入加载状态(很像之前的 navigation.state
),但这次我们可以做得更好。我们可以使用一种名为 "优化用户界面 "的方法
fetcher
知道提交给操作的表单数据,因此可以在 fetcher.formData
上获取这些数据。我们将利用这些数据立即更新星形的状态,即使网络尚未结束。如果更新最终失败,用户界面将恢复为真实数据。
从fetcher.formData
中读取优化值
src/routes/contact.jsx
jsx
// existing code
function Favorite({ contact }) {
const fetcher = useFetcher();
let favorite = contact.favorite;
// 修改:添加优化值
if (fetcher.formData) {
favorite = fetcher.formData.get("favorite") === "true";
}
return (
<fetcher.Form method="post">
<button
name="favorite"
value={favorite ? "false" : "true"}
aria-label={
favorite
? "Remove from favorites"
: "Add to favorites"
}
>
{favorite ? "★" : "☆"}
</button>
</fetcher.Form>
);
}
如果你现在点击按钮,就会看到星形立即 变成新的状态。我们不会一直呈现实际数据,而是会检查取件器是否有任何 formData
正在提交,如果有,我们就会使用它。当操作完成后, fetcher.formData
将不再存在,我们将重新使用实际数据。因此,即使您在优化用户界面代码中编写了错误,它最终也会回到正确的状态
十五、优化存在的问题
15.1 未找到数据
如果我们要加载的联系人不存在,会发生什么情况?
当我们尝试呈现 null
联系信息时,我们的根errorElement
正在捕捉这个意外错误。优化一下这个
只要在loader
或action
中出现预期的错误情形(如数据不存在),就可以 throw
。调用堆栈会中断,React Router 会捕获它,然后渲染错误路径。我们甚至不会尝试呈现 null
联系人。
👉在加载器中抛出 404 响应
src/routes/contact.jsx
jsx
export async function loader({ params }) {
//修改:添加404
const contact = await getContact(params.contactId);
if (!contact) {
throw new Response("", {
status: 404,
statusText: "Not Found",
});
}
return { contact };
}
与使用 Cannot read properties of null
时出现的渲染错误相反,我们完全避开了组件,而是渲染错误路径,告诉用户一些更具体的信息
15.2 无路径路由
错误页面如果能在根路由中呈现会更好,而不是整个页面。事实上,我们所有子路由中的每个错误都最好在出口中呈现,这样用户就有更多的选择,而不是点击刷新。
我们可以在每一个子路由中添加错误元素,但由于都是同一个错误页面,因此不建议这样做。
还有一种更简洁的方法。路由可以在没有路径的情况下使用,这样它们就可以参与用户界面布局,而不需要在 URL 中添加新的路径段
将子路由包裹在无路径路由中
src/main.jsx
jsx
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 */
],
},
],
},
]);
当子路由出现任何错误时,我们的新无路径路由会捕捉并呈现错误,同时保留根路由的用户界面!
十六、 JSX 路由
很多人喜欢用 JSX 配置路由。可以使用 createRoutesFromElements
。在配置路由时,JSX 和对象在功能上没有区别,这只是一种风格上的偏好。
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>
)
);