提示💡
React Router
是属于客户端路由。
安装
安装 Web 版本。
npm i react-router-dom
路由配置
<RouterProvider>
: 配置路由
首先,新建 router.js
文件,配置路由。每一路径对应一个组件。
注意❗
index: true
表示默认路由。
import { createBrowserRouter } from "react-router-dom";
import App, {
// { appLoader }
loader as appLoader,
} from "./App";
import Welcome from "./components/Welcome";
import ErrorPage from "./components/ErrorPage";
import NoteForm, {
action as noteFormAction,
loader as noteFormLoader,
} from "./components/NoteForm";
import NoteDetails, {
loader as noteLoader,
action as noteAction,
} from "./components/NoteDetails";
import { action as noteDeleteAction } from "./components/NoteDelete";
const router = createBrowserRouter([
{
path: "/",
element: <App />,
loader: appLoader,
// 记得添加 errorElement,渲染无路由匹配时显示的 UI
errorElement: <ErrorPage />,
children: [
{
// pathless route,作为错误边界,这样下面哪个组件出错,侧边栏都会显示,因为它在上层的 App组件中
errorElement: <ErrorPage />,
children: [
// 主页无其他组件时,渲染的内容
{ index: true, element: <Welcome /> },
// 右侧笔记详情页,包含在主页,因为导航是固定的
{
path: "notes/:noteId",
element: <NoteDetails />,
loader: noteLoader,
action: noteAction,
},
// 添加笔记
{
path: "notes/new",
element: <NoteForm />,
action: noteFormAction,
},
{
path: "notes/:noteId/edit",
element: <NoteForm />,
loader: noteFormLoader,
action: noteFormAction,
},
{
path: "notes/:noteId/delete",
action: noteDeleteAction,
errorElement: (
<p style={{ color: "var(--secondary-color)" }}>删除出错了</p>
),
},
],
},
],
},
]);
export default router;
然后,使用 RouterProvider
加载路由。
<RouterProvider router={router} />
<Outlet />
动态切换内容
设置 <Outlet />
根据路由动态切换内容。
<Outlet />
<Link>
:路由跳转
Link
组件来实现路由跳转,可以使用相对路径,并实现客户端路由,只切换变化部分。
{/* 使用 a 标签会导致整个页面刷新,注意使用 a 时必须加上开头的 / */}
{/* <a href={`/notes/${note.id}`}>{note.title}</a> */}
{/* 使用 Link 来实现客户端路由,只切换变化的部分,注意:使用 Link 时,可以省略开头的 / */}
{/* <Link to={`notes/${note.id}`}>{note.title}</Link> */}
<Link to={`notes/${note.id}`}>{note.title}</Link>
loader
:加载远程数据(新特性)
首先,需要获取数据的组件里面导出 loader
函数,编写数据获取操作,并返回数据。
// This feature only works if using a data router, see Picking a Router
// https://reactrouter.com/en/main/routers/picking-a-router
// 例如我们这个项目里使用的 browser router
// 函数名字可以随便起,可以根据组件名,也可以统一为 loader,在导入的时候再起别名
// export async function appLoader() {
export async function loader({ request }) {
// 这里可以直接返回 fetch() 的结果,也就是 response 对象,
// 在使用 useLoaderData() 的时候, React router 会自动调用 response.json()
// return fetch("/api/notes");
// 以下为添加搜索功能后,注意需要回车才能搜索
const url = new URL(request.url);
// return fetch(`/api/notes?${url.searchParams}`);
// url 与搜索框同步
const res = await fetch(`/api/notes?${url.searchParams}`);
const notes = await res.json();
return { notes, term: url.searchParams.get("term") };
}
然后,在需要获取数据的路由对象里面添加一个 loader
配置。
const router = createBrowserRouter([
{
// ...
element: <App />,
loader: appLoader,
// ...
},
]);
使用 useLoaderData
获取 loader
数据。
// 获取数据
const { notes } = useLoaderData();
获取动态路由参数并加载数据
{
path: "notes/:noteId",
element: <NoteDetails />,
loader: noteLoader,
//...
},
export async function loader({ params }) {
const res = await fetch(`/api/notes/${params.noteId}`);
}
<NavLink>
: 高亮选中导航菜单
className
回调函数中可以获取到, isActive
isPending
两个状态属性,然后就可以根据这两个状态加载不同的样式。
{/* 改为 NavLink 来展示选中高亮样式,className 值为函数,先高亮标题,后面再演示如何高亮整个笔记项目 */}
<NavLink
to={`notes/${note.id}`}
className={({ isActive, isPending }) =>
isActive ? "active" : isPending ? "pending" : ""
}
>
{note.title}
</NavLink>
/* 激活状态下设置整个笔记项目 .note 的背景高亮 */
.note:has(a.active)::before {
content: "";
position: absolute;
top: -6px;
bottom: -6px;
left: -24px;
right: -24px;
background: hsl(183, 100%, 95%, 0.1);
}
/* 激活状态下设置链接字体为橘色 */
.note a.active {
color: var(--secondary-color);
}
.note a.pending {
/* pending 状态只设置颜色为橘色 */
color: var(--secondary-color);
}
增删改查
路由跳转: <Form>
发送 GET
请求
首先,配置路由跳转。
// 添加笔记
{
path: "notes/new",
element: <NoteForm />,
},
然后,使用 <Form>
组件进行路由跳转。
<Form action="notes/new">
<button type="submit" className="addNoteBtn">
添加笔记
</button>
</Form>
添加数据 :使用 <Form>
发送 POST
请求
使用 <Form>
发送 POST
请求。
<Form method={note ? "put" : "post"}>
<input
type="text"
// name 是必须得,用于获取表单数据(下同)
name="title"
// 注意这里要用 defaulValue,不能用 value,否则控制台会报错:You provided a `value` prop to a form field without an `onChange` handler.
defaultValue={note?.title}
placeholder="请输入笔记标题"
/>
<textarea
// name 是必须得,用于获取表单数据(下同)
name="content"
rows="6"
placeholder="请输入笔记内容"
defaultValue={note?.content}
> </textarea>
<div className="formActions">
<button type="submit">{note ? "保存笔记" : "添加笔记"}</button>
{/* 编程式控制导航 */}
<button type="button" onClick={() => navigate(-1)}>
返回
</button>
</div>
</Form>
提示💡
loader
和action
的区别是loader
用于加载数据,action
则用于修改数据。当action
改变数据后,Router
会自动使用loader
获取数据。
下面添加 action
, <Form>
将原生的表单进行了拦截,并将表单数据注入到了 request
对象中。使用 formData()
获取表单数据,返回一个类似于 Map
类型。可以 formData.get("name")
来获取没有表单项的数据。
export async function action({ request, params }) {
const formData = await request.formData();
const note = Object.fromEntries(formData); // 转换为Javascript对象
let url = "/api/notes";
if (params.noteId) {
url += `/${params.noteId}`;
}
const res = await fetch(url, {
method: request.method, // POST 或 PUT,先讲使用 POST 的情况
headers: {
"Content-Type": "application/json",
Authorization: "Bearer SOMEJWTTOKEN",
},
body: JSON.stringify(note),
});
const newNote = await res.json();
return redirect(`/notes/${newNote.id}`);
}
为路由配置 action
属性。
// 添加笔记
{
path: "notes/new",
element: <NoteForm />,
action: noteFormAction,
},
redirect: 重定向页面
export async function action({ request, params }) {
//...
return redirect(`/notes/${newNote.id}`);
}
编辑数据 :展示表单默认值
使用表单的 defaultValue
属性设置默认值。
<Form method={note ? "put" : "post"}>
<input
//...
// 注意这里要用 defaulValue,不能用 value,否则控制台会报错:You provided a `value` prop to a form field without an `onChange` handler.
defaultValue={note?.title}
//...
/>
//...
</Form>
编辑数据: 发送 PUT
请求
根据编辑和添加指定不同的 method
值。
<h2>{note ? "编辑笔记" : "添加新笔记"}</h2>
<Form method={note ? "put" : "post"}>
//...
</Form>
获取表单不同的 request.method
值。
export async function action({ request, params }) {
//...
const res = await fetch(url, {
method: request.method, // POST 或 PUT,先讲使用 POST 的情况
//...
});
//...
}
配置编辑的路由。
{
path: "notes/:noteId/edit",
element: <NoteForm />,
loader: noteFormLoader,
action: noteFormAction,
},
删除数据:发送 DELETE
请求
action="delete"
为路由添加路径 /delete
。
<Form action="delete" method="delete">
<button type="submit">删除</button>
</Form>
添加删除的 action
。
export async function action({ params }) {
// 此时全屏显示错误,所以需要在 delete 路由里配置 errorElement,来让侧边栏存在
throw new Error("删除出错了!");
await fetch(`/api/notes/${params.noteId}`, {
method: "delete",
});
return redirect("/");
}
配置路由。
{
path: "notes/:noteId/delete",
action: noteDeleteAction,
errorElement: (
<p style={{ color: "var(--secondary-color)" }}>删除出错了</p>
),
},
navigate
: 编程式的控制导航
使用 useNavigate()
进行页面跳转。
const navigate = useNavigate();
return (
//...
{/* 编程式控制导航 */}
<button type="button" onClick={() => navigate(-1)}>
返回
</button>
//...
);
查询数据:GET
请求+请求参数
Form
表单会把表单控件的值以键值对的形式追加到 URL
后边,这些键值对的 key
就是 name
属性,值就是 value
属性。
{/* Search 发送的是 GET 请求 */}
<Form>
<input
type="search"
name="term"
//...
/>
</Form>
使用 url.searchParams
为请求数据,添加查询参数。
export async function loader({ request }) {
// 以下为添加搜索功能后,注意需要回车才能搜索
const url = new URL(request.url);
return fetch(`/api/notes?${url.searchParams}`);
}
查询数据:编程式提交表单
监听 onChange
执行 submit()
方法。通过 event.currentTarget.form
访问表单实例。
// 如果想在每次按键都要搜索的话:
const submit = useSubmit();
return (
<Form>
<input
type="search"
name="term"
// 如果想在每次按键都要搜索的话:
onChange={(event) => {
// submit(event.currentTarget.form)
// 如果使用 replce 来防止后退太多层
const isFirstSearch = term == null;
submit(event.currentTarget.form, {
replace: !isFirstSearch,
});
}}
/>
</Form>
);
查询数据:URL 与表单的同步
获取 URL
参数 url.searchParams
并返回。
export async function loader({ request }) {
const url = new URL(request.url);
// url 与搜索框同步
const res = await fetch(`/api/notes?${url.searchParams}`);
const notes = await res.json();
return { notes, term: url.searchParams.get("term") };
}
SearchNote
中使用 useLoaderData()
获取父组件中的数据。并设置 defaultValue
为 term
。
const { term } = useLoaderData();
return (
<Form>
<input
type="search"
name="term"
defaultValue={term}
/>
</Form>
);
push
vs replace
: 防止后退太多层
// 后退按钮保持搜索框中的内容
useEffect(() => {
searchRef.current.value = term;
// 测试浏览器长按后退的history,有个友好的名称
// document.title = "搜索结果:" + term;
}, [term]);
修改历史记录方式,不是第一次搜索就使用 replace
。
return (
<Form>
<input
type="search"
name="term"
onChange={(event) => {
// submit(event.currentTarget.form)
// 如果使用 replce 来防止后退太多层
const isFirstSearch = term == null;
submit(event.currentTarget.form, {
replace: !isFirstSearch,
});
}}
/>
</Form>
);
操作数据:不发生路由跳转和刷新页面
使用 useFetcher()
返回对象中的 <fetcher.Form>
来实现表单的提交。
export async function action({ request, params }) {
// 不使用 optimistic UI,网络慢会等待数字增长
const formData = await request.formData();
return fetch(`/api/notes/${params.noteId}`, {
method: request.method,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
likes: Number(formData.get("likes")) + 1,
}),
});
}
function NoteDetails() {
const fetcher = useFetcher();
return (
<fetcher.Form method="put">
<button name="likes" value={note.likes} type="submit">
</fetcher.Form>
);
}
路由中添加 action
配置。
{
path: "notes/:noteId",
element: <NoteDetails />,
loader: noteLoader,
action: noteAction,
},
操作数据: Optimistic UI
Optimistic UI
乐观 UI
是指先为用户返回交互效果,然后再请求服务器,更新数据。更新不成功也没有关系,下次直接展示数据库中的值。
定义一个 likes
变量,进行点赞处理之后,更新表单中的 value
值。
const note = useLoaderData();
const fetcher = useFetcher();
// optimistic ui
let likes = note.likes;
if (fetcher.formData) {
likes = Number(fetcher.formData.get("likes")) + 1;
}
return (
<fetcher.Form method="put">
{/* value 也需要改(Optimistic UI) */}
<button name="likes" value={likes} type="submit">
{/* 点赞 {note.likes} */}
{/* Optimistic UI,先增长数量 */}
点赞 {likes}
</button>
</fetcher.Form>
);
}
异常处理
加载状态:全局加载状态
使用 useNavigation()
获取到加载状态 state
,然后根据状态进行展示加载状态。
// 全局加载状态
const navigation = useNavigation();
return (
<div className="container">
{/* 渲染路由组件 */}
{/* 加载状态 navigation state: loading, idle, submitting */}
{/* 搜索时也会闪动 */}
{/* {navigation.state === "loading" && <div>loading...</div>} */}
{/* 试试使用 progress */}
{navigation.state === "loading" && (
<progress className="loadingProgress" />
)}
</div>
);
.loadingProgress {
position: fixed;
width: 100%;
height: 6px;
top: 0;
left: 0;
right: 0;
}
加载状态: 局部加载状态:搜索状态
通过搜索关键词 term
判断是否在搜索中。
const navigation = useNavigation();
// 搜索笔记列表加载状态
const searching =
navigation.location &&
new URLSearchParams(navigation.location.search).has("term");
return (
<div className="search">
{searching && <div>搜索中...</div>}
</div>
);
错误处理:全屏错误提示
使用 useRouteError()
访问错误信息。
function ErrorPage() {
const error = useRouteError();
return (
<div className="errorPage">
<h1>出错了!</h1>
<p>您访问的页面遇到了错误.</p>
<p>错误信息:{error.statusText || error.message}</p>
</div>
);
}
在需要展示错误的地方添加 errorElement
属性。
const router = createBrowserRouter([
{
path: "/",
element: <App />,
// 记得添加 errorElement,渲染无路由匹配时显示的 UI
errorElement: <ErrorPage />,
},
]);
错误处理:局部错误提示
需要在展示错误的同时展示侧边栏,就可以将 errorElement
放到 children
的一个对象里边。所有页面都放到这个对象的 children
里面。也可以在对应的组件添加 errorElement
错误。错误是从当前节点向上查找 errorElement
,如果都没有就展示Router
默认错误。
const router = createBrowserRouter([
{
path: "/",
element: <App />,
children: [
{
// pathless route,作为错误边界,这样下面哪个组件出错,侧边栏都会显示,因为它在上层的 App组件中
errorElement: <ErrorPage />,
children:[
{
path: "notes/:noteId/delete",
action: noteDeleteAction,
errorElement: (
<p style={{ color: "var(--secondary-color)" }}>删除出错了</p>
),
},
]
},
],
},
]);
错误处理:自定义错误信息
通过 res.status
判断是否需要抛出错误,如果有错误就使用 throw new Response()
抛出一个错误对象。
export async function loader({ params }) {
// 处理错误
const res = await fetch(`/api/notes/${params.noteId}`);
if (res.status === 404) {
throw new Response("", {
status: 404,
statusText: "笔记不存在", // 不支持中文
});
}
return res;
}
json utility
: 组装自定义响应数据
第一个参数是响应数据,第二个参数是 header
属性。
const newData = json({...data, custom:"123"},{ staus: 200})
// 等价于
const newData = new Response(JSON.stringify({...data, custom:"123"}), {
status: 200,
})
优化
先渲染后加载数据:Deferred
数据方式和加载状态展示
使用 defer()
传入对象,需要注意的是要手动进行处理 Promise
。使用 useLoaderData()
获取数据。使用 <React.Suspense>
和 <Await>
显示加载状态和请求数据。
// defered
export async function loader({ params }) {
return defer({
note: fetch(`/api/notes/${params.noteId}`).then((res) => res.json()),
});
}
function NoteDetails() {
// 获取 defer 对象
const data = useLoaderData();
return (
<React.Suspense fallback={<div>loading...</div>}>
<Await resolve{data.note} errorElement={<p>出现错误!</p>}>
{(note) => {
// 移动到这里,可以使用 note
let likes = note.likes;
console.log(likes);
if (fetcher.formData) {
likes = Number(fetcher.formData.get("likes")) + 1;
}
return (
<div>
// 其他内容...
</div>
);
}}
</Await>
</React.Suspense>
);
}
使用 useAsyncValue
访问 deffered
数据
将需要展示的视图定义为一个单独的组件 <Note />
,然后在组件中使用 useAsyncValue()
就可以访问到最近一个父组件 <Await>
中的数据 data.note
。
// defered
export async function loader({ params }) {
return defer({
note: fetch(`/api/notes/${params.noteId}`).then((res) => res.json()),
});
}
function NoteDetails() {
// 获取 defer 对象
const data = useLoaderData();
return (
<React.Suspense fallback={<div>loading...</div>}>
<Await resolve={data.note} errorElement={<p>出现错误!</p>}>
<Note />
</Await>
</React.Suspense>
);
}
// useAsyncValue 只能访问最近的父组件中 defer 的数据
function Note() {
// Await 中 resolve 属性指定了 data.note,这里获取的就是 note
const note = useAsyncValue();
//...
return (
<div>
// 内容
</div>
);
}
export default NoteDetails;
配置恢复滚动位置
<ScrollRestoration>
默认会返回 location.key
, location.key
相同就会滚动到相应的位置。返回 location.pathname
,相同路径就会滚动到相同位置。
return (
<div className="container">
{/* 1. 要修容滚动行为,添加 ScrollRestoration 组件,react router 建议在根组件中配置 */}
<ScrollRestoration
getKey={(location) => {
// 默认为 location.key 是浏览器默认行为,每个页面都会有一个唯一的 key,
// 会记录滚动位置,如果用户点击的不是前进后退,而是直接点击按钮跳转了,
// 即使是到相同的页面,但浏览器还是当做是新的页面,不会恢复之前的滚动位置,
// 使用 location.pathname 就可以避免这个问题
return location.pathname;
}}
/>
{/* 测试用 */}
<Link to="/">回到首页</Link>
</div>
);
使用 JSX
配置路由
每一个路由都是一个 <Route>
组件。
const routerJSX = createBrowserRouter(
createRoutesFromElements(<Route path="/" element={<App />} ></Route>)
);
export default routerJSX;