提示💡
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;