主分支
分支
main (6.23.1)dev
版本
6.23.1v4/5.xv3.x
教程
本页内容

教程

欢迎来到教程!我们将构建一个小型但功能丰富的应用程序,用于跟踪您的联系人。如果您跟着做,预计需要 30-60 分钟。

👉 每次看到这个符号,就意味着您需要在应用程序中执行某些操作!

其余部分只是为了您的信息和更深入的理解。让我们开始吧。

设置

如果您不打算在自己的应用程序中进行操作,可以跳过此部分。

在本教程中,我们将使用 Vite 作为捆绑器和开发服务器。您需要安装 Node.js 以使用 npm 命令行工具。

👉️ 打开您的终端并使用 Vite 启动一个新的 React 应用程序:

npm create vite@latest name-of-your-project -- --template react
# follow prompts
cd <your new project directory>
npm install react-router-dom # always need this!
npm install localforage match-sorter sort-by # only for this tutorial.
npm run dev

您应该能够访问终端中打印的 URL。

 VITE v3.0.7  ready in 175 ms

  ➜  Local:   http://127.0.0.1:5173/
  ➜  Network: use --host to expose

我们为本教程准备了一些预先编写的 CSS,这样我们就可以专注于 React Router。您可以随意批评它或编写自己的 CSS 😅(我们在 CSS 中做了一些我们通常不会做的事情,以便本教程中的标记尽可能简洁。)

👉 将教程 CSS 复制/粘贴到这里src/index.css

本教程将创建、读取、搜索、更新和删除数据。一个典型的 Web 应用程序可能会与 Web 服务器上的 API 进行通信,但我们将使用浏览器存储并模拟一些网络延迟,以使本教程保持重点。这些代码与 React Router 无关,因此只需复制/粘贴即可。

👉 将教程数据模块 复制/粘贴到这里src/contacts.js

您在 src 文件夹中只需要 contacts.jsmain.jsxindex.css。您可以删除其他任何文件(例如 App.jsassets 等)。

👉 删除 src/ 中未使用的文件,以便只剩下这些文件:

src
├── contacts.js
├── index.css
└── main.jsx

如果您的应用程序正在运行,它可能会短暂崩溃,请继续操作 😋。有了这些,我们就可以开始了!

添加路由器

首先,创建一个 浏览器路由器 并配置我们的第一个路由。这将为我们的 Web 应用程序启用客户端路由。

main.jsx 文件是入口点。打开它,我们将把 React Router 放到页面上。

👉 main.jsx 中创建并渲染一个 浏览器路由器

import * as React from "react";
import * as 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>
);

这个第一个路由通常被称为“根路由”,因为我们其余的路由将在它内部渲染。它将作为 UI 的根布局,随着我们进一步深入,我们将拥有嵌套布局。

根路由

让我们为这个应用程序添加全局布局。

👉 创建 src/routessrc/routes/root.jsx

mkdir src/routes
touch 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>
    </>
  );
}

目前还没有 React Router 特定的内容,因此您可以随意复制/粘贴所有这些内容。

👉 <Root> 设置为根路由的 element

/* existing imports */
import Root from "./routes/root";

const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,
  },
]);

ReactDOM.createRoot(document.getElementById("root")).render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>
);

现在应用程序应该看起来像这样。拥有一个既能设计又能编写 CSS 的设计师真好,不是吗?(感谢 Jim 🙏)。

处理未找到错误

在项目早期了解应用程序如何响应错误始终是一个好主意,因为我们在构建新应用程序时编写的错误代码远远多于功能代码!这样不仅可以让您的用户获得良好的体验,而且在开发过程中也有所帮助。

我们向这个应用程序添加了一些链接,让我们看看点击它们会发生什么?

👉 点击侧边栏中的一个名称

screenshot of default React Router error element

太糟糕了!这是 React Router 中的默认错误屏幕,由于我们在应用程序中对根元素的 flex 盒样式,它变得更糟了 😂。

无论何时您的应用程序在渲染、加载数据或执行数据变异时抛出错误,React Router 都会捕获它并渲染一个错误屏幕。让我们创建自己的错误页面。

👉 创建一个错误页面组件

touch src/error-page.jsx
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>
  );
}

👉 <ErrorPage> 设置为根路由的 errorElement

/* previous imports */
import ErrorPage from "./error-page";

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

ReactDOM.createRoot(document.getElementById("root")).render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>
);

现在错误页面应该看起来像这样

new error page, but still ugly

(好吧,这并没有好多少。也许有人忘记让设计师制作一个错误页面。也许每个人都忘记让设计师制作一个错误页面,然后责怪设计师没有想到它 😆)

请注意,useRouteError 提供了抛出的错误。当用户导航到不存在的路由时,您将获得一个 错误响应,其中包含一个“未找到”的 statusText。我们将在本教程的后面看到一些其他错误,并对其进行更详细的讨论。

现在,知道几乎所有错误都将由此页面处理,而不是无限的加载动画、无响应的页面或空白屏幕,就足够了 🙌

联系路由 UI

我们希望在链接到的 URL 上实际渲染一些内容,而不是 404“未找到”页面。为此,我们需要创建一个新的路由。

👉 创建联系路由模块

touch src/routes/contact.jsx

👉 添加联系组件 UI

它只是一堆元素,您可以随意复制/粘贴。

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

export default function Contact() {
  const contact = {
    first: "Your",
    last: "Name",
    avatar: "https://robohash.org/you.png?size=200x200",
    twitter: "your_handle",
    notes: "Some notes",
    favorite: true,
  };

  return (
    <div id="contact">
      <div>
        <img
          key={contact.avatar}
          src={
            contact.avatar ||
            `https://robohash.org/${contact.id}.png?size=200x200`
          }
        />
      </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 }) {
  const 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>
  );
}

现在我们有了组件,让我们将其连接到一个新的路由。

👉 导入联系组件并创建一个新的路由

/* existing imports */
import Contact from "./routes/contact";

const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,
    errorElement: <ErrorPage />,
  },
  {
    path: "contacts/:contactId",
    element: <Contact />,
  },
]);

/* existing code */

现在,如果我们点击其中一个链接或访问 /contacts/1,我们将获得我们的新组件!

contact route rendering without the parent layout

但是,它不在我们的根布局中 😠

嵌套路由

我们希望联系组件在 <Root> 布局中渲染,如下所示。

我们通过将联系路由设置为根路由的子路由来实现这一点。

👉 将联系路由移动到根路由的子路由中

const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,
    errorElement: <ErrorPage />,
    children: [
      {
        path: "contacts/:contactId",
        element: <Contact />,
      },
    ],
  },
]);

您现在将再次看到根布局,但在右侧有一个空白页面。我们需要告诉根路由在哪里渲染其子路由。我们使用 <Outlet> 来实现这一点。

找到 <div id="detail"> 并将一个出口放在里面

👉 渲染一个 <Outlet>

import { Outlet } from "react-router-dom";

export default function Root() {
  return (
    <>
      {/* all the other elements */}
      <div id="detail">
        <Outlet />
      </div>
    </>
  );
}

客户端路由

您可能已经注意到,当我们点击侧边栏中的链接时,浏览器正在对下一个 URL 执行完整的文档请求,而不是使用 React Router。

客户端路由允许我们的应用程序更新 URL,而无需从服务器请求另一个文档。相反,应用程序可以立即渲染新的 UI。让我们使用 <Link> 来实现它。

👉 将侧边栏中的 <a href> 更改为 <Link to>

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

您可以在浏览器开发者工具中打开网络选项卡,查看它不再请求文档。

加载数据

URL 段、布局和数据通常是耦合在一起的(三倍?)。我们已经在该应用程序中看到了这一点。

URL 段 组件 数据
/ <Root> 联系人列表
contacts/:id <Contact> 单个联系人

由于这种自然的耦合,React Router 具有数据约定,可以轻松地将数据放入您的路由组件中。

我们将使用两个 API 来加载数据,loaderuseLoaderData。首先,我们在根模块中创建并导出一个加载器函数,然后将其连接到路由。最后,我们将访问并渲染数据。

👉 root.jsx 导出加载器

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

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

👉 在路由上配置加载器

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

👉 访问并渲染数据

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

就是这样!React Router 现在将自动将该数据与您的 UI 保持同步。我们还没有任何数据,因此您可能正在获取这样的空白列表。

数据写入 + HTML 表单

我们将在下一秒创建第一个联系人,但首先让我们谈谈 HTML。

React Router 模仿 HTML 表单导航作为数据变异原语,根据 JavaScript 寒武纪大爆发之前的 Web 开发。它为您提供了客户端渲染应用程序的用户体验功能,以及“老式”Web 模型的简单性。

虽然一些 Web 开发人员不熟悉,但 HTML 表单实际上会导致浏览器中的导航,就像单击链接一样。唯一的区别在于请求:链接只能更改 URL,而表单还可以更改请求方法(GET 与 POST)和请求主体(POST 表单数据)。

如果没有客户端路由,浏览器将自动序列化表单的数据,并将其作为 POST 的请求主体发送到服务器,以及作为 GET 的 URLSearchParams。React Router 做同样的事情,只是它不会将请求发送到服务器,而是使用客户端路由并将请求发送到路由 action

我们可以通过单击应用程序中的“新建”按钮来测试这一点。该应用程序应该崩溃,因为 Vite 服务器没有配置为处理 POST 请求(它发送 404,尽管它可能应该是 405 🤷)。

与其将该 POST 发送到 Vite 服务器以创建新的联系人,不如使用客户端路由。

创建联系人

我们将通过在根路由中导出一个 action 来创建新的联系人,将其连接到路由配置,并将我们的 <form> 更改为 React Router <Form>

👉 创建操作并将 <form> 更改为 <Form>

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

👉 导入并在路由上设置操作

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

就是这样!继续单击“新建”按钮,您应该会看到一个新记录弹出到列表中 🥳

createContact 方法只是创建一个没有名称或数据或任何内容的空联系人。但它确实创建了一个记录,承诺!

🧐 等一下... 侧边栏是如何更新的?我们在哪里调用了 action?在哪里编写了重新获取数据的代码?useStateonSubmituseEffect 在哪里?

这就是“老式 Web”编程模型出现的地方。正如我们之前讨论的那样,<Form> 阻止浏览器将请求发送到服务器,而是将其发送到您的路由 action。在 Web 语义中,POST 通常意味着某些数据正在更改。按照惯例,React Router 使用此作为提示,在操作完成后自动重新验证页面上的数据。这意味着您所有的 useLoaderData 钩子都会更新,并且 UI 会自动与您的数据保持同步!太酷了。

加载器中的 URL 参数

👉 单击“无名称”记录

我们应该再次看到我们旧的静态联系人页面,但有一个区别:URL 现在具有记录的真实 ID。

查看路由配置,路由如下所示

[
  {
    path: "contacts/:contactId",
    element: <Contact />,
  },
];

注意 :contactId URL 段。冒号 (:) 具有特殊含义,将其转换为“动态段”。动态段将匹配 URL 中该位置的动态(更改)值,例如联系人 ID。我们将 URL 中的这些值称为“URL 参数”,或简称为“参数”。

这些 params 将传递给加载器,其键与动态段匹配。例如,我们的段名为 :contactId,因此该值将作为 params.contactId 传递。

这些参数最常用于通过 ID 查找记录。让我们试一试。

👉 向联系人页面添加加载器,并使用 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
}

👉 在路由上配置加载器

/* 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 */

更新数据

就像创建数据一样,您使用 <Form> 更新数据。让我们在 contacts/:contactId/edit 处创建一个新路由。同样,我们将从组件开始,然后将其连接到路由配置。

👉 创建编辑组件

touch src/routes/edit.jsx

👉 添加编辑页面 UI

我们之前没有见过任何东西,请随意复制/粘贴

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

👉 添加新的编辑路由

/* 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 */

我们希望它在根路由的出口中呈现,因此我们将其设置为现有子路由的同级。

(您可能会注意到我们为该路由重复使用了 contactLoader。这仅仅是因为我们在教程中偷懒。没有理由尝试在路由之间共享加载器,它们通常有自己的加载器。)

好的,单击“编辑”按钮会给我们这个新的 UI

使用 FormData 更新联系人

我们刚刚创建的编辑路由已经呈现了一个表单。我们所需要做的就是将一个操作连接到路由,以更新记录。该表单将发布到操作,并且数据将自动重新验证。

👉 向编辑模块添加操作

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 */

👉 将操作连接到路由

/* 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 */

填写表单,点击保存,您应该会看到类似的东西!(除了更容易看懂,也许不那么毛茸茸。)

变异讨论

😑 它起作用了,但我不知道这里发生了什么...

让我们深入研究一下...

打开 src/routes/edit.jsx 并查看表单元素。注意它们每个都有一个名称

<input
  placeholder="First"
  aria-label="First name"
  type="text"
  name="first"
  defaultValue={contact.first}
/>

如果没有 JavaScript,当提交表单时,浏览器将创建 FormData 并将其设置为请求主体,当它将其发送到服务器时。如前所述,React Router 阻止了这一点,而是将请求发送到您的操作,包括 FormData

表单中的每个字段都可以使用 formData.get(name) 访问。例如,给定上面的输入字段,您可以像这样访问姓氏和名字

export async function action({ request, params }) {
  const formData = await request.formData();
  const firstName = formData.get("first");
  const lastName = formData.get("last");
  // ...
}

由于我们有几个表单字段,我们使用了 Object.fromEntries 将它们全部收集到一个对象中,这正是我们的 updateContact 函数想要的。

const updates = Object.fromEntries(formData);
updates.first; // "Some"
updates.last; // "Name"

除了 action 之外,我们正在讨论的这些 API 都不由 React Router 提供:requestrequest.formDataObject.fromEntries 都由 Web 平台提供。

在我们完成操作后,请注意最后面的 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}`);
}

加载器和操作都可以 返回一个 Response(说得通,因为它们接收了一个 Request!)。redirect 帮助程序只是使返回一个 response 更容易,该 response 告诉应用程序更改位置。

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

将新记录重定向到编辑页面

现在我们已经知道如何重定向,让我们更新创建新联系人的操作,将其重定向到编辑页面

👉 重定向到新记录的编辑页面

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

现在,当我们点击“新建”时,我们应该会进入编辑页面

👉 添加一些记录

我将使用第一次 Remix 大会上的一系列杰出演讲者 😁

现在我们有了很多记录,但我们无法清楚地知道在侧边栏中查看的是哪一个。我们可以使用 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>
    </>
  );
}

请注意,我们正在将一个函数传递给 className。当用户位于 NavLink 中的 URL 时,isActive 将为真。当它即将处于活动状态(数据仍在加载)时,isPending 将为真。这使我们能够轻松地指示用户的位置,以及提供对已点击但我们仍在等待数据加载的链接的即时反馈。

全局待处理 UI

当用户浏览应用程序时,React Router 会保留旧页面,因为正在加载下一页的数据。您可能已经注意到,当您在列表之间点击时,应用程序感觉有点反应迟钝。让我们为用户提供一些反馈,这样应用程序就不会感觉反应迟钝。

React Router 在幕后管理所有状态,并揭示您构建动态 Web 应用程序所需的片段。在这种情况下,我们将使用 useNavigation 钩子。

👉 useNavigation 添加全局待处理 UI

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 返回当前导航状态:它可以是 "idle" | "submitting" | "loading" 之一。

在我们的例子中,如果我们不处于空闲状态,我们将向应用程序的主要部分添加一个 "loading" 类。然后,CSS 在短暂延迟后添加一个不错的淡入效果(以避免在快速加载时闪烁 UI)。不过,您可以做任何您想做的事情,例如在顶部显示一个旋转器或加载条。

请注意,我们的数据模型 (src/contacts.js) 具有客户端缓存,因此第二次导航到同一个联系人速度很快。这种行为不是 React Router,它将重新加载更改路由的数据,无论您之前是否去过。但是,它确实避免了在导航期间对不变路由(如列表)调用加载器。

删除记录

如果我们查看联系人路由中的代码,我们会发现删除按钮看起来像这样

<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>

请注意 action 指向 "destroy"。与 <Link to> 一样,<Form action> 可以接受一个相对值。由于表单是在 contact/:contactId 中呈现的,因此具有 destroy 的相对操作将在点击时将表单提交到 contact/:contactId/destroy

在这一点上,您应该知道使删除按钮工作所需的一切。也许在继续之前尝试一下?您需要

  1. 一个新的路由
  2. 该路由上的一个 action
  3. 来自 src/contacts.jsdeleteContact

👉 创建“destroy”路由模块

touch src/routes/destroy.jsx

👉 添加 destroy 操作

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 */

好了,导航到一条记录并点击“删除”按钮。它可以工作!

😅 我仍然不明白为什么这一切都能正常工作

当用户点击提交按钮时

  1. <Form> 阻止了浏览器将新的 POST 请求发送到服务器的默认行为,而是通过使用客户端路由创建 POST 请求来模拟浏览器
  2. <Form action="destroy">"contacts/:contactId/destroy" 上的新路由匹配,并将请求发送到它
  3. 在操作重定向后,React Router 会调用页面上所有数据的加载器以获取最新值(这是“重新验证”)。useLoaderData 返回新值并导致组件更新!

添加一个表单,添加一个操作,React Router 会完成剩下的工作。

上下文错误

为了好玩,在 destroy 操作中抛出一个错误

export async function action({ params }) {
  throw new Error("oh dang!");
  await deleteContact(params.contactId);
  return redirect("/");
}

认出那个屏幕了吗?这是我们之前使用的 errorElement。但是,用户除了刷新页面之外,实际上无法做任何事情来从这个屏幕中恢复。

让我们为 destroy 路由创建一个上下文错误消息

[
  /* other routes */
  {
    path: "contacts/:contactId/destroy",
    action: destroyAction,
    errorElement: <div>Oops! There was an error.</div>,
  },
];

现在再试一次

我们的用户现在有了比猛烈刷新更多的选择,他们可以继续与页面中没有问题的部分进行交互 🙌

因为 destroy 路由有自己的 errorElement 并且是根路由的子路由,所以错误将在那里呈现,而不是在根路由中。正如您可能注意到的,这些错误会冒泡到最近的 errorElement。您可以根据需要添加任意数量的错误,只要您在根目录中有一个错误即可。

索引路由

当我们加载应用程序时,您会注意到列表右侧有一个很大的空白页面。

当一个路由有子路由时,并且您位于父路由的路径上,<Outlet> 没有任何内容要呈现,因为没有子路由匹配。您可以将索引路由视为填充该空间的默认子路由。

👉 创建索引路由模块

touch src/routes/index.jsx

👉 填充索引组件的元素

随意复制粘贴,这里没有什么特别的。

export default function Index() {
  return (
    <p id="zero-state">
      This is a demo for React Router.
      <br />
      Check out{" "}
      <a href="https://reactrouter.com.cn">
        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 */
    ],
  },
]);

请注意 { index:true } 而不是 { path: "" }。这告诉路由器在用户位于父路由的精确路径上时匹配并呈现此路由,因此 <Outlet> 中没有其他子路由要呈现。

瞧!没有更多空白。通常将仪表板、统计信息、提要等放在索引路由中。它们也可以参与数据加载。

取消按钮

在编辑页面上,我们有一个取消按钮,它目前什么也不做。我们希望它执行与浏览器后退按钮相同的事情。

我们需要在按钮上添加一个点击处理程序,以及来自 React Router 的 useNavigate

👉 使用 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>
  );
}

现在,当用户点击“取消”时,他们将被发送回浏览器历史记录中的一个条目。

🧐 为什么按钮上没有 event.preventDefault

<button type="button"> 虽然看似多余,但它是 HTML 阻止按钮提交其表单的方式。

还有两个功能要完成。我们已经接近尾声了!

URL 搜索参数和 GET 提交

到目前为止,我们所有交互式 UI 都是更改 URL 的链接或将数据发布到操作的表单。搜索字段很有趣,因为它两者兼而有之:它是一个表单,但它只更改 URL,它不会更改数据。

现在它只是一个普通的 HTML <form>,而不是 React Router <Form>。让我们看看浏览器默认情况下会如何处理它

👉 在搜索字段中输入一个名称,然后按 Enter 键

请注意,浏览器的 URL 现在在 URL 中包含您的查询,作为 URLSearchParams

http://127.0.0.1:5173/?q=ryan

如果我们查看搜索表单,它看起来像这样

<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= 的原因。如果我们将其命名为 search,则 URL 将为 ?search=

请注意,此表单与我们使用过的其他表单不同,它没有 <form method="post">。默认的 method"get"。这意味着当浏览器创建下一个文档的请求时,它不会将表单数据放入请求 POST 主体中,而是放入 GET 请求的 URLSearchParams 中。

使用客户端路由进行 GET 提交

让我们使用客户端路由来提交此表单,并在现有加载器中过滤列表。

👉 <form> 更改为 <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>

👉 如果存在 URLSearchParams,则过滤列表

export async function loader({ request }) {
  const url = new URL(request.url);
  const q = url.searchParams.get("q");
  const contacts = await getContacts(q);
  return { contacts };
}

因为这是一个 GET 请求,而不是 POST 请求,所以 React Router 不会调用 action。提交 GET 表单与点击链接相同:只有 URL 会改变。这就是为什么我们在 loader 中添加了过滤代码,而不是此路由的 action 中。

这也意味着这是一个正常的页面导航。您可以点击后退按钮返回到您之前的位置。

将 URL 与表单状态同步

这里有一些 UX 问题,我们可以快速解决。

  1. 如果您在搜索后点击后退,表单字段仍然保留您输入的值,即使列表不再被过滤。
  2. 如果您在搜索后刷新页面,表单字段将不再保留其值,即使列表被过滤。

换句话说,URL 和我们的表单状态不同步。

👉 从您的加载器中返回 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 */}
    </>
  );
}

这解决了问题 (2)。如果您现在刷新页面,输入字段将显示查询。

现在是问题 (1),点击后退按钮并更新输入。我们可以从 React 中引入 useEffect 来直接操作 DOM 中的表单状态。

👉 将输入值与 URL 搜索参数同步

import { useEffect } from "react";

// existing code

export default function Root() {
  const { contacts, q } = useLoaderData();
  const navigation = useNavigation();

  useEffect(() => {
    document.getElementById("q").value = q;
  }, [q]);

  // existing code
}

🤔 您不应该为此使用受控组件和 React 状态吗?

您当然可以将其作为受控组件来实现,但您最终会为相同的行为付出更多复杂性。您不控制 URL,而是用户使用后退/前进按钮来控制。使用受控组件将有更多同步点。

如果您仍然担心,请展开此部分以查看它将是什么样子

请注意,控制输入现在需要三个同步点,而不是只有一个。行为相同,但代码更复杂。

import { useEffect, useState } from "react";
// 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 };
}

// existing code

export default function Root() {
  const { contacts, q } = useLoaderData();
  const [query, setQuery] = useState(q);
  const navigation = useNavigation();

  useEffect(() => {
    setQuery(q);
  }, [q]);

  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"
              value={query}
              onChange={(e) => {
                setQuery(e.target.value);
              }}
            />
            {/* existing code */}
          </Form>
          {/* existing code */}
        </div>
        {/* existing code */}
      </div>
    </>
  );
}

onChange 提交表单

我们在这里需要做出一个产品决策。对于此 UI,我们可能更希望在每次按键时进行过滤,而不是在显式提交表单时进行过滤。

我们已经看到了 useNavigate,我们将使用它的兄弟,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 */}
    </>
  );
}

现在,当您键入时,表单会自动提交!

请注意传递给 submit 的参数。我们正在传递 event.currentTarget.formcurrentTarget 是事件附加到的 DOM 节点,currentTarget.form 是输入的父表单节点。submit 函数将序列化并提交您传递给它的任何表单。

添加搜索加载动画

在生产应用程序中,此搜索很可能是在数据库中查找记录,而数据库太大,无法一次性发送所有记录并在客户端进行过滤。这就是为什么此演示有一些模拟的网络延迟。

如果没有加载指示器,搜索感觉有点迟钝。即使我们可以让我们的数据库更快,我们仍然会受到用户网络延迟的影响,而且我们无法控制。为了获得更好的 UX,让我们为搜索添加一些即时的 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 */}
    </>
  );
}

当应用程序导航到新 URL 并加载其数据时,navigation.location 将显示。当不再有待处理的导航时,它就会消失。

管理历史记录堆栈

现在,表单在每次按键时都会提交,如果我们键入字符“seba”然后用退格键删除它们,我们最终会在堆栈中得到 7 个新条目 😂。我们绝对不想要这样。

我们可以通过替换历史记录堆栈中的当前条目来避免这种情况,而不是将其推入堆栈。

👉 submit 中使用 replace

// existing code

export default function Root() {
  // existing code

  return (
    <>
      <div id="sidebar">
        <h1>React Router Contacts</h1>
        <div>
          <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>
          {/* existing code */}
        </div>
        {/* existing code */}
      </div>
      {/* existing code */}
    </>
  );
}

我们只希望替换搜索结果,而不是我们开始搜索之前的页面,所以我们快速检查一下这是否是第一次搜索,然后决定是否替换。

每次按键不再创建新条目,因此用户可以点击后退按钮退出搜索结果,而无需点击 7 次 😅。

无需导航的变异

到目前为止,我们所有的变异(我们更改数据的时间)都使用了会导航的表单,在历史记录堆栈中创建新条目。虽然这些用户流程很常见,但同样常见的是想要更改数据而无需进行导航。

对于这些情况,我们有 useFetcher 钩子。它允许我们与加载器和操作进行通信,而不会导致导航。

联系页面上的 ★ 按钮非常适合这种情况。我们不是创建或删除新记录,我们不想更改页面,我们只是想更改我们正在查看的页面上的数据。

👉 <Favorite> 表单更改为 fetcher 表单

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

// existing code

function Favorite({ contact }) {
  const fetcher = useFetcher();
  const 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>
  );
}

当我们在这里的时候,可能需要看一下那个表单。和往常一样,我们的表单具有带有 name 属性的字段。此表单将发送 formData,其中包含一个 favorite 键,其值为 "true" | "false"。由于它具有 method="post",它将调用操作。由于没有 <fetcher.Form action="..."> 属性,它将发布到渲染表单的路由。

👉 创建操作

// existing code
import { getContact, updateContact } from "../contacts";

export async function action({ request, params }) {
  const 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"> 的工作方式几乎与我们一直在使用的 <Form> 相同:它调用操作,然后所有数据都会自动重新验证——即使您的错误也会以相同的方式被捕获。

不过,有一个关键的区别,它不是导航——URL 不会改变,历史记录堆栈不会受到影响。

乐观 UI

您可能已经注意到,当我们点击上一节中的收藏按钮时,应用程序感觉有点没有响应。再次,我们添加了一些网络延迟,因为您将在现实世界中遇到它!

为了给用户一些反馈,我们可以使用 fetcher.state(与之前的 navigation.state 非常相似)将星星置于加载状态,但这次我们可以做得更好。我们可以使用一种称为“乐观 UI”的策略。

fetcher 知道提交到操作的表单数据,因此您可以在 fetcher.formData 上使用它。我们将使用它来立即更新星星的状态,即使网络尚未完成。如果更新最终失败,UI 将恢复到真实数据。

👉 fetcher.formData 读取乐观值

// existing code

function Favorite({ contact }) {
  const fetcher = useFetcher();

  const favorite = fetcher.formData
    ? fetcher.formData.get("favorite") === "true"
    : 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>
  );
}

如果您现在点击按钮,您应该会看到星星立即更改为新状态。我们不是总是渲染实际数据,而是检查 fetcher 是否有任何正在提交的 formData,如果有,我们将使用它。当操作完成时,fetcher.formData 将不再存在,我们将恢复使用实际数据。因此,即使您在乐观 UI 代码中编写了错误,它最终也会恢复到正确状态 🥹

未找到数据

如果我们尝试加载的联系人不存在会发生什么?

我们的根 errorElement 在我们尝试渲染一个 null 联系人时捕获了这个意外错误。很好,错误被正确处理了,但我们可以做得更好!

每当您在加载器或操作中遇到预期的错误情况(例如数据不存在)时,您可以 throw。调用堆栈将中断,React Router 将捕获它,并且将渲染错误路径。我们甚至不会尝试渲染一个 null 联系人。

👉 在加载器中抛出一个 404 响应

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

我们避免了完全渲染组件,而是渲染了错误路径,并告诉用户更具体的信息,而不是遇到 Cannot read properties of null 的渲染错误。

这使您的快乐路径保持快乐。您的路由元素不需要关心错误和加载状态。

无路径路由

最后一点。我们看到的最后一个错误页面,如果它在根出口渲染,而不是整个页面,会更好。事实上,我们所有子路由中的所有错误,在出口渲染都会更好,这样用户比刷新页面有更多选择。

我们希望它看起来像这样

我们可以将错误元素添加到每个子路由中,但由于它们都是同一个错误页面,所以不建议这样做。

有一个更干净的方法。路由可以在没有路径的情况下使用,这使它们能够参与 UI 布局,而无需在 URL 中添加新的路径段。看看吧

👉 将子路由包装在无路径路由中

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

当子路由中抛出任何错误时,我们新的无路径路由将捕获它并进行渲染,从而保留根路由的 UI!

JSX 路由

最后,许多人更喜欢使用 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>
  )
);

就是这样!感谢您尝试 React Router。我们希望本教程能为您构建出色的用户体验提供坚实的基础。React Router 还有很多功能,所以请务必查看所有 API 😀