地址簿
本页内容

地址簿

我们将构建一个小的但功能丰富的地址簿应用程序,让您跟踪您的联系人。这里没有数据库或其他“生产就绪”的东西,因此我们可以专注于 React Router 为您提供的功能。如果您跟随操作,我们预计需要 30-45 分钟,否则只需快速阅读即可。

如果您喜欢观看视频,也可以观看我们的 React Router 教程演练 🎥

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

其余内容仅供您参考和更深入的理解。让我们开始吧。

设置

👉 生成基本模板

npx create-react-router@latest --template remix-run/react-router/tutorials/address-book

这使用了一个非常基础的模板,但包含了我们的 CSS 和数据模型,因此我们可以专注于 React Router。

👉 启动应用程序

# cd into the app directory
cd {wherever you put the app}

# install dependencies if you haven't already
npm install

# start the server
npm run dev

您应该能够打开 https://127.0.0.1:5173 并看到一个看起来像这样的无样式屏幕

根路由

请注意 app/root.tsx 文件。这就是我们所说的“根路由”。它是 UI 中渲染的第一个组件,因此它通常包含页面的全局布局,以及默认的 错误边界

在此处展开以查看根组件代码
import {
  Form,
  Scripts,
  ScrollRestoration,
  isRouteErrorResponse,
} from "react-router";
import type { Route } from "./+types/root";

import appStylesHref from "./app.css?url";

export default function App() {
  return (
    <>
      <div id="sidebar">
        <h1>React Router Contacts</h1>
        <div>
          <Form id="search-form" role="search">
            <input
              aria-label="Search contacts"
              id="q"
              name="q"
              placeholder="Search"
              type="search"
            />
            <div
              aria-hidden
              hidden={true}
              id="search-spinner"
            />
          </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>
    </>
  );
}

// The Layout component is a special export for the root route.
// It acts as your document's "app shell" for all route components, HydrateFallback, and ErrorBoundary
// For more information, see https://reactrouter.com.cn/explanation/special-files#layout-export
export function Layout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta
          name="viewport"
          content="width=device-width, initial-scale=1"
        />
        <link rel="stylesheet" href={appStylesHref} />
      </head>
      <body>
        {children}
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

// The top most error boundary for the app, rendered when your app throws an error
// For more information, see https://reactrouter.com.cn/start/framework/route-module#errorboundary
export function ErrorBoundary({
  error,
}: Route.ErrorBoundaryProps) {
  let message = "Oops!";
  let details = "An unexpected error occurred.";
  let stack: string | undefined;

  if (isRouteErrorResponse(error)) {
    message = error.status === 404 ? "404" : "Error";
    details =
      error.status === 404
        ? "The requested page could not be found."
        : error.statusText || details;
  } else if (
    import.meta.env.DEV &&
    error &&
    error instanceof Error
  ) {
    details = error.message;
    stack = error.stack;
  }

  return (
    <main id="error-page">
      <h1>{message}</h1>
      <p>{details}</p>
      {stack && (
        <pre>
          <code>{stack}</code>
        </pre>
      )}
    </main>
  );
}

联系人路由 UI

如果您单击侧边栏项目之一,您将获得默认的 404 页面。让我们创建一个与 URL /contacts/1 匹配的路由。

👉 创建联系人路由模块

mkdir app/routes
touch app/routes/contact.tsx

我们可以将此文件放在我们想要的任何位置,但为了使事情更有条理,我们将把所有路由放在 app/routes 目录中。

如果您愿意,也可以使用基于文件的路由

👉 配置路由

我们需要告诉 React Router 关于我们的新路由。routes.ts 是一个特殊文件,我们可以在其中配置所有路由。

import type { RouteConfig } from "@react-router/dev/routes";
import { route } from "@react-router/dev/routes";

export default [
  route("contacts/:contactId", "routes/contact.tsx"),
] satisfies RouteConfig;

在 React Router 中,: 使段变为动态。我们刚刚使以下 URL 与 routes/contact.tsx 路由模块匹配

  • /contacts/123
  • /contacts/abc

👉 添加联系人组件 UI

这只是一堆元素,请随意复制/粘贴。

import { Form } from "react-router";

import type { ContactRecord } from "../data";

export default function Contact() {
  const contact = {
    first: "Your",
    last: "Name",
    avatar: "https://placecats.com/200/200",
    twitter: "your_handle",
    notes: "Some notes",
    favorite: true,
  };

  return (
    <div id="contact">
      <div>
        <img
          alt={`${contact.first} ${contact.last} avatar`}
          key={contact.avatar}
          src={contact.avatar}
        />
      </div>

      <div>
        <h1>
          {contact.first || contact.last ? (
            <>
              {contact.first} {contact.last}
            </>
          ) : (
            <i>No Name</i>
          )}
          <Favorite contact={contact} />
        </h1>

        {contact.twitter ? (
          <p>
            <a
              href={`https://twitter.com/${contact.twitter}`}
            >
              {contact.twitter}
            </a>
          </p>
        ) : null}

        {contact.notes ? <p>{contact.notes}</p> : null}

        <div>
          <Form action="edit">
            <button type="submit">Edit</button>
          </Form>

          <Form
            action="destroy"
            method="post"
            onSubmit={(event) => {
              const response = confirm(
                "Please confirm you want to delete this record."
              );
              if (!response) {
                event.preventDefault();
              }
            }}
          >
            <button type="submit">Delete</button>
          </Form>
        </div>
      </div>
    </div>
  );
}

function Favorite({
  contact,
}: {
  contact: Pick<ContactRecord, "favorite">;
}) {
  const favorite = contact.favorite;

  return (
    <Form method="post">
      <button
        aria-label={
          favorite
            ? "Remove from favorites"
            : "Add to favorites"
        }
        name="favorite"
        value={favorite ? "false" : "true"}
      >
        {favorite ? "" : ""}
      </button>
    </Form>
  );
}

现在,如果我们单击其中一个链接或访问 /contacts/1,我们会得到... 没有新的内容?

嵌套路由和出口

React Router 支持嵌套路由。为了使子路由在父布局中渲染,我们需要在父组件中渲染一个 Outlet。让我们修复它,打开 app/root.tsx 并在其中渲染一个出口。

👉 渲染 <Outlet />

import {
  Form,
  Outlet,
  Scripts,
  ScrollRestoration,
  isRouteErrorResponse,
} from "react-router";

// existing imports & exports

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

现在子路由应该通过出口进行渲染。

客户端路由

您可能已经注意到,也可能没有注意到,但是当我们单击侧边栏中的链接时,浏览器正在为下一个 URL 执行完整的文档请求,而不是客户端路由,这会完全重新挂载我们的应用程序

客户端路由允许我们的应用程序更新 URL 而无需重新加载整个页面。相反,应用程序可以立即渲染新的 UI。让我们使用 <Link> 来实现它。

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

import {
  Form,
  Link,
  Outlet,
  Scripts,
  ScrollRestoration,
  isRouteErrorResponse,
} from "react-router";

// existing imports & exports

export default function App() {
  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>
      </div>
      {/* other elements */}
    </>
  );
}

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

加载数据

URL 段、布局和数据通常(三重?)耦合在一起。我们已经可以在此应用程序中看到它

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

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

首先,我们将在根路由中创建并导出一个 clientLoader 函数,然后渲染数据。

👉 app/root.tsx 导出 clientLoader 函数并渲染数据

以下代码中存在类型错误,我们将在下一节中修复它

// existing imports
import { getContacts } from "./data";

// existing exports

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

export default function App({ loaderData }) {
  const { contacts } = loaderData;

  return (
    <>
      <div id="sidebar">
        {/* other elements */}
        <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>
                    ) : null}
                  </Link>
                </li>
              ))}
            </ul>
          ) : (
            <p>
              <i>No contacts</i>
            </p>
          )}
        </nav>
      </div>
      {/* other elements */}
    </>
  );
}

就是这样!React Router 现在将自动使该数据与您的 UI 保持同步。侧边栏现在应该看起来像这样

您可能想知道为什么我们“客户端”加载数据而不是在服务器上加载数据以便我们可以进行服务器端渲染 (SSR)。目前,我们的联系人站点是一个 单页应用程序,因此没有服务器端渲染。这使得它很容易部署到任何静态托管提供商,但我们将稍后详细讨论如何启用 SSR,以便您可以了解 React Router 提供的所有不同的渲染策略

类型安全

您可能已经注意到我们没有为 loaderData 属性分配类型。让我们修复它。

👉 ComponentProps 类型添加到 App 组件

// existing imports
import type { Route } from "./+types/root";
// existing imports & exports

export default function App({
  loaderData,
}: Route.ComponentProps) {
  const { contacts } = loaderData;

  // existing code
}

等等,什么?这些类型从哪里来的?!

我们没有定义它们,但它们不知何故已经知道我们从 clientLoader 返回的 contacts 属性。

那是因为 React Router 为应用程序中的每个路由生成类型,以提供自动类型安全。

添加 HydrateFallback

我们之前提到过,我们正在开发一个没有服务器端渲染的单页应用程序。如果您查看 react-router.config.ts 内部,您会看到这是使用一个简单的布尔值配置的

import { type Config } from "@react-router/dev/config";

export default {
  ssr: false,
} satisfies Config;

您可能已经开始注意到,每当您刷新页面时,在应用程序加载之前都会出现短暂的白屏。由于我们仅在客户端上渲染,因此在应用程序加载时,没有任何内容可以向用户显示。

👉 添加 HydrateFallback 导出

我们可以提供一个回退,它将在应用程序水合(首次在客户端上渲染)之前显示,通过 HydrateFallback 导出。

// existing imports & exports

export function HydrateFallback() {
  return (
    <div id="loading-splash">
      <div id="loading-splash-spinner" />
      <p>Loading, please wait...</p>
    </div>
  );
}

现在,如果您刷新页面,您将短暂地看到加载闪屏,然后应用程序才会水合。

索引路由

当您加载应用程序并且尚未进入联系人页面时,您会注意到列表右侧有一个很大的空白页面。

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

👉 为根路由创建索引路由

touch app/routes/home.tsx
import type { RouteConfig } from "@react-router/dev/routes";
import { index, route } from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  route("contacts/:contactId", "routes/contact.tsx"),
] satisfies RouteConfig;

👉 填充索引组件的元素

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

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

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

添加关于路由

在我们继续处理用户可以与之交互的动态数据之前,让我们添加一个页面,其中包含我们期望很少更改的静态内容。关于页面将非常适合此目的。

👉 创建关于路由

touch app/routes/about.tsx

不要忘记将路由添加到 app/routes.ts

export default [
  index("routes/home.tsx"),
  route("contacts/:contactId", "routes/contact.tsx"),
  route("about", "routes/about.tsx"),
] satisfies RouteConfig;

👉 添加关于页面 UI

这里没什么特别的,只需复制并粘贴

import { Link } from "react-router";

export default function About() {
  return (
    <div id="about">
      <Link to="/">← Go to demo</Link>
      <h1>About React Router Contacts</h1>

      <div>
        <p>
          This is a demo application showing off some of the
          powerful features of React Router, including
          dynamic routing, nested routes, loaders, actions,
          and more.
        </p>

        <h2>Features</h2>
        <p>
          Explore the demo to see how React Router handles:
        </p>
        <ul>
          <li>
            Data loading and mutations with loaders and
            actions
          </li>
          <li>
            Nested routing with parent/child relationships
          </li>
          <li>URL-based routing with dynamic segments</li>
          <li>Pending and optimistic UI</li>
        </ul>

        <h2>Learn More</h2>
        <p>
          Check out the official documentation at{" "}
          <a href="https://reactrouter.com.cn">
            reactrouter.com
          </a>{" "}
          to learn more about building great web
          applications with React Router.
        </p>
      </div>
    </div>
  );
}

👉 在侧边栏中添加指向关于页面的链接

export default function App() {
  return (
    <>
      <div id="sidebar">
        <h1>
          <Link to="about">React Router Contacts</Link>
        </h1>
        {/* other elements */}
      </div>
      {/* other elements */}
    </>
  );
}

现在导航到 关于页面,它应该看起来像这样

布局路由

我们实际上不希望关于页面嵌套在侧边栏布局中。让我们将侧边栏移动到布局,以便我们可以避免在关于页面上渲染它。此外,我们希望避免在关于页面上加载所有联系人数据。

👉 为侧边栏创建布局路由

您可以随意命名和放置此布局路由,但将其放在 layouts 目录中将有助于使我们简单的应用程序保持井井有条。

mkdir app/layouts
touch app/layouts/sidebar.tsx

现在只需返回一个 <Outlet>

import { Outlet } from "react-router";

export default function SidebarLayout() {
  return <Outlet />;
}

👉 在侧边栏布局下移动路由定义

我们可以定义一个 layout 路由,以自动为其中所有匹配的路由渲染侧边栏。这基本上就是我们的 root 曾经是的样子,但现在我们可以将其范围限定为特定路由。

import type { RouteConfig } from "@react-router/dev/routes";
import {
  index,
  layout,
  route,
} from "@react-router/dev/routes";

export default [
  layout("layouts/sidebar.tsx", [
    index("routes/home.tsx"),
    route("contacts/:contactId", "routes/contact.tsx"),
  ]),
  route("about", "routes/about.tsx"),
] satisfies RouteConfig;

👉 将布局和数据获取移动到侧边栏布局

我们想将 clientLoaderApp 组件内的所有内容移动到侧边栏布局。它应该看起来像这样

import { Form, Link, Outlet } from "react-router";
import { getContacts } from "../data";
import type { Route } from "./+types/sidebar";

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

export default function SidebarLayout({
  loaderData,
}: Route.ComponentProps) {
  const { contacts } = loaderData;

  return (
    <>
      <div id="sidebar">
        <h1>
          <Link to="about">React Router Contacts</Link>
        </h1>
        <div>
          <Form id="search-form" role="search">
            <input
              aria-label="Search contacts"
              id="q"
              name="q"
              placeholder="Search"
              type="search"
            />
            <div
              aria-hidden
              hidden={true}
              id="search-spinner"
            />
          </Form>
          <Form method="post">
            <button type="submit">New</button>
          </Form>
        </div>
        <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>
                    ) : null}
                  </Link>
                </li>
              ))}
            </ul>
          ) : (
            <p>
              <i>No contacts</i>
            </p>
          )}
        </nav>
      </div>
      <div id="detail">
        <Outlet />
      </div>
    </>
  );
}

app/root.tsx 内部,App 应该只返回一个 <Outlet>,并且可以删除所有未使用的导入。确保 root.tsx 中没有 clientLoader

// existing imports and exports

export default function App() {
  return <Outlet />;
}

现在,完成此改组后,我们的关于页面不再加载联系人数据,也不再嵌套在侧边栏布局中

预渲染静态路由

如果您刷新关于页面,您仍然会在页面在客户端上渲染之前看到加载微标,仅持续一瞬间。这确实不是一个好的体验,而且该页面只是静态信息,我们应该能够在构建时将其预渲染为静态 HTML。

👉 预渲染关于页面

react-router.config.ts 内部,我们可以向配置添加一个 prerender 数组,以告知 React Router 在构建时预渲染某些 URL。在这种情况下,我们只想预渲染关于页面。

import { type Config } from "@react-router/dev/config";

export default {
  ssr: false,
  prerender: ["/about"],
} satisfies Config;

现在,如果您转到 关于页面 并刷新,您将看不到加载微标!

如果您在刷新时仍然看到微标,请确保您已删除 root.tsx 中的 clientLoader

服务器端渲染

React Router 是构建单页应用程序的绝佳框架。许多应用程序可以通过仅客户端渲染以及可能在构建时静态预渲染几个页面来很好地服务。

如果您确实想将服务器端渲染引入到您的 React Router 应用程序中,那非常容易(还记得早期的 ssr: false 布尔值吗?)。

👉 启用服务器端渲染

export default {
  ssr: true,
  prerender: ["/about"],
} satisfies Config;

现在... 没有任何变化?在页面在客户端上渲染之前,我们仍然会看到加载微标,持续一瞬间?另外,我们不是在使用 clientLoader 吗,所以我们的数据仍然是在客户端上获取的?

没错!使用 React Router,您仍然可以使用 clientLoader(和 clientAction)在您认为合适的地方进行客户端数据获取。React Router 为您提供了很大的灵活性,可以使用适合工作的工具。

让我们切换到使用 loader,它(您猜对了)用于在服务器上获取数据。

👉 切换到使用 loader 获取数据

// existing imports

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

是否将 ssr 设置为 truefalse 取决于您和您的用户的需求。这两种策略都是完全有效的。在本教程的其余部分中,我们将使用服务器端渲染,但请注意,所有渲染策略在 React Router 中都是一等公民。

加载器中的 URL 参数

👉 单击其中一个侧边栏链接

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

还记得 app/routes.ts 中路由定义的 :contactId 部分吗?这些动态段将匹配 URL 该位置中的动态(更改)值。我们将 URL 中的这些值称为“URL 参数”,或简称“参数”。

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

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

👉 向联系人页面添加 loader 函数,并使用 loaderData 访问数据

以下代码中存在类型错误,我们将在下一节中修复它们

// existing imports
import { getContact } from "../data";
import type { Route } from "./+types/contact";

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

export default function Contact({
  loaderData,
}: Route.ComponentProps) {
  const { contact } = loaderData;

  // existing code
}

// existing code

抛出响应

您会注意到 loaderData.contact 的类型是 ContactRecord | null。根据我们的自动类型安全,TypeScript 已经知道 params.contactId 是一个字符串,但我们没有做任何事情来确保它是一个有效的 ID。由于联系人可能不存在,getContact 可能会返回 null,这就是我们出现类型错误的原因。

我们可以在组件代码中考虑找不到联系人的可能性,但更符合 Web 规范的做法是发送正确的 404 错误。我们可以在加载器中执行此操作,并一次性解决我们所有的问题。

// existing imports

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

// existing code

现在,如果找不到用户,则此路径上的代码执行将停止,并且 React Router 将渲染错误路径。React Router 中的组件可以仅专注于快乐路径 😁

数据变更

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

React Router 模拟 HTML 表单导航作为数据变更原语,这曾经是 JavaScript 寒武纪大爆发之前的唯一方法。不要被简单性所迷惑!React Router 中的表单为您提供了客户端渲染应用程序的 UX 功能,以及“老式”Web 模型的简单性。

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

如果没有客户端路由,浏览器将自动序列化 form 的数据,并将其作为 POST 的请求正文发送到服务器,并作为 GETURLSearchParams 发送。React Router 执行相同的操作,只是它没有将请求发送到服务器,而是使用客户端路由并将其发送到路由的 action 函数。

我们可以通过单击应用程序中的“新建”按钮来测试这一点。

React Router 发送 405 错误,因为服务器上没有代码来处理此表单导航。

创建联系人

我们将通过在根路由中导出一个 action 函数来创建新联系人。当用户单击“新建”按钮时,表单将 POST 到根路由操作。

👉 app/root.tsx 导出 action 函数

// existing imports

import { createEmptyContact } from "./data";

export async function action() {
  const contact = await createEmptyContact();
  return { contact };
}

// existing code

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

createEmptyContact 方法只是创建一个没有名称或数据或任何内容的空联系人。但它仍然会创建一个记录,我保证!

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

这就是“老式 Web”编程模型出现的地方。<Form> 阻止浏览器将请求发送到服务器,而是使用 fetch 将其发送到路由的 action 函数。

在 Web 语义中,POST 通常意味着某些数据正在更改。按照惯例,React Router 使用此作为提示,在 action 完成后自动重新验证页面上的数据。

实际上,由于这一切都只是 HTML 和 HTTP,你可以禁用 JavaScript,整个东西仍然可以工作。浏览器将序列化表单并发出文档请求,而不是 React Router 序列化表单并向你的服务器发出 fetch 请求。然后 React Router 将在服务器端渲染页面并将其发送下来。最终的用户界面无论哪种方式都是相同的。

不过,我们将保留 JavaScript,因为我们将提供比旋转网站图标和静态文档更好的用户体验。

更新数据

让我们添加一种方法来填写我们新记录的信息。

就像创建数据一样,你可以使用 <Form> 更新数据。让我们在 app/routes/edit-contact.tsx 内部创建一个新的路由模块。

👉 创建编辑联系人路由

touch app/routes/edit-contact.tsx

不要忘记将路由添加到 app/routes.ts

export default [
  layout("layouts/sidebar.tsx", [
    index("routes/home.tsx"),
    route("contacts/:contactId", "routes/contact.tsx"),
    route(
      "contacts/:contactId/edit",
      "routes/edit-contact.tsx"
    ),
  ]),
  route("about", "routes/about.tsx"),
] satisfies RouteConfig;

👉 添加编辑页面 UI

我们之前已经见过这些,随意复制/粘贴

import { Form } from "react-router";
import type { Route } from "./+types/edit-contact";

import { getContact } from "../data";

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

export default function EditContact({
  loaderData,
}: Route.ComponentProps) {
  const { contact } = loaderData;

  return (
    <Form key={contact.id} id="contact-form" method="post">
      <p>
        <span>Name</span>
        <input
          aria-label="First name"
          defaultValue={contact.first}
          name="first"
          placeholder="First"
          type="text"
        />
        <input
          aria-label="Last name"
          defaultValue={contact.last}
          name="last"
          placeholder="Last"
          type="text"
        />
      </p>
      <label>
        <span>Twitter</span>
        <input
          defaultValue={contact.twitter}
          name="twitter"
          placeholder="@jack"
          type="text"
        />
      </label>
      <label>
        <span>Avatar URL</span>
        <input
          aria-label="Avatar URL"
          defaultValue={contact.avatar}
          name="avatar"
          placeholder="https://example.com/avatar.jpg"
          type="text"
        />
      </label>
      <label>
        <span>Notes</span>
        <textarea
          defaultValue={contact.notes}
          name="notes"
          rows={6}
        />
      </label>
      <p>
        <button type="submit">Save</button>
        <button type="button">Cancel</button>
      </p>
    </Form>
  );
}

现在单击你的新记录,然后单击“编辑”按钮。我们应该看到新路由。

使用 FormData 更新联系人

我们刚刚创建的编辑路由已经渲染了一个 form。我们只需要添加 action 函数。React Router 将序列化 form,使用 fetch 进行 POST 请求,并自动重新验证所有数据。

👉 向编辑路由添加 action 函数

import { Form, redirect } from "react-router";
// existing imports

import { getContact, updateContact } from "../data";

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

// existing code

填写表单,点击保存,你应该看到类似这样的内容! (除了更美观,并且可能有耐心切西瓜。)

Mutation 讨论

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

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

打开 app/routes/edit-contact.tsx 并查看 form 元素。请注意它们每个都有一个名称

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

在没有 JavaScript 的情况下,当表单提交时,浏览器将创建 FormData,并在将其发送到服务器时将其设置为请求的主体。如前所述,React Router 阻止了这种情况,并通过使用 fetch 将请求发送到你的 action 函数来模拟浏览器,包括 FormData

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

export const action = async ({
  params,
  request,
}: ActionFunctionArgs) => {
  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 平台提供的。

在我们完成 action 后,请注意末尾的 redirect

export async function action({
  params,
  request,
}: Route.ActionArgs) {
  invariant(params.contactId, "Missing contactId param");
  const formData = await request.formData();
  const updates = Object.fromEntries(formData);
  await updateContact(params.contactId, updates);
  return redirect(`/contacts/${params.contactId}`);
}

actionloader 函数都可以返回 Response(这是有道理的,因为它们接收到一个 Request!)。redirect 助手只是使返回一个 Response 变得更容易,该 Response 告诉应用程序更改位置。

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

最后一件事。在没有 JavaScript 的情况下,redirect 将是正常的重定向。但是,使用 JavaScript,它是客户端重定向,因此用户不会丢失客户端状态,例如滚动位置或组件状态。

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

既然我们知道如何重定向,让我们更新创建新联系人的 action,以重定向到编辑页面

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

import {
  Outlet,
  Scripts,
  ScrollRestoration,
  isRouteErrorResponse,
  redirect,
} from "react-router";
// existing imports

export async function action() {
  const contact = await createEmptyContact();
  return redirect(`/contacts/${contact.id}/edit`);
}

// existing code

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

现在我们有很多记录,但侧边栏中不清楚我们正在查看哪个记录。我们可以使用 NavLink 来解决这个问题。

👉 在侧边栏中将 <Link> 替换为 <NavLink>

import { Form, Link, NavLink, Outlet } from "react-router";

// existing imports and exports

export default function SidebarLayout({
  loaderData,
}: Route.ComponentProps) {
  const { contacts } = loaderData;

  return (
    <>
      <div id="sidebar">
        {/* existing elements */}
        <ul>
          {contacts.map((contact) => (
            <li key={contact.id}>
              <NavLink
                className={({ isActive, isPending }) =>
                  isActive
                    ? "active"
                    : isPending
                    ? "pending"
                    : ""
                }
                to={`contacts/${contact.id}`}
              >
                {/* existing elements */}
              </NavLink>
            </li>
          ))}
        </ul>
        {/* existing elements */}
      </div>
      {/* existing elements */}
    </>
  );
}

请注意,我们正在将一个函数传递给 className。当用户位于与 <NavLink to> 匹配的 URL 时,isActive 将为 true。当它即将变为活动状态(数据仍在加载)时,isPending 将为 true。这使我们能够轻松指示用户所在的位置,并在单击链接但需要加载数据时提供即时反馈。

全局等待 UI

当用户在应用程序中导航时,React Router 将在为下一个页面加载数据时保留旧页面。你可能已经注意到,当你单击列表之间时,应用程序感觉有点无响应。让我们为用户提供一些反馈,以便应用程序感觉不那么无响应。

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

👉 使用 useNavigation 添加全局等待 UI

import {
  Form,
  Link,
  NavLink,
  Outlet,
  useNavigation,
} from "react-router";

export default function SidebarLayout({
  loaderData,
}: Route.ComponentProps) {
  const { contacts } = loaderData;
  const navigation = useNavigation();

  return (
    <>
      {/* existing elements */}
      <div
        className={
          navigation.state === "loading" ? "loading" : ""
        }
        id="detail"
      >
        <Outlet />
      </div>
    </>
  );
}

useNavigation 返回当前导航状态:它可以是 "idle""loading""submitting" 之一。

在我们的例子中,如果不是空闲状态,我们向应用程序的主要部分添加一个 "loading" 类。然后 CSS 在短暂延迟后添加一个漂亮的淡入淡出效果(以避免快速加载时 UI 闪烁)。你可以做任何你想做的事情,例如在顶部显示一个微调器或加载栏。

删除记录

如果我们查看联系人路由中的代码,我们可以找到如下所示的删除按钮

<Form
  action="destroy"
  method="post"
  onSubmit={(event) => {
    const response = confirm(
      "Please confirm you want to delete this record."
    );
    if (!response) {
      event.preventDefault();
    }
  }}
>
  <button type="submit">Delete</button>
</Form>

请注意,action 指向 "destroy"。与 <Link to> 类似,<Form action> 可以采用相对值。由于表单是在路由 contacts/:contactId 中渲染的,因此当单击时,带有 destroy 的相对 action 会将表单提交到 contacts/:contactId/destroy

此时,你应该了解使删除按钮工作所需的一切知识。也许在继续之前尝试一下?你将需要

  1. 一个新路由
  2. 该路由上的一个 action
  3. 来自 app/data.tsdeleteContact
  4. redirect 到某个地方之后

👉 配置“destroy”路由模块

touch app/routes/destroy-contact.tsx
export default [
  // existing routes
  route(
    "contacts/:contactId/destroy",
    "routes/destroy-contact.tsx"
  ),
  // existing routes
] satisfies RouteConfig;

👉 添加 destroy action

import { redirect } from "react-router";
import type { Route } from "./+types/destroy-contact";

import { deleteContact } from "../data";

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

好的,导航到一个记录并单击“删除”按钮。它工作了!

😅 我仍然困惑为什么这一切都有效

当用户单击提交按钮时

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

添加一个 Form,添加一个 action,React Router 完成剩下的工作。

取消按钮

在编辑页面上,我们有一个取消按钮,但它目前还没有任何作用。我们希望它执行与浏览器的后退按钮相同的功能。

我们需要按钮上的单击处理程序以及 useNavigate

👉 使用 useNavigate 添加取消按钮单击处理程序

import { Form, redirect, useNavigate } from "react-router";
// existing imports & exports

export default function EditContact({
  loaderData,
}: Route.ComponentProps) {
  const { contact } = loaderData;
  const navigate = useNavigate();

  return (
    <Form key={contact.id} id="contact-form" method="post">
      {/* existing elements */}
      <p>
        <button type="submit">Save</button>
        <button onClick={() => navigate(-1)} type="button">
          Cancel
        </button>
      </p>
    </Form>
  );
}

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

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

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

还有两个功能要介绍。我们即将到达终点线!

URLSearchParamsGET 提交

到目前为止,我们所有的交互式 UI 都是更改 URL 的链接或将数据发布到 action 函数的 form。搜索字段很有趣,因为它混合了两者:它是一个 form,但它只更改 URL,而不更改数据。

让我们看看当我们提交搜索表单时会发生什么

👉 在搜索字段中键入名称并按 Enter 键

请注意,浏览器的 URL 现在在 URL 中包含你的查询,形式为 URLSearchParams

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

由于它不是 <Form method="post">,React Router 通过将 FormData 序列化为 URLSearchParams 而不是请求主体来模拟浏览器。

loader 函数可以访问来自 request 的搜索参数。让我们使用它来过滤列表

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

// existing imports & exports

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

// existing code

因为这是一个 GET,而不是 POST,所以 React Router 不会调用 action 函数。提交 GET form 与单击链接相同:只有 URL 发生变化。

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

将 URL 同步到表单状态

这里有一些用户体验问题,我们可以快速解决。

  1. 如果你在搜索后单击后退按钮,即使列表不再被过滤,表单字段仍然具有你输入的值。
  2. 如果你在搜索后刷新页面,即使列表被过滤,表单字段也不再包含该值

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

让我们首先解决(2),并使用 URL 中的值启动输入。

👉 从你的 loader 返回 q,将其设置为输入的默认值

// existing imports & exports

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

export default function SidebarLayout({
  loaderData,
}: Route.ComponentProps) {
  const { contacts, q } = loaderData;
  const navigation = useNavigation();

  return (
    <>
      <div id="sidebar">
        {/* existing elements */}
        <div>
          <Form id="search-form" role="search">
            <input
              aria-label="Search contacts"
              defaultValue={q || ""}
              id="q"
              name="q"
              placeholder="Search"
              type="search"
            />
            {/* existing elements */}
          </Form>
          {/* existing elements */}
        </div>
        {/* existing elements */}
      </div>
      {/* existing elements */}
    </>
  );
}

如果你在搜索后刷新页面,输入字段现在将显示查询。

现在解决问题(1),单击后退按钮并更新输入。我们可以从 React 引入 useEffect 以直接操作 DOM 中输入的值。

👉 将输入值与 URLSearchParams 同步

// existing imports
import { useEffect } from "react";

// existing imports & exports

export default function SidebarLayout({
  loaderData,
}: Route.ComponentProps) {
  const { contacts, q } = loaderData;
  const navigation = useNavigation();

  useEffect(() => {
    const searchField = document.getElementById("q");
    if (searchField instanceof HTMLInputElement) {
      searchField.value = q || "";
    }
  }, [q]);

  // existing code
}

🤔 你不应该为此使用受控组件和 React State 吗?

你当然可以将其作为受控组件来完成。你将有更多的同步点,但这取决于你。

展开以查看它的外观
// existing imports
import { useEffect, useState } from "react";

// existing imports & exports

export default function SidebarLayout({
  loaderData,
}: Route.ComponentProps) {
  const { contacts, q } = loaderData;
  const navigation = useNavigation();
  // the query now needs to be kept in state
  const [query, setQuery] = useState(q || "");

  // we still have a `useEffect` to synchronize the query
  // to the component state on back/forward button clicks
  useEffect(() => {
    setQuery(q || "");
  }, [q]);

  return (
    <>
      <div id="sidebar">
        {/* existing elements */}
        <div>
          <Form id="search-form" role="search">
            <input
              aria-label="Search contacts"
              id="q"
              name="q"
              // synchronize user's input to component state
              onChange={(event) =>
                setQuery(event.currentTarget.value)
              }
              placeholder="Search"
              type="search"
              // switched to `value` from `defaultValue`
              value={query}
            />
            {/* existing elements */}
          </Form>
          {/* existing elements */}
        </div>
        {/* existing elements */}
      </div>
      {/* existing elements */}
    </>
  );
}

好的,你现在应该能够单击后退/前进/刷新按钮,并且输入的值应与 URL 和结果同步。

提交 FormonChange

我们在这里需要做一个产品决定。有时你希望用户提交 form 来过滤一些结果,有时你希望在用户键入时进行过滤。我们已经实现了第一个,所以让我们看看第二个是什么样的。

我们已经看到了 useNavigate,我们将使用它的表亲 useSubmit 来实现此目的。

import {
  Form,
  Link,
  NavLink,
  Outlet,
  useNavigation,
  useSubmit,
} from "react-router";
// existing imports & exports

export default function SidebarLayout({
  loaderData,
}: Route.ComponentProps) {
  const { contacts, q } = loaderData;
  const navigation = useNavigation();
  const submit = useSubmit();

  // existing code

  return (
    <>
      <div id="sidebar">
        {/* existing elements */}
        <div>
          <Form
            id="search-form"
            onChange={(event) =>
              submit(event.currentTarget)
            }
            role="search"
          >
            {/* existing elements */}
          </Form>
          {/* existing elements */}
        </div>
        {/* existing elements */}
      </div>
      {/* existing elements */}
    </>
  );
}

在你键入时,form 现在会自动提交!

请注意 submit 的参数。submit 函数将序列化并提交你传递给它的任何表单。我们正在传递 event.currentTargetcurrentTarget 是事件附加到的 DOM 节点(form)。

添加搜索微调器

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

在没有任何加载指示器的情况下,搜索感觉有点迟缓。即使我们可以使我们的数据库更快,我们始终会遇到用户的网络延迟,而这是我们无法控制的。

为了获得更好的用户体验,让我们为搜索添加一些即时的 UI 反馈。我们将再次使用 useNavigation

👉 添加一个变量来了解我们是否正在搜索

// existing imports & exports

export default function SidebarLayout({
  loaderData,
}: Route.ComponentProps) {
  const { contacts, q } = loaderData;
  const navigation = useNavigation();
  const submit = useSubmit();
  const searching =
    navigation.location &&
    new URLSearchParams(navigation.location.search).has(
      "q"
    );

  // existing code
}

当没有任何事情发生时,navigation.location 将是 undefined,但是当用户导航时,它将在数据加载时填充下一个位置。然后我们检查他们是否正在使用 location.search 进行搜索。

👉 使用新的 searching 状态向搜索表单元素添加类

// existing imports & exports

export default function SidebarLayout({
  loaderData,
}: Route.ComponentProps) {
  // existing code

  return (
    <>
      <div id="sidebar">
        {/* existing elements */}
        <div>
          <Form
            id="search-form"
            onChange={(event) =>
              submit(event.currentTarget)
            }
            role="search"
          >
            <input
              aria-label="Search contacts"
              className={searching ? "loading" : ""}
              defaultValue={q || ""}
              id="q"
              name="q"
              placeholder="Search"
              type="search"
            />
            <div
              aria-hidden
              hidden={!searching}
              id="search-spinner"
            />
          </Form>
          {/* existing elements */}
        </div>
        {/* existing elements */}
      </div>
      {/* existing elements */}
    </>
  );
}

加分项,避免在搜索时淡出主屏幕

// existing imports & exports

export default function SidebarLayout({
  loaderData,
}: Route.ComponentProps) {
  // existing code

  return (
    <>
      {/* existing elements */}
      <div
        className={
          navigation.state === "loading" && !searching
            ? "loading"
            : ""
        }
        id="detail"
      >
        <Outlet />
      </div>
      {/* existing elements */}
    </>
  );
}

你现在应该在搜索输入框的左侧有一个漂亮的微调器。

管理历史记录堆栈

由于表单是为每个按键提交的,因此键入字符“alex”然后使用退格键删除它们会导致巨大的历史记录堆栈 😂。我们绝对不希望这样

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

👉 submit 中使用 replace

// existing imports & exports

export default function SidebarLayout({
  loaderData,
}: Route.ComponentProps) {
  // existing code

  return (
    <>
      <div id="sidebar">
        {/* existing elements */}
        <div>
          <Form
            id="search-form"
            onChange={(event) => {
              const isFirstSearch = q === null;
              submit(event.currentTarget, {
                replace: !isFirstSearch,
              });
            }}
            role="search"
          >
            {/* existing elements */}
          </Form>
          {/* existing elements */}
        </div>
        {/* existing elements */}
      </div>
      {/* existing elements */}
    </>
  );
}

在快速检查这是否是第一次搜索之后,我们决定替换。现在,第一次搜索将添加一个新条目,但之后每次按键都将替换当前条目。用户只需单击一次后退按钮即可删除搜索,而不是单击 7 次后退按钮。

没有导航的 Form

到目前为止,我们所有的表单都更改了 URL。虽然这些用户流程很常见,但同样常见的是希望提交表单而不引起导航。

对于这些情况,我们有 useFetcher。它允许我们在不引起导航的情况下与 actionloader 进行通信。

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

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

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

// existing imports & exports

function Favorite({
  contact,
}: {
  contact: Pick<ContactRecord, "favorite">;
}) {
  const fetcher = useFetcher();
  const favorite = contact.favorite;

  return (
    <fetcher.Form method="post">
      <button
        aria-label={
          favorite
            ? "Remove from favorites"
            : "Add to favorites"
        }
        name="favorite"
        value={favorite ? "false" : "true"}
      >
        {favorite ? "" : ""}
      </button>
    </fetcher.Form>
  );
}

此表单将不再引起导航,而只是 fetch 到 action。说到这里...在我们创建 action 之前,这将不起作用。

👉 创建 action

// existing imports
import { getContact, updateContact } from "../data";
// existing imports

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

// existing code

好的,我们准备好单击用户名旁边的星星了!

看看这个,两颗星都会自动更新。我们的新 <fetcher.Form method="post"> 的工作方式几乎与我们一直在使用的 <Form> 完全相同:它调用 action,然后所有数据都会自动重新验证——即使你的错误也会以相同的方式被捕获。

但是,有一个关键的区别,它不是导航,因此 URL 不会更改,并且历史记录堆栈不受影响。

乐观 UI

你可能已经注意到,当我们从上一节单击收藏夹按钮时,应用程序感觉有点无响应。再一次,我们添加了一些网络延迟,因为你在现实世界中会遇到这种情况。

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

Fetcher 知道正在提交给 actionFormData,因此它在 fetcher.formData 上对你可用。我们将使用它来立即更新星星的状态,即使网络尚未完成。如果更新最终失败,UI 将恢复为真实数据。

👉 fetcher.formData 读取乐观值

// existing code

function Favorite({
  contact,
}: {
  contact: Pick<ContactRecord, "favorite">;
}) {
  const fetcher = useFetcher();
  const favorite = fetcher.formData
    ? fetcher.formData.get("favorite") === "true"
    : contact.favorite;

  return (
    <fetcher.Form method="post">
      <button
        aria-label={
          favorite
            ? "Remove from favorites"
            : "Add to favorites"
        }
        name="favorite"
        value={favorite ? "false" : "true"}
      >
        {favorite ? "" : ""}
      </button>
    </fetcher.Form>
  );
}

现在,当你单击星星时,星星立即更改为新状态。


就这样!感谢你尝试 React Router。我们希望本教程为你构建出色的用户体验提供坚实的起点。你还可以做更多的事情,因此请务必查看所有 API 😀

文档和示例 CC 4.0