跳转到内容

Tutorial (30m)

Remix 教程

我们将构建一个小型但功能丰富的应用程序, 让你可以管理联系人。为了专注于 Remix, 我们不会使用数据库或其他 “生产就绪” 的功能。如果你跟着做, 预计需要 30 分钟, 否则只需要快速阅读即可。

👉 每次看到这个就意味着你需要在应用程序中做一些事情!

其余内容仅供您参考并加深理解。让我们开始吧。

设置

👉 生成一个基础模板

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

这使用了一个相当基础的模板, 但包含了我们的 CSS 和数据模型, 这样我们就可以专注于 Remix。如果你想了解更多关于 Remix 项目基本设置的信息, 可以参考 快速入门

👉 启动应用程序

Terminal window
# 进入应用目录
cd {你放置应用的位置}
# 如果还没有安装依赖,先安装依赖
npm install
# 启动服务器
npm run dev

你现在应该可以打开 http://localhost:5173 并看到一个没有样式的界面, 如下所示:

根路由

注意 app/root.tsx 文件。这就是我们所说的 “根路由”。它是 UI 中首先渲染的组件, 因此通常包含页面的全局布局。

展开此处查看根组件代码
app/root.tsx
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。

👉 导入应用程序样式

app/root.tsx
import type { LinksFunction } from "@remix-run/node";
// 现有导入
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 目录和联系路由模块

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

在 Remix 的 路由文件约定 中,. 会在 URL 中创建一个 /, 而 $ 使段落变成动态的。我们刚刚创建的路由将匹配如下格式的 URL:

  • /contacts/123
  • /contacts/abc

👉 添加联系人组件 UI

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

// app/routes/contacts.$contactId.tsx
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

嵌套路由和 Outlets

由于 Remix 是基于 React Router 构建的,因此它支持嵌套路由。为了让子路由在父布局内呈现,我们需要在父布局中呈现 Outlet。让我们修复它,打开 app/root.tsx 并在其中呈现一个 Outlets。

👉 渲染 <Outlet />

app/root.tsx
// 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>
);
}

现在子路线应该通过 Outlets 进行渲染。

带有主要内容的 contact 路线

客户端路由

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

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

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

app/root.tsx
// 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 SegmentComponentData
/<Root>联系人列表
contacts/:contactId<Contact>联系人

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

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

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

以下代码中有类型错误,我们将在下一部分中进行修复

app/root.tsx
// 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 添加一个快速注释,以获取有关我们数据的类型推断:

app/root.tsx
// existing imports & exports
export default function App() {
const { contacts } = useLoaderData<typeof loader>();
// existing code
}

Loaders 中的 URL 参数

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

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

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

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

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

👉 在联系页面添加 loader 函数,并使用 useLoaderData 访问数据

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

// app/routes/contacts.$contactId.tsx
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 对我们非常不满,让我们让它高兴起来,看看这迫使我们考虑什么:

// app/routes/contacts.$contactId.tsx
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 错误仍然到处乱飞。

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

// app/routes/contacts.$contactId.tsx
// existing imports
export const loader = async ({
params,
}: LoaderFunctionArgs) => {
invariant(params.contactId, "缺少 contactId 参数");
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 的请求主体发送到服务器,作为 GETURLSearchParams 发送到服务器。Remix 做同样的事情,只不过它不将请求发送到服务器,而是使用客户端路由并将其发送到路由的 action 函数。

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

由于服务器上没有代码处理此表单导航,Remix 返回了 405。

创建联系人

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

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

app/root.tsx
// existing imports
import { createEmptyContact, getContacts } from "./data";
export const action = async () => {
const contact = await createEmptyContact();
return json({ contact });
};
// existing code

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

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

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

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

在网络语义中,“POST” 通常表示某些数据正在发生变化。按照惯例,Remix 会将此用作提示,在 “操作” 完成后自动重新验证页面上的数据。

事实上,由于它只是 HTML 和 HTTP,您可以禁用 JavaScript,整个过程仍会正常运行。浏览器将序列化表单并发出文档请求,而不是 Remix 序列化表单并向您的服务器发出 fetch 请求。从那里,Remix 将在服务器端呈现页面并将其发送下去。无论哪种方式,最终的 UI 都是相同的。

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

更新数据

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

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

👉 创建编辑组件

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

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

👉 添加编辑页面 UI

没有什么是我们以前没见过的,请随意复制 / 粘贴:

// app/routes/contacts.$contactId_.edit.tsx
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 功能

// app/routes/contacts.$contactId_.edit.tsx
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 元素。注意它们每个都有一个名称:

// app/routes/contacts.$contactId_.edit.tsx
<input
aria-label="First name"
defaultValue={contact.first}
name="first"
placeholder="First"
type="text"
/>

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

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

// app/routes/contacts.$contactId_.edit.tsx
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 函数想要的。

// app/routes/contacts.$contactId_.edit.tsx
const updates = Object.fromEntries(formData);
updates.first; // "Some"
updates.last; // "Name"

除了 action 函数之外,我们讨论的这些 API 均不由 Remix 提供:requestrequest.formDataObject.fromEntries 均由 Web 平台提供。

完成 action 后,请注意最后的redirect

// app/routes/contacts.$contactId_.edit.tsx
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 时,它是客户端重定向,因此用户不会丢失客户端状态,例如滚动位置或组件状态。

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

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

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

app/root.tsx
// 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>

app/root.tsx
// 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”。当用户位于与 “” 匹配的 URL 时,“isActive” 将为真。当它即将激活(数据仍在加载)时,“isPending” 将为真。这使我们能够轻松指示用户的位置,并在单击链接但需要加载数据时提供即时反馈。

全局待处理用户界面

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

Remix 在后台管理所有状态,并揭示构建动态 Web 应用所需的部分。在本例中,我们将使用 useNavigation 钩子。

👉 使用 useNavigation 添加全局待处理 UI

app/root.tsx
// 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 闪烁)。不过,您可以做任何您想做的事情,比如在顶部显示一个旋转器或加载栏。

删除记录

如果我们检查联系路线中的代码,我们会发现删除按钮如下所示:

// app/routes/contact.$contactId.tsx
<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> 可以采用 relative 值。由于表单是在 contacts.$contactId.tsx 中呈现的,因此带有 destroy 的相对操作将在单击时将表单提交给 contacts.$contactId.destroy

此时,您应该知道使删除按钮正常工作所需的所有知识。在继续之前,也许可以先试一试?您需要:

  1. 一条新路线
  2. 该路线上的 action
  3. app/data.ts 中删除联系人
  4. redirect 到某个地方

👉 创建 “destroy” 路线模块

Terminal window
touch app/routes/contacts.\$contactId.destroy.tsx

👉 添加销毁操作

// app/routes/contacts.$contactId.destroy.tsx
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" 处的新路由匹配并向其发送请求
  3. action 重定向后,Remix 调用页面上的所有 loader 以获取最新值(这就是 “重新验证”)。useLoaderData 返回新值并导致组件更新!

添加一个 “表单”,添加一个 “动作”,Remix 会完成剩下的工作。

索引路线

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

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

👉 为根路由创建索引路由

Terminal window
touch app/routes/_index.tsx

👉 填写索引组件的元素

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

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.run">the docs at remix.run</a>.
</p>
);
}

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

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

取消按钮

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

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

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

// app/routes/contacts.$contactId_.edit.tsx
// 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 的链接,要么是将数据发布到 “操作” 函数的 “表单”。搜索字段很有趣,因为它是两者的混合:它是一个 “表单”,但它只更改 URL,而不会更改数据。

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

👉 在搜索栏中输入姓名并按回车键

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

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

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

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

👉 如果有 URLSearchParams,则过滤列表

app/root.tsx
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,将其设置为输入的默认值

app/root.tsx
// 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 同步输入值

app/root.tsx
// 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 State 吗?

您当然可以将其作为受控组件来执行。您将拥有更多同步点,但这取决于您。

展开它看看它是什么样子
app/root.tsx
// 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

这里我们要做出一个产品决策。有时您希望用户提交 “表单” 来过滤某些结果,有时您希望在用户输入时进行过滤。我们已经实现了第一个,所以让我们看看第二个是什么样的。

我们已经看到了 useNavigate,我们将使用它的兄弟useSubmit来实现这一点。

app/root.tsx
// 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>
);
}

当您输入时,“表单” 就会自动提交!

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

添加搜索微调器

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

没有任何加载指示器,搜索感觉有点迟缓。即使我们可以让数据库更快,我们总是会遇到用户的网络延迟,而这不受我们控制。

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

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

app/root.tsx
// 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” 将为 “未定义”,但当用户导航时,它将在数据加载时填充下一个位置。然后我们检查他们是否正在使用 “location.search” 进行搜索。

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

app/root.tsx
// 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>
);
}

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

app/root.tsx
// 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

app/root.tsx
// 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> 表单更改为获取器表单

// app/routes/contacts.$contactId.tsx
// 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 之前,这不会起作用。

👉 创建动作

// app/routes/contacts.$contactId.tsx
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> 几乎完全相同:它调用操作,然后自动重新验证所有数据 — 甚至您的错误也会以相同的方式被捕获。

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

乐观的用户界面

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

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

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

👉 fetcher.formData 中读取乐观值

// app/routes/contacts.$contactId.tsx
// 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 😀