案例最终效果图
需要实现的功能如目录所示
1.安装|Setup
npm create vite@latest my-project --template react
cd my-project
npm install react-router-dom localforage match-sorter sort-by
npm run dev
打开终端上的 URL:
VITE v4.3.9 ready in 678 ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose
➜ press h to show help
? 复制/粘贴这里的CSS到 src/index.css
? 复制/粘贴这里的模拟接口到 src/contacts.js
? 删除 src/中不使用的文件,此时的目录如下:
src
├── contacts.js
├── index.css
└── main.jsx
2.初始化第一个路由
修改入口文件 main.jsx 如下:
import React from "react";
import 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>
);
初始化后调试如下图
3.初始化根路由模板
? 创建文件夹 src/routes
? 创建根布局组件 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>
</>
);
}
将新增的 组件引入 main.jsx 如下:
import Root from "./routes/root";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
},
]);
最终调式结果如下
4.初始化错误提示模板
? 创建错误页面组件 src/error-page.jsx
并使用useRouteError 获取错误响应的提示
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>
);
}
在入口文件 main.jsx 中引入错误提示组件
import Root from "./routes/root";
import ErrorPage from "./error-page";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
},
]);
错误页调试结果如下
5.初始化嵌套路由
为了实现点击左侧边栏菜单组件时,显示对应页面内容。需要使用 createBrowserRouter 的子路由属性children
? 创建联系人路由模块
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>
);
}
修改如果入口文件 main.jsx 如下
/* existing imports */
import Contact from "./routes/contact";
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
children: [
{
path: "contacts/:contactId",
element: <Contact />,
},
],
},
]);
此时还没有完成配置,还需要在根路由 src/routes/root.jsx 中引入 属性实现嵌套的效果
import { Outlet } from "react-router-dom";
export default function Root() {
return (
<>
{/* all the other elements */}
<div id="detail">
<Outlet />
</div>
</>
);
}
嵌套路由最终调试结果
6.实现路由懒加载
这个时候 F12 查看 Network 请求,你会发现我们点击侧边栏中的链接时,浏览器会对下一个 URL 进行完整的文档请求,而不是使用 React Router。
客户端路由可以让应用程序更新 URL,不需要从服务器请求另一个文档。
相反,用**<Link/>**
属性可以实现它立即呈现新的 UI
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 的路由。
但是目前的 To 参数还是写死的,这其中还存在耦合性,为了解决这个问题官方采购了异步懒加载请求路由的方法。结合loader 属性和 useLoaderData 方法完成
? 在 src/routes/root.jsx 中导出一个加载器
import { Outlet, Link } from "react-router-dom";
import { getContacts } from "../contacts";
export async function loader() {
const contacts = await getContacts();
return { contacts };
}
? 并在入口文件 main.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 />,
},
],
},
]);
? 最后将写死的路由导航 to 参数修改为动态的形式
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>
</>
);
}
修改为动态写入菜单栏数据之后,现在会自动将这些数据与你的 UI 保持同步。但我们还没有任何数据,所以你的菜单栏可能会得到这样一个空白列表:
7.实现动态路由
模仿 forms 表单导航的模型,可以实现一个简单的客户端渲染功能。 forms 表单实际上会在浏览器中引起导航,就像单击”<a
/>”链接一样。唯一的区别在于请求:链接只能更改 URL,而 forms 表单可以更改请求方法(GET vs POST)和请求主体(POST 表单数据)。
如果没有客户端路由,浏览器将自动序列化表单的数据,并将其作为 POST 的请求主体发送到服务器。React Router 也做同样的事情,将表单的数据作为 GET 的 URLSearchParams 发送到服务器。只不过它不是把请求发送给服务器,而是使用客户端路由,把它发送给一个路由属性’action‘。
具体实现:
在 src/routes/root.jsx 中导出一个 action ,将它连接到路由配置中,并将更改为 React Router < Form>,从而创建新的联系人。
? 创建 action 并将更改为
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>
</>
);
}
? 在入口文件 maib.jsx 上导入并设置 action 属性
/* 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 />,
},
],
},
]);
这时点击新增按钮 New 你会发现菜单栏已经有 No Name 数据被添加进来了
到这里已经完成菜单栏的动态加入表单路由,但目前点击 新增的表单 No Name 发现右边显示的数据并不一样。
为了同步右边的数据,我们还需要完成以下步骤:
? 在 src/routes/contact.jsx 页面添加加载器loader,并改用 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
}
? 在入口文件 main.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 */
再次点击菜单栏中的 No Name 表单,显示结果如下
到这里手动新增的动态路由功能已经实现。但了实现不同菜单栏对应不同的内容,我们还需要一个手动修改右侧内容的路由。
8.实现新增编辑路由
同理新增一个路由组件,并将其引入根路由的指定位置
? 在 src/routes/edit.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>
);
}
? 在入口文件 main.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 */
点击右侧内容的Edit编辑按钮,最终调试结果如下
到这里新增的路由已经添加完成。
但为了能将填入的信息更新记录,我们需要将更新的路由参数通过 action 属性连接到根路由上。
当表单发送操作,数据将自动重新验证。
? 向编辑组件添加一个更新操作
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 */
? 在入口文件 main.jsx 中引入修改的 action 属性中
/* 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 */
填入信息之后保存调试结果如下
到这里编辑更新信息的功能已经实现,但是我们不知道这里发生了什么…
9.实现路由重定向
下面让我们深入的分析一下它究竟是怎么工作的。
读取输入参数
打开 src/routes/edit.jsx,看看表单元素。注意它们每个都有一个 name:
<input
placeholder="First"
aria-label="First name"
type="text"
name="first"
defaultValue={contact.first}
/>
如果没有 JavaScript,当提交表单时,浏览器会创建 formdata,并在将其发送到服务器时,设置为请求的主体。
如前所述,React Router <Link/>
会阻止这种情况,并将请求发送给对应根路由的 action 。而** FormData**表单也是一样的道理,它的每个字段都可以通过 formData.get(name) 来访问。例如,给定上面的输入字段,你可以通过访问 name 属性读取到对应的 first 名称:
export async function action({ request, params }) {
const formData = await request.formData();
const firstName = formData.get("first");
const lastName = formData.get("last");
// ...
}
转换成对象参数
之后将读取到的参数,通过Object.fromEntries 方法将数组对象转化为对象
将参数转给调用接口同时更新路由参数
更新之后通过 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}`);
}
在新增的时候,如果菜单列表中没有客户路由,额外的重新验证代码就不存在,所以它也不需要在客户端路由中存在。所以在新增的客户路由时,直接将新增的表单重定向到信息编辑页面即可
? 在 sec/routes/root.jsx 中添加重定向到新记录的编辑页的 redirect 属性
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`);
}
现在点击 New 按钮新增重定向后的调试效果如下
到这里新增路由重定向的功能已经实现。但有个问题当选择菜单路由时,没有很明显的标注样式,所以不知道当前的选择是谁。
10.实现选中的菜单挂载
为了解决这个问题,可以将跟路由的 src/routes/root.jsx 中的 <Link/>
换成 <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>
</>
);
}
使用 NavLink 属性后可以很清除的看见当前所在位置,但你在切换不同菜单导航时,你会发现感觉反应有点迟钝。
11.实现全局响应挂起
切换菜单导航迟钝的原因:
当用户浏览应用程序时,React 路由器会保留旧页面,因为数据正在加载到下一页。
解决方法:
在数据加载完成之前保持原来在状态,加载完成之后在路由在跳。为此官方封装了一个方法useNavigation
idle → submitting → loading → idle
在 src/routes/root.jsx 中引入 useNavigation
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方法,解决了 UI 界面响应迟钝的问题。
12.实现删除路由记录
实现原理:
通过 <Form/>
的 action 属性读取到的相关标记值。
在相对操作将把表单提交给删除路由 contact/:contactId/destroy。
当用户点击提交按钮时:
- react-router-dom 的
<Form>
方法阻止了浏览器默认向服务器发送新的 POST 请求的行为,而是通过使用客户端路由创建 POST 请求来模拟浏览器 <Form action="destroy">
匹配到根路由下的"contacts/:contactId/destroy"
路径,并向它发送请求- action 重定向后,删除记录后,通过重定向路由。让 React Router 调用页面上所有数据的加载器来获取最新的值,使用useLoaderData读取返回新值并更新到组件!
? 创建“destroy”路由模块 src/routes/destroy.jsx
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 */
? 当操作抛出一个错误信息
export async function action({ params }) {
throw new Error("oh dang!");
}
如果在子路由:
- 未添加 errorElement 属性时,他会找到根路由的 errorElement
- 添加有 errorElement 属性时,他会找到字节自己的 errorElement(冒泡就近原则)
? 配置路由errorElement属性
[
/* other routes */
{
path: "contacts/:contactId/destroy",
action: destroyAction,
errorElement: <div>Oops! There was an error.</div>,
},
];
删除与抛出错误信息的最终调试如下
删除与抛出错误信息时,信息对应的功能已经实现。但还有一个问题,当返回根路由时,右侧的内容时空白的。
13.实现默认子路由模板
当一个路由有子路由,而你在父路由的路径上时(http://localhost:5173/ ),根路由上的 <Outlet>
属性没有什么要渲染的内容,因为没有匹配的子路由。所以右侧内容显示空白页。
为了解决这个问题,您可以将索引路由视为默认的子路由来填充该空间。
? 创建索引路由模块
export default function Index() {
return (
<p id="zero-state">
This is a demo for React Router.
Check out{" "}
<a href="https://reactrouter.com">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 */
],
},
]);
最终调试结果如下
14.实现撤回路由
在编辑页面上,我们有一个取消按钮,它还没有做任何事情。我们希望它和浏览器的后退按钮做同样的事情。
? 使用 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>
);
}
新增一个菜单路由,在右侧点击 Cancel 按钮结果如下
新增一个菜单路由
取消后返回上一个路由
15.实现搜索功能
到目前为止,我们所有的交互功能都是更改 URL 的链接或将数据发送给操作的表单。
搜索功能也不列外,唯一不同的是搜索只改变 URL,它不改变数据。
现在搜索框只是一个普通的 HTML 的<form>
,没有使用 React Router “< Form>”。
测试原生 HTML 的<form>
是怎么处理的,在搜索框中键入一个名称( Norush ),然后按回车键。注意此时浏览器的 URL 中包含输入的查询参数:
http://localhost:5173/?q=Norush
HTML 的<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>
结合段代码分析:
正如我们之前看到的,浏览器可以通过的 name 属性序列化表单。
这个输入的名称是 q,这就是为什么 URL 有?q=
如果我们将它name属性为 search, URL 将是?search=
http://localhost:5173/?search=Norush
**注意,**这个表单与我们使用过的其他表单不同,它没有<form method="post">
。默认方法为“get”。这意味着当浏览器为下一个文档创建请求时,它不会将表单数据放入请求 POST 主体中,而是放入 GET 请求的URLSearchParams 中。
使用客户端路由获取提交
让我们使用客户端路由来提交这个表单,并在现有的加载器中过滤列表
? 将 HTLM 换成 React Router
<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 中的加载器 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 };
}
输入A 回车调式结果如下
因为使用的**
**默认这是一个 GET,而不是 POST,所以 React Router 不会调用这个动作。提交 GET 表单与单击表单与链接相同 ,都只是 URL 发生了变化。这就是为什么我们为过滤添加的代码是在加载器中,而不是在路由的动作中。
这也意味着它是一个普通的页面导航。你可以点击后退按钮回到原来的位置。
存在问题:
从调试的结果来看,目前的搜索功能还没有完善,仍然存在以下几个用户体验问题:
- 如果在搜索后刷新页面,则表单字段中不再包含该值,即使列表已经过过滤
- 如果在搜索后单击返回,表单字段仍然具有您输入的值,即使列表不再经过过滤。
换句话说,其实就是URL和表单状态不同步。哪怎么解决呢?
解决方式:
? 1.从加载器返回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 */}
</>
);
}
输入内容回车调式结果如下
这就解决了问题(1)。如果现在刷新页面,输入字段依然显示查询。
? **2.将输入值与URL搜索参数同步 **
相对于搜索功能来说,我们可能更希望过滤发生在每次击键时,而不是在表单显式提交时。而官方定义的 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 */}
</>
);
}
现在,当你输入键值时,表单将自动提交!
注意要提交的参数。我们传入event.currentarget.form。currentTarget是事件附加的DOM节点,currentTarget.form是输入的父Form节点。submit函数将序列化并提交您传递给它的任何表单。
15.1.搜索迟钝问题
在生产应用程序中,这种搜索可能会在数据库中查找记录,而数据库太大,无法一次发送所有记录并过滤客户端。这就是为什么这个演示有一些伪造的网络延迟。
没有任何加载指示,搜索感觉有点迟钝。即使我们可以使我们的数据库更快,我们也总是会有用户的网络延迟,这是我们无法控制的。为了获得更好的用户体验,让我们为搜索添加一些即时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 */}
</>
);
}
输入任意值调试结果如下
与全局响应挂载的方法一样,你会发现输入任意值时会有一个Pending 的效果。
navigation.location 会在应用导航到新URL并为其加载数据时显示。当没有待处理的导航时,它就会消失。
16.浏览器导航栏历史记录
现在每个按键都提交了表单,如果我们输入字符“seba”,然后用退格键删除它们,发现每个按键的内容都被添加到历史记录栈里边。
为了避免每次输入,都会被添加到历史堆栈中。我们可以将历史堆栈中的当前条目替换为下一页,而不是将其推入其中。在这里可以使用 **useSubmit ** 的replace属性来解决问题
? 在提交中使用replace
<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>
我们只想替换搜索结果,而不是我们开始搜索之前的页面,所以会快速检查输入内容q 是否是第一次搜索( q == null ),然后决定替换。添加这个条件后,每个按键就不会创建新的条目,历史栈中也只会添加最终输入结果。调试结果如下
到目前为止,我们所有改变数据的次数都使用了导航表单,在历史堆栈中创建新条目。虽然这些用户流很常见,但同样常见的方法还有,在不引用导航的情况下更改数据。
17.实现无导航更新
在HTML/HTTP中,数据变化和加载是用导航:*和’ 来建模的。在React Router中对应的是和。两者都会用到浏览器中的导航。但有时你想在不更改URL的情况下调用导航之外的加载器,或者调用一个操作(并获取页面上的数据以重新验证)。对于这些情况,官方提供了 **useFetcher **钩子。它能让我们在不引起导航的情况下与加载器和操作进行通信。
下面以收藏功能为例:
点联系人页面上的”☆” 收藏后。我们不需要创建或删除新记录,也不想更改页面,我们只想更改正在查看的页面上的数据
完成这个效果还需要以下几个步骤:
? 将表单更改为fetcher表单
import {
useLoaderData,
Form,
useFetcher,
} from "react-router-dom";
// existing code
function Favorite({ contact }) {
const fetcher = useFetcher();
let 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>
);
}
? 创建路由 action
// existing code
import { getContact, updateContact } from "../contacts";
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
}
? 在根路由配置路由的新动作
// 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”>的工作原理与一直在使用的 几乎完全相同。
它们都是调用操作,然后自动重新验证所有数据,甚至您的错误也会以有同样的方式被捕获。
它们唯一的区别就是用 <fetcher.Form method=”post”> 方法导航时 URL不会改变,历史堆栈不受影响。
虽然到这里无导航更新数据的功能已经实现,但是你会发现点击”☆”还是有点迟钝。实际使用中网络延迟可能也会导致同样的效果。解决这个问题可以使用 fetcher 的 formData 属性获取表单的数据提前更新
? 从fetcher.formData中读 favorite 状态值
// existing code
function Favorite({ contact }) {
const fetcher = useFetcher();
let favorite = contact.favorite;
if (fetcher.formData) {
favorite = fetcher.formData.get("favorite") === "true";
}
// existing code
}
? 避免网络延迟,或错误情况,需要在加载器中抛出404响应
export async function loader({ params }) {
const contact = await getContact(params.contactId);
if (!contact) {
throw new Response("", {
status: 404,
statusText: "Not Found",
});
}
return { contact };
}
? 将子路由封装在无路径路由中
最后一件事。我们看到的最后一个错误页面最好是在根出口中呈现,而不是在整个页面中呈现。事实上,我们所有子路由中的每个错误都应该放在 中,这样用户就有了比点击刷新更多的选择。如果将error元素添加到每个子路由中,但由于它们可能都是相同的错误页面,因此不建议这样做。
有一个更干净的方法。根据冒泡就近原则,我们可以再套一层子路由,将errorElement属性统一放在根子路由上
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 */
],
},
],
},
]);
18.类一种配置风格|JSX Routes
许多人喜欢用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>
)
);
总结
通过这个案例可以学到了什么?
其一、理解Router6构建工程化时,createBrowserRouter 是怎么结合action,loadar,element,errorElement,path,childern实现交互配置的。
其二、同时也会更清楚userouteerror、useloaderdata、usenavigation、usenavigate、usesubmit、usefetcher、<outlet/>
、<Link/>
、<NavLink/>
、<Form/>
等属性和方法的不同用途。