我们将构建一个小型但功能丰富的应用程序,用于跟踪您的联系人。 这里没有数据库或其他“生产就绪”的东西,因此我们可以专注于 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>
);
}
links
添加样式表虽然有多种方法可以为你的 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 🙏)。
如果你点击侧边栏中的一个项目,你将看到默认的 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
,我们得到... 还是一样?
由于 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>
);
}
现在子路由应该通过出口渲染了。
你可能已经注意到,当我们点击侧边栏中的链接时,浏览器正在对下一个 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 来加载数据,loader
和 useLoaderData
。首先,我们将在根路由中创建一个并导出 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 现在有了该记录的真实 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
函数?用于重新获取数据的代码在哪里?useState
、onSubmit
和useEffect
在哪里?
这就是“老式 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 提供的:request
、request.formData
、Object.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}`);
};
action
和 loader
函数都可以 返回一个 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。这使我们能够轻松地指示用户的当前位置,并在点击链接但需要加载数据时提供即时反馈。
当用户在应用程序中导航时,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
。
此时,您应该知道使删除按钮工作所需的一切。在继续之前,不妨试一试?您将需要
action
deleteContact
来自 app/data.ts
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("/");
};
好了,导航到一个记录并点击“删除”按钮。它起作用了!
😅 我仍然不明白为什么这一切都能奏效
当用户点击提交按钮时
<Form>
会阻止浏览器将新文档 POST
请求发送到服务器的默认行为,而是通过使用客户端路由和 fetch
来模拟浏览器,创建 POST
请求<Form action="destroy">
与 contacts.$contactId_.destroy.tsx
中的新路由相匹配,并将请求发送到该路由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 阻止按钮提交其表单的方式。
还有两个功能要完成。我们已经快到终点了!
URLSearchParams
和 GET
提交到目前为止,我们所有的交互式 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 会改变。
这也意味着它是一个普通的页面导航。您可以点击后退按钮回到您之前的位置。
这里有两个我们可以快速解决的 UX 问题。
换句话说,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 和结果同步。
Form
的 onChange
我们这里需要做出一个产品决策。有时你希望用户提交 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.currentTarget
。currentTarget
是事件附加到的 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
。它允许我们与 action
和 loader
通信,而不会导致导航。
联系页面上的 ★ 按钮适合这种情况。我们不创建或删除新的记录,也不想改变页面。我们只是想改变我们正在查看的页面的数据。
👉 将 <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 不会改变,历史记录栈也不会受到影响。
你可能注意到,当我们点击上一节中的收藏按钮时,应用程序感觉有点没有反应。我们再次添加了一些网络延迟,因为你将在现实世界中遇到这种延迟。
为了给用户一些反馈,我们可以使用 fetcher.state
(非常类似于之前的 navigation.state
)将星星设置为加载状态,但这次我们可以做一些更好的事情。我们可以使用一种称为 "乐观 UI" 的策略。
fetcher 知道提交给 action
的 FormData
,因此它在 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 😀