React + Router

React + Router

这个只是专门讲解 React Router 新开的例子。

教程来源:https://reactrouter.com/en/main/start/tutorial

创建新项目

yarn create vite my-react-router-app --template react-ts
cd my-react-router-app
yarn

安装 React Router 依赖:

yarn add react-router-dom localforage match-sorter sort-by
# 对于 ts 要添加 @types/sort-by, 否则报错找不到模块【不影响运行】
yarn add -D @types/sort-by

运行项目

yarn dev

# VITE v4.4.11  ready in 358 ms

# ➜  Local:   http://localhost:5173/
# ➜  Network: use --host to expose
# ➜  press h to show help

获取该项目所需要的文件

这里找到 css 文件,将其粘贴到 src/index.css 文件中: Copy/Paste the tutorial CSS

这里找到 js 文件,将其添加到 src/contacts.jsCopy/Paste the tutorial CSS

Js 文件主要的用途:将创建、阅读、搜索、更新和删除数据。一个典型的Web应用程序可能会与Web服务器上的API对话,将使用浏览器存储并伪造一些网络延迟来保持这一点。这些代码都与React Router无关,所以直接复制/粘贴即可。

2个文件拷贝到项目中后,你可以将无关文件删除,可以删除任何其他内容(如 App.jsassets 等)。

项目主要文件(文件目录):

src
├── contacts.js # 这里最好转换成 ts 文件,即添加上类型,方便后面
├── index.css
└── main.tsx

添加路由器

main.tsx 中创建并渲染浏览器路由器

// 导入路由模块
import {
  createBrowserRouter,
  RouterProvider
} from "react-router-dom"

// 配置路由
const router = createBrowserRouter([
  {
    // 根路由 root router
    path: "/",
    // 之后会替换成组件
    element: <div>Hello world!</div>
  }
])

// 渲染:将其添加到 render 函数中作为参数传递即可
ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <RouterProvider router={router}></RouterProvider>
  </React.StrictMode>,
)

创建根路由

创建 src/routessrc/routes/root.tsx

rout.tsx 文件如下:

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

回到 src/main.tsx 的 router 变量处,将 <div>Hello World</div> 替换成组件。

import Root from './routes/root'

// 配置路由
const router = createBrowserRouter([
  {
    path: "/",
    element: <Root></Root>
  }
])

好了,基本结构搭建完毕!运行项目看看吧。

yarn dev

处理错误页面

以前在写 React 的时候,因为是单页面应用,所以地址栏的变化不会影响程序的影响。

但是导入了 React Router 组件后,地址栏会被它所监听到,从而出现 React Router 默认的错误屏幕。

创建 src/error-page.tsx 文件。

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

回到 src/main.tsx 文件。

将根路由上的 <ErrorPage> 设置为 errorElement

import ErrorPage from './error-page'

// 配置路由
const router = createBrowserRouter([
  {
    path: "/",
    element: <Root></Root>,
    errorElement: <ErrorPage></ErrorPage>
  }
])

然后我们随机访问一个页面,http://localhost:5173/contacts/1 会显示我们创建的错误页面(ErrorPage 组件)。

useRouteError 提供了抛出的错误。当用户导航到不存在的路由时,您将得到一个错误响应,并显示“Not Found” statusText

创建联系人路由

有了根路由,那么其他页面呢?

创建一个 src/routes/contact.tsx文件

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.tsx 进行添加路由信息,还是配置那里。

// 配置路由
const router = createBrowserRouter([
  //....
  {
    path: "contacts/:contactId",
    element: <Contact></Contact>,
    errorElement: <ErrorPage></ErrorPage>
  }
])

会发现,它不在我们的根布局中(右侧没有 组件,即侧边导航栏没有看到)

如果希望contact组件在 <Root> 布局中呈现。

我们通过使联系路由成为根路由的子路由来实现这一点。

回到我们的 src/main,tsx 的配置路由信息:

// 将添加的 contacts 路由信息移动到 Root 的 children 属性上
// 配置路由
const router = createBrowserRouter([
  {
    //....
    children: [
      {
        path: "contacts/:contactId",
        element: <Contact></Contact>
      }
    ]
  },
])

再次看到根布局,但右侧是一个空白页面。我们需要告诉根路由我们希望它在哪里渲染其子路由。

回到我们的 组件中(src/routes/rout.tsx

Render an Outlet 👉渲染一个 <Outlet>

// 导入
import { Outlet } from "react-router-dom"

// 在哪里进行渲染
<div id="detail">
  <Outlet></Outlet>
</div>

客户端路由 CSR

Client Side Routing 。

在路由跳转之间,我们发现,浏览器是请求整个网页文档。而不是使用 React Router。

客户端路由允许我们的应用更新URL,而无需从服务器请求另一个文档。【立即呈现新的UI】

使用 组件

import { Link } from "react-router-dom"

// 将 a 标签替换成 <Link to={}> </Link>
<nav>
  <ul>
    <li>
      <Link to={`/contacts/1`}>Your Name</Link>
    </li>
    <li>
      <Link to={`/contacts/2`}>Your Friend</Link>
    </li>
	</ul>
</nav>

加载数据

前面我们只是将数据写死,该节内容就是如何通过 React Router 调用 API 去获取数据。

注意:这是一个 Demo,真实的情况请采用 axios 去获取后端接口。

到我们的根路由组件()

import { getContacts } from "../contacts"

export async function loader() {
    const contacts = await getContacts();
    return { contacts };
}

再来到 src/main.tsx 文件,进行配置 loader

import Root, { loader as rootLoader } from './routes/root'

// 配置路由
const router = createBrowserRouter([
  {
    // ...
    loader: rootLoader,
    // ...
  },
])

然后回到我们的根路由组件()

import { Outlet, Link, useLoaderData } from "react-router-dom"

// 其他代码...

export default function Root() {
  const { contacts }: any = useLoaderData();
  
  return (
  	<>
    	{* 其他代码... *}
			<nav>
          {/* 判断是否存在联系人 */}
          {contacts.length ? (
            <ul>
              {contacts.map((contact: any) => (
                <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>
			 {* 其他代码... *}
    </>
  )
}

好了,看看页面吧~

创建联系人

在页面中我们可以看到没有联系人列表。

这个时候我们需要完成新建功能,看到 New 按钮了吗?

点击一下会发生什么?呜?出错了?找不到 localhost 的网页

这里正常情况会报 405 错误,找不到对应的请求方式。

这里可以采取两种方式: 给 vite 增加 post 提交功能,或者采用 CSR 客户端路由方式【本节重点】。

回到我们的根路由组件()

import { Outlet, Link, useLoaderData, Form } from "react-router-dom"
import { getContacts, createContact } from "../contacts";

// 新增联系人
export async function action() {
    const contact = await createContact();
    return { contact };
}

// ...

return (
	// ...
  <Form method="post">
    <button type="submit">New</button>
  </Form>
)

回到 src/main.tsx 配置 action

import Root, { loader as rootLoader, action as rootAction } from './routes/root'

// 配置路由
const router = createBrowserRouter([
  {
    path: "/",
    element: <Root></Root>,
    errorElement: <ErrorPage></ErrorPage>,
    loader: rootLoader,
    action: rootAction,
    //...
])

但是思考一下。

当我们点击 New 后,页面如何更新的? action 在哪里?重新获取数据的代码在哪里? useStateonSubmituseEffect 在哪里?!

<Form> 阻止浏览器向服务器发送请求,而是将其发送到路由 action 。在Web语义中,POST通常意味着某些数据正在更改。按照惯例,React Router将此作为提示,在操作完成后自动重新验证页面上的数据。这意味着你所有的 useLoaderData 钩子都会更新,UI会自动与你的数据保持同步!

所以,原理在于 React Router 根据 POST (意味着要更新数据了),去自动帮助我们更新源代码。

获取 Loader 中 URL 参数

当点击联系人列表的某个名称时,会跳转到某个页面,只是 id 变成我们模拟出来的了,而不是写死的。

但是这里有个问题,右侧还是 组件,数据没有进行更新。

在 也需要 loader ,这里与根路由一样,不再阐述,只会给关键代码。

// src/routes/contact.tsx
import { Form, useLoaderData } from "react-router-dom"
import { getContact } from "../contacts"

export async function loader({ params }: any) {
    const contact = await getContact(params.contactId)
    return { contact }
}

export default function Contact() {
    const { contact }: any = useLoaderData(); 
    // ...
}
// src/main.tsx
import Contact, { loader as contactLoader } from './routes/contact'

// 配置路由
const router = createBrowserRouter([
  {
    //....
    children: [
      {
        path: "contacts/:contactId",
        element: <Contact></Contact>,
        loader: contactLoader
      }
    ]
  },
])

更新数据

一个新文件 src/routes/edit.tsx

import { Form, useLoaderData } from "react-router-dom";

// 自己补充 loader ,参考 contact.tsx

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.tsx
import EditContact, { loader as editLoader } from './routes/edit'
// 配置路由
const router = createBrowserRouter([
  {
    //...
      {
        path: "contacts/:contactId/edit",
        element: <EditContact></EditContact>,
        loader: editLoader
      }
    ]
  },
])

除了添加 loader 外,还应该添加 action

// src/routes/edit.tsx
import { Form, useLoaderData, redirect } from "react-router-dom";
import { getContact, updateContact } from "../contacts"

export async function action({ request, params }: any) {
    const formData = await request.formData()
    const updates = Object.fromEntries(formData)
    await updateContact(params.contactId, updates)
    return redirect(`/contacts/${params.contactId}`)
}

// src/main.tsx
// 配置路由
const router = createBrowserRouter([
  {
    //...
    children: [
      {
        //..
      },
      {
        path: "contacts/:contactId/edit",
        element: <EditContact></EditContact>,
        loader: editLoader,
        action: editAction
      }
    ]
  },
])

Loaders and actions 都能够返回 Response (它们都能接收 request 参数)。

redirect 重定向到一个地址。

如果没有客户端路由,如果服务器在POST请求后重定向,则新页面将获取最新数据并呈现。正如我们之前了解到的,React Router模拟了这个模型,并在操作之后自动重新验证页面上的数据。这就是为什么侧边栏会在我们保存表单时自动更新。如果没有客户端路由,额外的重新验证代码就不存在,所以它也不需要在客户端路由中存在!

小插曲 ,更新 “New” 按钮的 action ,

在 组件中,经过前面这么多步骤,自己也能够实现

高亮显示当前联系人

随着左侧的导航栏联系人越来越多,我们分不清右侧人物是左侧哪个标签。

可以采用 <NavLink>

// src/routes/root.tsx
import {
  Outlet,
  NavLink,
  useLoaderData,
  Form,
  redirect,
} from "react-router-dom";

return (
	// ...
  <NavLink
    to={`contacts/${contact.id}`}
    className={({ isActive, isPending }) =>
        isActive
        ? "active"
        : isPending
        ? "pending"
        : ""
        }
     >
    {/* other code */}
  </NavLink>
)

加上过渡动画

使用 Global Pending UI

使用到的 API : useNavigation。通过它,我们可以知道该页面是否处于加载中。

在短延迟后添加一个漂亮的淡入淡出(以避免快速加载的UI闪烁)。你可以做任何你想做的事情,比如显示一个加载条在顶部。

在跳转过程时,会发现第二次明显加快不少,这是有缓存。

// src/routes/root.tsx
import {
  // existing code
  useNavigation,
} from "react-router-dom";

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

删除记录

这里与 edit.tsx 类似,同样创建文件,添加 action

// src/routes/destroy.tsx 【自己创建】
import { redirect } from "react-router-dom";
import { deleteContact } from "../contacts";

export async function action({ params }: any) {
    await deleteContact(params.contactId);
    return redirect("/");
}

// src/main.tsx
/* 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 */

索引路由

Index Routes

在浏览首页时,右侧总是空白,我们可以设置一个组件显示在那里。

// src/routes/index.tsx
export default function Index() {
  return (
    <p id="zero-state">
      This is a demo for React Router.
      <br />
      Check out{" "}
      <a href="https://reactrouter.com">
        the docs at reactrouter.com
      </a>
      .
    </p>
  );
}

// src/main.tsx
// 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 */
    ],
  },
]);

浏览器后退按钮

我们需要在按钮上添加一个click处理程序,以及React Router中的 useNavigate

// src/routes/edit.tsx
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>
  );
}

URL 搜查参数 和 GET 提交

在前面我们,我们分别针对增删改,都做了处理。

  • 按下 New 创建联系人
  • 点击 左侧导航栏,查看联系人信息
  • 点击 Edit 按钮可以修改
  • 点击 Delete 按钮可以进行删除
  • 点击 编辑页面的取消按钮,可以回退回上一个页面
  • 在首页添加了索引路由
  • 左侧导航栏使用 NavLink 显示当前

不仅仅只有上面,还保存到 indexDb 中哦,这个演示已经做了很多了,

现在来做搜索。

现在将 form 改成 React Router 做 CSR 【客户端路由】

// src/routes/root.jsx
<Form id="search-form" role="search"></Form>

因为这是 GET 请求,它不会经过 action,将过滤代码写在 loader 中。

// src/routes/root.jsx
// 获取联系人列表 
export async function loader({ request }: any) {
    // 过滤
    const url = new URL(request.url);
    const q = url.searchParams.get("q");
    const contacts = await getContacts(q);
    return { contacts };
}

现在回到页面,进行简单的输入一些字符。就能看到查询的结构了。

URL地址栏与搜索框一致

前面只是简单的进行搜索。

问题:

  • 当重新刷新该页面时,搜索框中不包含值了
  • 当搜素完后,回退上一页,搜索框中还是以前的值

第一个问题比较好解决,给搜索框一个默认值。

// src/routes/root.jsx
export async function loader({ request }: any) {
    //...
    return { contacts, q };
}

const { contacts, q }: any = useLoaderData();
<input
  ...
  defaultValue={q}
/>

解决第二个问题,搜索框会残留之前的搜素记录。

当数据发生变化时,对搜素框的值进行修改,这里可以用到 useEffect(callback, [q])

// src/routes/root.jsx
const [query, setQuery] = useState(q);

// 当查询 q 变化时,更新 query
useEffect(() => {
  setQuery(q);
}, [q])

// 将查询值作为搜素框的值
<input value={query} />
  
// 在搜索框输入时,记得监听,更新 query 值
<input onChange={e => {
	setQuery(e.target.value)
}}

自动提交 useSubmit

前面的搜素,只有我们按下回车键才会进行表单提交,才会过滤后的结果。

使用 React Router 的 API:useSubmit

// src/routes/root.jsx
import { useSubmit } from "react-router-dom"

// 获取 submit
const submit = useSubmit();

// 提交表单组件
onChange={e => {
  // currentTarget 与 target
	submit(e.currentTarget.form);
}}

这里有个小小知识点:

  • currentTarget 当前绑定该事件的元素
  • target 触发该事件的元素
  • 当绑定事件元素和触发事件元素一致时,它们相同(包括 this)

优化体验

因为搜素是在网络请求,可能网络不太好或者请求数据量大。使用 useNavigation 获取加载状态。

// src/routes/root.jsx
// 是否在搜素
const searching = navigation.location && new URLSearchParams(navigation.location.search).has("q");

// 修改搜索框样式
<input className={searching ? "loading" : ""} />

// 修改搜索框图标
<div
	id="search-spinner"
  aria-hidden
  hidden={!searching}
/>

管理历史堆栈

优化自动提交,useSubmit()

submit 有其他配置,如 replace。

设置为true,替换浏览器历史记录堆栈中的当前条目,而不是创建一个新条目(即保持在“同一页面”)。默认为false。

submit(e.currentTarget.value, {
  replace: true
})

我们只想替换搜索结果,而不是开始搜索之前的页面。

const isFirstSearch = q == null;
submit(e.currentTarget.value, {
  replace: !isFirstSearch;
})

在没有导航下更改数据

在前面都是通过修改地址栏,通过导航的方式切换页面,使页面上的数据发生改变。

在该节内容中,我们将学习到如何通过 useFetcher API 去更改数据。

我们有 useFetcher 钩子。它允许我们与 loader 和 action 进行通信,而不会导致导航。

比如该项目中联系人页面上的★按钮对此很有意义。我们不是在创建或删除新记录,我们不想更改页面,我们只是想更改我们正在查看的页面上的数据。

// src/routes/contact.tsx
import { useFetcher } from "react-router-dom"

// 使用
const fetcher = useFetcher();
return (
	<fetcher.Form method="post">
  </fetcher.Form>
)

对于 post 提交我们还要 action 来处理。

export async function action({ request, params }: any) {
    const formData = await request.formData();
    return updateContact(params.contactId, {
        favorite: formData.get("favorite") === "true"
    })
}

将 action 加入到 src/main.tsx 中,这一步跳过,较简单。

优化点击

在点击收藏按钮时,明显感到迟钝。

在 Favorite 加上下面代码:

if (fetcher.formData) {
  favorite = fetcher.formData.get("favorite") === "true"
}

加上后,会立即更新状态。

优化找不到联系人

当访问一个未知的联系人时,报错:

Cannot read properties of null (reading 'avatar')

在获取联系人(loader)中去判断。

// src/routes/contact.tsx
export async function loader({ params }) {
  const contact = await getContact(params.contactId);
  if (!contact) {
    throw new Response("", {
      status: 404,
      statusText: "Not Found",
    });
  }
  return { contact };
}

将错误页面单独出来

将报错信息,都放在根路由上。(即:左侧有侧边导航栏)

// 配置路由
const router = createBrowserRouter([
  {
    // 根路由
    path: "/",
    element: <Root></Root>,
    errorElement: <ErrorPage></ErrorPage>,
    loader: rootLoader,
    action: rootAction,
    children: [
      {
        // 单独拉出一个路由,去处理错误页面
        errorElement: <ErrorPage></ErrorPage>,
        children: [
         	// 子路由
        ]
      }
    ]
  },
])

JSX 风格【拓展】

可以不使用 createBrowserRouter 配置路由。

可以采取 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>
  )
);

更多 API 访问 React Router 官方: React-Router v6.16.0

posted @ 2023-10-16 12:02  辰梦starDream  阅读(65)  评论(0编辑  收藏  举报  来源