提示💡

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}`);
}

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>

提示💡

loaderaction 的区别是 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>
	),
},

使用 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() 获取父组件中的数据。并设置 defaultValueterm

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.keylocation.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;