跳转到内容

Form vs. fetcher

Form vs. fetcher

Remix 中的开发提供了一套丰富的工具,这些工具有时会在功能上重叠,给新手带来一种模糊感。在 Remix 中有效开发的关键是了解每种工具的细微差别和适当的用例。本文档旨在阐明何时以及为何使用特定 API。

焦点 API

了解这些 API 的区别和交集对于高效、有效的 Remix 开发至关重要。

URL 注意事项

在这些工具中进行选择时,主要标准是您是否希望更改 URL:

  • 需要更改 URL:在页面之间导航或转换时,或者在执行某些操作(例如创建或删除记录)之后。这可确保用户的浏览器历史记录准确反映他们在应用程序中的旅程。

  • 预期行为:在许多情况下,当用户点击后退按钮时,他们应该被带到上一页。其他时候,历史记录条目可能会被替换,但 URL 更改仍然很重要。

  • 无需更改 URL:适用于不会显著改变当前视图的上下文或主要内容的操作。这可能包括更新单个字段或不需要新 URL 或重新加载页面的次要数据操作。这也适用于使用提取器加载诸如弹出窗口、组合框等的数据。

具体用例

URL 何时需要改变

这些操作通常反映用户环境或状态的重大变化:

  • 创建新记录:创建新记录后,通常会将用户重定向到专门针对该新记录的页面,以便用户能够查看或进一步修改该记录。

  • 删除记录:如果用户在专门针对特定记录的页面上并决定删除该记录,则合乎逻辑的下一步是将他们重定向到一般页面,例如所有记录的列表。

对于这些情况,开发人员应考虑结合使用 <Form>useActionDatauseNavigation。这些工具中的每一个都可以协调起来,分别处理表单提交、调用特定操作、检索与操作相关的数据和管理导航。

URL 何时不应该改变

这些操作通常更加微妙,不需要用户进行上下文切换:

  • 更新单个字段:用户可能想要更改列表中某个项目的名称或更新某条记录的特定属性。此操作很小,不需要新页面或 URL。

  • 从列表中删除记录:在列表视图中,如果用户删除某个项目,他们很可能希望保留在列表视图中,而该项目不再存在于列表中。

  • 在列表视图中创建记录:当向列表添加新项目时,用户通常希望保留在该上下文中,这样他们就能看到新项目添加到列表中而无需进行完整的页面转换。

  • 为弹出框或组合框加载数据:为弹出框或组合框加载数据时,用户的上下文保持不变。数据在后台加载并显示在一个小的、独立的 UI 元素中。

对于此类操作,useFetcher 是首选 API。它功能多样,结合了其他四个 API 的功能,非常适合 URL 应保持不变的任务。

API 比较

可以看出,两组 API 有很多相似之处:

Navigation/URL APIFetcher API
<Form><fetcher.Form>
useActionData()fetcher.data
navigation.statefetcher.state
navigation.formActionfetcher.formAction
navigation.formDatafetcher.formData

示例

创建新记录

app/routes/recipes/new.tsx
import type { ActionFunctionArgs } from "@remix-run/node"; // or cloudflare/deno
import { redirect } from "@remix-run/node"; // or cloudflare/deno
import {
Form,
useActionData,
useNavigation,
} from "@remix-run/react";
export async function action({
request,
}: ActionFunctionArgs) {
const formData = await request.formData();
const errors = await validateRecipeFormData(formData);
if (errors) {
return json({ errors });
}
const recipe = await db.recipes.create(formData);
return redirect(`/recipes/${recipe.id}`);
}
export function NewRecipe() {
const { errors } = useActionData<typeof action>();
const navigation = useNavigation();
const isSubmitting =
navigation.formAction === "/recipes/new";
return (
<Form method="post">
<label>
Title: <input name="title" />
{errors?.title ? <span>{errors.title}</span> : null}
</label>
<label>
Ingredients: <textarea name="ingredients" />
{errors?.ingredients ? (
<span>{errors.ingredients}</span>
) : null}
</label>
<label>
Directions: <textarea name="directions" />
{errors?.directions ? (
<span>{errors.directions}</span>
) : null}
</label>
<button type="submit">
{isSubmitting ? "Saving..." : "Create Recipe"}
</button>
</Form>
);
}

该示例利用 <Form>useActionDatauseNavigation 来促进直观的记录创建过程。

使用 <Form> 可确保直接且合乎逻辑的导航。创建记录后,用户自然会被引导至新食谱的唯一 URL,从而强化其操作的结果。

useActionData 连接服务器和客户端,对提交问题提供即时反馈。这种快速响应使用户能够毫无阻碍地纠正任何错误。

最后,useNavigation 动态反映表单的提交状态。这种微妙的 UI 变化(如切换按钮的标签)可确保用户的操作正在被处理。

这些 API 结合起来提供了结构化导航和反馈的均衡融合。

更新记录

现在假设我们正在查看一个食谱列表,每个条目上都有删除按钮。当用户点击删除按钮时,我们希望从数据库中删除该食谱并将其从列表中移除,而无需离开列表。

首先考虑获取页面上的食谱列表的基本路由设置:

app/routes/recipes/_index.tsx
import type { LoaderFunctionArgs } from "@remix-run/node"; // or cloudflare/deno
import { json } from "@remix-run/node"; // or cloudflare/deno
import { useLoaderData } from "@remix-run/react";
export async function loader({
request,
}: LoaderFunctionArgs) {
return json({
recipes: await db.recipes.findAll({ limit: 30 }),
});
}
export default function Recipes() {
const { recipes } = useLoaderData<typeof loader>();
return (
<ul>
{recipes.map((recipe) => (
<RecipeListItem key={recipe.id} recipe={recipe} />
))}
</ul>
);
}

现在我们来看看删除菜谱的操作以及呈现列表中每个菜谱的组件。

app/routes/recipes/_index.tsx
export async function action({
request,
}: ActionFunctionArgs) {
const formData = await request.formData();
const id = formData.get("id");
await db.recipes.delete(id);
return json({ ok: true });
}
const RecipeListItem: FunctionComponent<{
recipe: Recipe;
}> = ({ recipe }) => {
const fetcher = useFetcher();
const isDeleting = fetcher.state !== "idle";
return (
<li>
<h2>{recipe.title}</h2>
<fetcher.Form method="post">
<button disabled={isDeleting} type="submit">
{isDeleting ? "Deleting..." : "Delete"}
</button>
</fetcher.Form>
</li>
);
};

在这种情况下,使用 useFetcher 效果很好。我们不需要离开或刷新整个页面,而是需要就地更新。当用户删除食谱时,操作会调用,抓取器会管理相应的状态转换。

这里的关键优势是上下文的维护。删除完成后,用户仍留在列表中。抓取器的状态管理功能可用于提供实时反馈:它在 正在删除...删除 之间切换,清晰地指示正在进行的过程。

此外,由于每个获取器都具有管理自己状态的自主权,对各个列表项的操作变得独立,从而确保对一个项目的操作不会影响其他项目(尽管页面数据的重新验证是一个共同关注的问题,在网络并发管理中有所涉及)。

本质上,useFetcher 为不需要改变 URL 或导航的操作提供了一种无缝机制,通过提供实时反馈和上下文保存来增强用户体验。

将文章标记为已读

假设您想要在当前用户浏览页面一段时间并滚动到页面底部后标记该文章已被用户阅读。您可以制作一个如下所示的钩子:

function useMarkAsRead({ articleId, userId }) {
const marker = useFetcher();
useSpentSomeTimeHereAndScrolledToTheBottom(() => {
marker.submit(
{ userId },
{
action: `/article/${articleId}/mark-as-read`,
method: "post",
}
);
});
}

用户头像详情弹出窗口

每当您显示用户头像时,您都可以添加悬停效果,从加载器中获取数据并将其显示在弹出窗口中。

// app/routes/users.$id.details.tsx
export async function loader({
params,
}: LoaderFunctionArgs) {
return json(
await fakeDb.user.find({ where: { id: params.id } })
);
}
function UserAvatar({ partialUser }) {
const userDetails = useFetcher<typeof loader>();
const [showDetails, setShowDetails] = useState(false);
useEffect(() => {
if (
showDetails &&
userDetails.state === "idle" &&
!userDetails.data
) {
userDetails.load(`/users/${user.id}/details`);
}
}, [showDetails, userDetails]);
return (
<div
onMouseEnter={() => setShowDetails(true)}
onMouseLeave={() => setShowDetails(false)}
>
<img src={partialUser.profileImageUrl} />
{showDetails ? (
userDetails.state === "idle" && userDetails.data ? (
<UserPopup user={userDetails.data} />
) : (
<UserPopupLoading />
)
) : null}
</div>
);
}

结论

Remix 提供一系列工具来满足各种开发需求。虽然某些功能似乎有重叠,但每个工具都是针对特定场景而设计的。通过了解 <Form>useActionDatauseFetcheruseNavigation 的复杂性和理想应用,开发人员可以创建更直观、响应更快、用户友好的 Web 应用程序。