地址簿
在本页

地址簿

我们将构建一个小型但功能丰富的地址簿应用,用于管理您的联系人。其中不包含数据库或其他“生产就绪”的内容,因此我们可以专注于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

您应该能够打开 http://localhost: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.remix.org.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.remix.org.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,我们看到的是...没什么新变化?

嵌套路由和Outlet

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

👉 渲染一个 <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>
    </>
  );
}

现在子路由应该通过Outlet渲染出来了。

客户端路由

您可能已经注意到,也可能没有,但是当我们点击侧边栏中的链接时,浏览器正在对下一个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 */}
    </>
  );
}

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

加载数据

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)。目前我们的联系人网站是一个单页应用(SPA),所以没有服务器端渲染。这使得部署到任何静态托管提供商都非常容易,但稍后我们会详细讨论如何启用SSR,这样您就可以了解React Router提供的各种渲染策略

类型安全

您可能注意到了,我们没有为 loaderData 属性指定类型。让我们来修复它。

👉 App 组件添加 ComponentProps 类型

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

我们之前提到过,我们正在构建一个没有服务器端渲染的单页应用(SPA)。如果您查看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.remix.org.cn">
        the docs at reactrouter.com
      </a>
      .
    </p>
  );
}

看!不再有空白空间了。通常会在索引路由中放置仪表盘、统计数据、动态等。它们也可以参与数据加载。

添加关于页面路由

在我们继续处理用户可以交互的动态数据之前,让我们添加一个包含静态内容的页面,这些内容预计很少更改。“关于我们”页面非常适合这个用途。

👉 创建关于页面路由

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.remix.org.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 路由来自动为该布局内的所有匹配路由渲染侧边栏。这基本上就是我们根路由的功能,但现在我们可以将其作用范围限定到特定的路由。

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;

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

我们想将 clientLoader 以及 App 组件内的所有内容移到侧边栏布局中。它应该看起来像这样:

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是构建单页应用(SPA)的优秀框架。许多应用仅通过客户端渲染就能很好地服务,或者也许在构建时静态预渲染几个页面。

如果您想在您的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 };
}

您是设置 ssrtrue 还是 false 取决于您和用户的需求。这两种策略都是完全有效的。在本教程的剩余部分,我们将使用服务器端渲染,但要知道所有渲染策略在React Router中都是一等公民。

Loader中的URL参数

👉 点击侧边栏中的一个链接

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

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

这些 params 会作为参数传递给loader,其键名与动态段匹配。例如,我们的段名为 :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响应。我们可以在loader中这样做,一次性解决所有问题。

// 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 Form导航模拟为数据变动原语,在JavaScript大爆发之前,这是唯一的方法。不要被其简单性所迷惑!React Router中的Form为您提供了客户端渲染应用的UX能力,同时保留了“老派”Web模型的简单性。

尽管对一些Web开发者来说可能不熟悉,但HTML form 实际上会在浏览器中触发导航,就像点击链接一样。唯一的区别在于请求:链接只能改变URL,而 form 还可以改变请求方法(GET vs. POST)和请求体(POST 表单数据)。

如果没有客户端路由,浏览器会自动序列化 form 的数据,并将其作为请求体发送给服务器(用于 POST),或作为URLSearchParams 发送(用于 GET)。React Router也做同样的事情,但它不是将请求发送到服务器,而是使用客户端路由将其发送到路由的action函数。

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

React Router发送一个405响应,因为服务器上没有代码来处理这种表单导航。

创建联系人

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

👉 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会在服务器端渲染页面并发送下来。无论哪种方式,最终呈现的UI都是相同的。

不过我们会保留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

填写表单,点击保存,您应该会看到类似这样的效果! (除了视觉效果更好,也许还需要切西瓜的耐心。)

变动讨论

😑 它工作了,但我完全不明白这里发生了什么...

让我们稍微深入探讨一下...

打开 app/routes/edit-contact.tsx 并查看 form 元素。注意它们都有一个 name 属性:

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

如果没有JavaScript,当表单提交时,浏览器会创建一个FormData,并在将其发送到服务器时将其设置为请求体。如前所述,React Router阻止了这一点,并模拟浏览器,而是使用fetch将请求发送到您的 action 函数,其中包含了FormData

表单中的每个字段都可以通过 formData.get(name) 访问。例如,对于上面的输入字段,您可以这样访问名字和姓氏:

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变得更容易。

如果没有客户端路由,服务器在 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 Hook。

👉 使用 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.ts 导入 deleteContact
  4. 完成后重定向到某个地方

👉 配置“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 Hook。

👉 使用 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,不改变数据。

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

👉 在搜索字段中输入一个名字并按下回车键

注意浏览器 URL 现在包含您的查询,格式为 URLSearchParams

http://localhost: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 和结果保持同步。

onChange 时提交 Form

这里我们需要做一个产品决策。有时您希望用户提交 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 */}
    </>
  );
}

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

注意传递给 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。然后我们通过 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>
  );
}

这个表单将不再引起导航,而只是向 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 (Optimistic 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