教程 (30 分钟)
本页内容

Remix 教程

我们将构建一个小型但功能丰富的应用程序,用于跟踪您的联系人。 这里没有数据库或其他“生产就绪”的东西,因此我们可以专注于 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

嵌套路由和出口

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

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

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

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
}

加载器中的 URL 参数

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

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

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

这些 params 被传递给加载器,其键与动态段落匹配。例如,我们的段落名为 $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 错误仍然四散飞舞。

我们可以在组件代码中考虑联系人可能找不到的情况,但最佳的做法是发送一个正确的 404。我们可以在加载器中做到这一点,并一次性解决我们所有的问题。

// 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 还可以更改请求方法(GET vs. POST)和请求主体(POST 表单数据)。

在没有客户端路由的情况下,浏览器将自动序列化 form 的数据,并将它发送到服务器,作为 POST 的请求主体,以及作为 URLSearchParams 用于 GET。Remix 也执行相同的操作,只是它没有将请求发送到服务器,而是使用客户端路由并将它发送到路由的 action 函数。

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

Remix 发送了一个 405,因为服务器上没有代码来处理这个表单导航。

创建联系人

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

👉 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 会阻止这种情况,并通过将请求发送到您的 action 函数(使用 fetch)来模拟浏览器,其中包括 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. deleteContact 来自 app/data.ts
  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,它不会更改数据。

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

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

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

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 和结果同步。

提交 FormonChange

我们这里需要做出一个产品决策。有时你希望用户提交 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