React Router v7 已发布。 查看文档
教程 (30分钟)
本页内容

Remix 教程

刚开始使用 Remix? 最新版本的 Remix 现在是 React Router v7。 如果您想使用最新的框架功能,您可以按照 React Router 文档中的教程进行操作。

我们将构建一个小的、功能丰富的应用程序,让您可以跟踪您的联系人。 没有数据库或其他“生产就绪”的东西,因此我们可以专注于 Remix。 如果您正在学习,我们预计大约需要 30 分钟,否则这是一个快速阅读。

👉 每次您看到这个,都表示您需要在应用程序中执行某些操作!

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

设置

👉 生成基本模板

npx create-remix@latest --template remix-run/remix/templates/remix-tutorial

这使用了一个非常简单的模板,但包含了我们的 css 和数据模型,因此我们可以专注于 Remix。 如果您想了解更多信息,快速入门可以帮助您熟悉 Remix 项目的基本设置。

👉 启动应用程序

# 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,
  Links,
  Meta,
  Scripts,
  ScrollRestoration,
} from "@remix-run/react";

export default function App() {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta
          name="viewport"
          content="width=device-width, initial-scale=1"
        />
        <Meta />
        <Links />
      </head>
      <body>
        <div id="sidebar">
          <h1>Remix 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>

        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

虽然有多种方式来设置 Remix 应用的样式,但为了让大家专注于 Remix 本身,我们将使用一个已经编写好的普通样式表。

你可以直接将 CSS 文件导入到 JavaScript 模块中。Vite 会对该资源进行指纹处理,将其保存到构建的客户端目录中,并为你的模块提供公开可访问的 href。

👉 导入应用样式

import type { LinksFunction } from "@remix-run/node";
// existing imports

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

export const links: LinksFunction = () => [
  { rel: "stylesheet", href: appStylesHref },
];

每个路由都可以导出一个 links 函数。它们将被收集并渲染到我们在 app/root.tsx 中渲染的 <Links /> 组件中。

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

联系人路由 UI

如果你点击侧边栏中的一个项目,你将看到默认的 404 页面。让我们创建一个匹配 URL /contacts/1 的路由。

👉 创建 app/routes 目录和联系人路由模块

mkdir app/routes
touch app/routes/contacts.\$contactId.tsx

在 Remix 的 路由文件约定 中,. 将在 URL 中创建一个 /,而 $ 使一个片段成为动态的。我们刚刚创建了一个路由,它将匹配如下所示的 URL:

  • /contacts/123
  • /contacts/abc

👉 添加联系人组件 UI

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

import { Form } from "@remix-run/react";
import type { FunctionComponent } from "react";

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

const Favorite: FunctionComponent<{
  contact: Pick<ContactRecord, "favorite">;
}> = ({ contact }) => {
  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,我们会得到...什么都没有改变?

contact route with blank main content

嵌套路由和 Outlet

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

👉 渲染一个 <Outlet />

// existing imports
import {
  Form,
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
} from "@remix-run/react";

// existing imports & code

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

现在子路由应该通过 outlet 进行渲染了。

contact route with the main content

客户端路由

你可能已经注意到,也可能没有注意到,但当我们点击侧边栏中的链接时,浏览器正在为下一个 URL 执行完整的文档请求,而不是客户端路由。

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

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

// existing imports
import {
  Form,
  Link,
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
} from "@remix-run/react";

// existing imports & exports

export default function App() {
  return (
    <html lang="en">
      {/* other elements */}
      <body>
        <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 */}
      </body>
    </html>
  );
}

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

加载数据

URL 片段、布局和数据通常是耦合在一起的(三位一体?)。我们已经可以在这个应用中看到它了

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

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

我们将使用两个 API 来加载数据,loaderuseLoaderData。首先,我们将在根路由中创建并导出一个 loader 函数,然后渲染数据。

👉 app/root.tsx 导出一个 loader 函数并渲染数据

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

// existing imports
import { json } from "@remix-run/node";
import {
  Form,
  Link,
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
  useLoaderData,
} from "@remix-run/react";

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

// existing exports

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

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

  return (
    <html lang="en">
      {/* other elements */}
      <body>
        <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 */}
      </body>
    </html>
  );
}

就是这样!Remix 现在会自动使该数据与你的 UI 同步。侧边栏现在应该看起来像这样

类型推断

你可能已经注意到 TypeScript 在 map 内部抱怨 contact 类型。我们可以添加一个快速注解,通过 typeof loader 获取有关我们数据的类型推断

// existing imports & exports

export default function App() {
  const { contacts } = useLoaderData<typeof loader>();

  // existing code
}

Loader 中的 URL 参数

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

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

还记得 app/routes/contacts.$contactId.tsx 文件名中的 $contactId 部分吗?这些动态片段将匹配 URL 中该位置的动态(变化)值。我们称 URL 中的这些值为“URL 参数”,简称“参数”。

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

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

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

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

import { json } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
// existing imports

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

export const loader = async ({ params }) => {
  const contact = await getContact(params.contactId);
  return json({ contact });
};

export default function Contact() {
  const { contact } = useLoaderData<typeof loader>();

  // existing code
}

// existing code

验证参数和抛出响应

TypeScript 对我们非常不满,让我们让它满意,看看这迫使我们考虑什么

import type { LoaderFunctionArgs } from "@remix-run/node";
// existing imports
import invariant from "tiny-invariant";

// existing imports

export const loader = async ({
  params,
}: LoaderFunctionArgs) => {
  invariant(params.contactId, "Missing contactId param");
  const contact = await getContact(params.contactId);
  return json({ contact });
};

// existing code

这突出的第一个问题是,我们可能在文件名和代码之间弄错了参数的名称(也许你更改了文件名!)。Invariant 是一个方便的函数,当您预期代码可能存在潜在问题时,它会抛出一个带有自定义消息的错误。

接下来,useLoaderData<typeof loader>() 现在知道我们得到了一个联系人或 null(可能没有具有该 ID 的联系人)。这种潜在的 null 对于我们的组件代码来说很麻烦,并且 TS 错误仍在到处飞。

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

// existing imports

export const loader = async ({
  params,
}: LoaderFunctionArgs) => {
  invariant(params.contactId, "Missing contactId param");
  const contact = await getContact(params.contactId);
  if (!contact) {
    throw new Response("Not Found", { status: 404 });
  }
  return json({ contact });
};

// existing code

现在,如果找不到用户,则此路径上的代码执行会停止,Remix 会改为渲染错误路径。Remix 中的组件可以只关注快乐的路径 😁

数据变更

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

Remix 模拟 HTML 表单导航作为数据变更基元,这在 JavaScript 寒武纪爆发之前是唯一的方法。不要被它的简单性所迷惑!Remix 中的表单为您提供客户端渲染应用的 UX 功能,并具有“老式”Web 模型的简单性。

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

在没有客户端路由的情况下,浏览器会自动序列化 form 的数据,并将其作为 POST 的请求正文发送到服务器,对于 GET,则将其作为 URLSearchParams 发送。Remix 也执行相同的操作,但不是将请求发送到服务器,而是使用客户端路由并将其发送到路由的 action 函数。

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

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

创建联系人

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

👉 app/root.tsx 导出一个 action 函数

// existing imports

import { createEmptyContact, getContacts } from "./data";

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

// existing code

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

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

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

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

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

实际上,由于这一切都只是 HTML 和 HTTP,你可以禁用 JavaScript,整个过程仍然可以正常工作。浏览器不是由 Remix 序列化表单并向你的服务器发出 fetch 请求,而是会序列化表单并发出文档请求。然后,Remix 将在服务器端渲染页面并将其发送下来。最终结果都是相同的 UI。

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

更新数据

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

就像创建数据一样,您可以使用 <Form> 更新数据。让我们在 app/routes/contacts.$contactId_.edit.tsx 中创建一个新路由。

👉 创建编辑组件

touch app/routes/contacts.\$contactId_.edit.tsx

请注意 $contactId_ 中奇怪的 _。默认情况下,路由会自动嵌套在具有相同前缀名称的路由内。添加尾随 _ 会告诉路由不要嵌套在 app/routes/contacts.$contactId.tsx 内。请在 路由文件命名 指南中阅读更多信息。

👉 添加编辑页面 UI

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

import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
import invariant from "tiny-invariant";

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

export const loader = async ({
  params,
}: LoaderFunctionArgs) => {
  invariant(params.contactId, "Missing contactId param");
  const contact = await getContact(params.contactId);
  if (!contact) {
    throw new Response("Not Found", { status: 404 });
  }
  return json({ contact });
};

export default function EditContact() {
  const { contact } = useLoaderData<typeof loader>();

  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 函数。Remix 会序列化 form,使用 fetch 发起 POST 请求,并自动重新验证所有数据。

👉 在编辑路由中添加 action 函数

import type {
  ActionFunctionArgs,
  LoaderFunctionArgs,
} from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
// existing imports

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

export const action = async ({
  params,
  request,
}: ActionFunctionArgs) => {
  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}`);
};

// existing code

填写表单,点击保存,你应该看到类似这样的内容!(除了更养眼,可能没那么毛茸茸的。)

变更讨论

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

让我们深入了解一下……

打开 contacts.$contactId_.edit.tsx,查看 form 元素。注意它们都有一个名称

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

在没有 JavaScript 的情况下,当表单被提交时,浏览器会创建 FormData,并将其设置为请求的主体,发送到服务器。如前所述,Remix 会阻止这种情况,而是通过 fetch 将请求发送到你的 action 函数,其中包含 FormData,以此来模拟浏览器行为。

form 中的每个字段都可以通过 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 都不是由 Remix 提供的:requestrequest.formDataObject.fromEntries 都是由 Web 平台提供的。

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

export const action = async ({
  params,
  request,
}: ActionFunctionArgs) => {
  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 请求后重定向,新页面将获取最新数据并进行渲染。正如我们之前学到的,Remix 模拟了这种模式,并在 action 调用后自动重新验证页面上的数据。这就是为什么当我们保存表单时,侧边栏会自动更新。如果没有客户端路由,就不会存在额外的重新验证代码,因此在 Remix 中使用客户端路由时也不需要存在!

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

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

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

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

// existing imports
import { json, redirect } from "@remix-run/node";
// existing imports

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

// existing code

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

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

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

// existing imports
import {
  Form,
  Links,
  Meta,
  NavLink,
  Outlet,
  Scripts,
  ScrollRestoration,
  useLoaderData,
} from "@remix-run/react";

// existing imports and exports

export default function App() {
  const { contacts } = useLoaderData<typeof loader>();

  return (
    <html lang="en">
      {/* existing elements */}
      <body>
        <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 */}
      </body>
    </html>
  );
}

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

全局等待 UI

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

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

👉 使用 useNavigation 添加全局等待 UI

// existing imports
import {
  Form,
  Links,
  Meta,
  NavLink,
  Outlet,
  Scripts,
  ScrollRestoration,
  useLoaderData,
  useNavigation,
} from "@remix-run/react";

// existing imports & exports

export default function App() {
  const { contacts } = useLoaderData<typeof loader>();
  const navigation = useNavigation();

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

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.tsx 中渲染,因此当点击时,具有 destroy 的相对操作会将表单提交到 contacts.$contactId.destroy

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

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

👉 创建“destroy”路由模块

touch app/routes/contacts.\$contactId_.destroy.tsx

👉 添加 destroy 操作

import type { ActionFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import invariant from "tiny-invariant";

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

export const action = async ({
  params,
}: ActionFunctionArgs) => {
  invariant(params.contactId, "Missing contactId param");
  await deleteContact(params.contactId);
  return redirect("/");
};

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

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

当用户点击提交按钮时

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

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

索引路由

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

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

👉 为根路由创建一个索引路由

touch app/routes/_index.tsx

👉 填写索引组件的元素

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

export default function Index() {
  return (
    <p id="index-page">
      This is a demo for Remix.
      <br />
      Check out{" "}
      <a href="https://remix.org.cn">the docs at remix.run</a>.
    </p>
  );
}

路由名称 _index 是特殊的。它告诉 Remix 当用户位于父路由的精确路径时匹配并渲染此路由,因此在 <Outlet /> 中没有其他子路由要渲染。

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

取消按钮

在编辑页面上,我们有一个取消按钮,它现在什么都不做。我们希望它执行与浏览器后退按钮相同的操作。

我们需要在按钮上设置一个单击处理程序,以及 useNavigate

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

// existing imports
import {
  Form,
  useLoaderData,
  useNavigate,
} from "@remix-run/react";
// existing imports & exports

export default function EditContact() {
  const { contact } = useLoaderData<typeof loader>();
  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 现在以 URLSearchParams 的形式包含你的查询

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

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

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

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

import type {
  LinksFunction,
  LoaderFunctionArgs,
} from "@remix-run/node";

// existing imports & exports

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

// existing code

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

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

将 URL 与表单状态同步

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

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

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

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

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

// existing imports & exports

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

export default function App() {
  const { contacts, q } = useLoaderData<typeof loader>();
  const navigation = useNavigation();

  return (
    <html lang="en">
      {/* existing elements */}
      <body>
        <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 */}
      </body>
    </html>
  );
}

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

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

👉 将输入值与 URLSearchParams 同步

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

// existing imports & exports

export default function App() {
  const { contacts, q } = useLoaderData<typeof loader>();
  const navigation = useNavigation();

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

  // existing code
}

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

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

展开此内容以查看其外观
// existing imports
import { useEffect, useState } from "react";

// existing imports & exports

export default function App() {
  const { contacts, q } = useLoaderData<typeof loader>();
  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 (
    <html lang="en">
      {/* existing elements */}
      <body>
        <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 */}
      </body>
    </html>
  );
}

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

onChange 上提交 Form

我们这里需要做一个产品决策。有时你希望用户提交 form 来筛选一些结果,有时你希望用户在输入时就进行筛选。我们已经实现了第一种情况,所以让我们看看第二种情况会是什么样子。

我们已经见过 useNavigate 了,这次我们将使用它的同胞兄弟 useSubmit

// existing imports
import {
  Form,
  Links,
  Meta,
  NavLink,
  Outlet,
  Scripts,
  ScrollRestoration,
  useLoaderData,
  useNavigation,
  useSubmit,
} from "@remix-run/react";
// existing imports & exports

export default function App() {
  const { contacts, q } = useLoaderData<typeof loader>();
  const navigation = useNavigation();
  const submit = useSubmit();

  // existing code

  return (
    <html lang="en">
      {/* existing elements */}
      <body>
        <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 */}
      </body>
    </html>
  );
}

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

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

添加搜索加载指示器

在生产应用中,这种搜索很可能是在一个太大的数据库中查找记录,而不能一次性发送所有数据并在客户端进行筛选。这就是为什么这个演示有一些伪造的网络延迟。

没有任何加载指示器,搜索感觉有点慢。即使我们能使数据库更快,我们也总会受到用户网络延迟的阻碍,而这是我们无法控制的。

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

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

// existing imports & exports

export default function App() {
  const { contacts, q } = useLoaderData<typeof loader>();
  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 App() {
  // existing code

  return (
    <html lang="en">
      {/* existing elements */}
      <body>
        <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 */}
      </body>
    </html>
  );
}

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

// existing imports & exports

export default function App() {
  // existing code

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

现在,您应该在搜索输入框的左侧看到一个漂亮的加载指示器。

管理历史堆栈

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

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

👉 submit 中使用 replace

// existing imports & exports

export default function App() {
  // existing code

  return (
    <html lang="en">
      {/* existing elements */}
      <body>
        <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 */}
      </body>
    </html>
  );
}

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

没有导航的 Form

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

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

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

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

// existing imports
import {
  Form,
  useFetcher,
  useLoaderData,
} from "@remix-run/react";
// existing imports & exports

// existing code

const Favorite: FunctionComponent<{
  contact: Pick<ContactRecord, "favorite">;
}> = ({ contact }) => {
  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

import type {
  ActionFunctionArgs,
  LoaderFunctionArgs,
} from "@remix-run/node";
// existing imports

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

export const action = async ({
  params,
  request,
}: ActionFunctionArgs) => {
  invariant(params.contactId, "Missing contactId param");
  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

const Favorite: FunctionComponent<{
  contact: Pick<ContactRecord, "favorite">;
}> = ({ contact }) => {
  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>
  );
};

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


就是这样!感谢您试用 Remix。我们希望本教程为您构建出色的用户体验提供一个坚实的基础。您可以做的事情还有很多,请务必查看所有 API 😀

文档和示例在以下许可下授权 MIT