Remix 中的数据写入(有些人称之为变异)建立在两个基本 Web API 之上:<form>
和 HTTP。然后,我们使用渐进增强来启用乐观 UI、加载指示器和验证反馈,但编程模型仍然建立在 HTML 表单之上。
当用户提交表单时,Remix 将
许多时候,人们会在 React 中使用全局状态管理库(如 redux)、数据库(如 apollo)和 fetch 包装器(如 React Query)来帮助管理将服务器状态放入组件中,并在用户更改状态时保持 UI 与其同步。Remix 的基于 HTML 的 API 替代了这些工具的大多数用例。Remix 知道如何加载数据,以及如何在使用标准 HTML API 后重新验证数据。
有几种方法可以调用操作并使路由重新验证
本指南仅涵盖 <Form>
。我们建议您在阅读完本指南后阅读其他两个的文档,以了解如何使用它们。本指南中的大部分内容适用于 useSubmit
,但 useFetcher
有一些不同。
在与我们公司 React Training 合作多年教授研讨会后,我们了解到许多较新的 Web 开发人员(尽管不是他们自己的错)实际上并不了解 <form>
的工作原理!
由于 Remix <Form>
的工作原理与 <form>
相同(除了为乐观 UI 等提供了一些额外的好处),我们将复习一下普通的 HTML 表单,这样您就可以同时学习 HTML 和 Remix。
原生表单支持两种 HTTP 动词:GET
和 POST
。Remix 使用这些动词来理解您的意图。如果是 GET,Remix 将确定页面中哪些部分正在更改,只获取更改布局的数据,并使用缓存的数据获取未更改的布局。如果是 POST,Remix 将重新加载所有数据,以确保它捕获来自服务器的更新。让我们看看这两种情况。
GET
只是一个正常的导航,其中表单数据在 URL 搜索参数中传递。您将其用于正常的导航,就像 <a>
一样,只是用户可以通过表单在搜索参数中提供数据。除了搜索页面之外,它与 <form>
的使用非常罕见。
考虑这个表单
<form method="get" action="/search">
<label>Search <input name="term" type="text" /></label>
<button type="submit">Search</button>
</form>
当用户填写并点击提交时,浏览器会自动将表单值序列化为 URL 搜索参数字符串,并使用附加的查询字符串导航到表单的 action
。假设用户输入了“remix”。浏览器将导航到 /search?term=remix
。如果我们将输入更改为 <input name="q"/>
,则表单将导航到 /search?q=remix
。
这与我们创建此链接的行为相同
<a href="/search?term=remix">Search for "remix"</a>
唯一的区别是 **用户** 可以提供信息。
如果您有更多字段,浏览器将添加它们
<form method="get" action="/search">
<fieldset>
<legend>Brand</legend>
<label>
<input name="brand" value="nike" type="checkbox" />
Nike
</label>
<label>
<input name="brand" value="reebok" type="checkbox" />
Reebok
</label>
<label>
<input name="color" value="white" type="checkbox" />
White
</label>
<label>
<input name="color" value="black" type="checkbox" />
Black
</label>
<button type="submit">Search</button>
</fieldset>
</form>
根据用户点击的复选框,浏览器将导航到类似的 URL
/search?brand=nike&color=black
/search?brand=nike&brand=reebok&color=white
当您想要在网站上创建、删除或更新数据时,表单 POST 是最佳选择。我们不仅指用户配置文件编辑页面等大型表单。即使是“点赞”按钮也可以通过表单处理。
让我们考虑一个“新建项目”表单。
<form method="post" action="/projects">
<label><input name="name" type="text" /></label>
<label><textarea name="description"></textarea></label>
<button type="submit">Create</button>
</form>
当用户提交此表单时,浏览器会将字段序列化为请求“正文”(而不是 URL 搜索参数),并将其“POST”到服务器。这仍然是一个正常的导航,就像用户点击链接一样。区别在于两方面:用户为服务器提供了数据,并且浏览器将请求作为“POST”而不是“GET”发送。
数据可供服务器的请求处理程序使用,因此您可以创建记录。之后,您将返回一个响应。在这种情况下,您可能需要重定向到新创建的项目。一个 remix 操作将类似于以下内容
export async function action({
request,
}: ActionFunctionArgs) {
const body = await request.formData();
const project = await createProject(body);
return redirect(`/projects/${project.id}`);
}
浏览器从 /projects/new
开始,然后将表单数据发布到 /projects
,最后服务器将浏览器重定向到 /projects/123
。在整个过程中,浏览器会进入其正常的“加载”状态:地址进度条会填满,favicon 会变成一个旋转器,等等。这实际上是一个不错的用户体验。
如果您是 Web 开发新手,您可能从未以这种方式使用过表单。许多人一直都在使用
<form onSubmit={(event) => { event.preventDefault(); // good
luck! }} />
如果您是这种情况,当您看到使用浏览器(和 Remix)内置功能可以多么轻松地进行变异时,您一定会感到高兴!
我们将从头到尾构建一个变异,包括
您使用 Remix 的 <Form>
组件进行数据变异的方式与使用 HTML 表单的方式相同。不同之处在于,现在您可以访问挂起的表单状态,以构建更好的用户体验:例如上下文加载指示器和“乐观 UI”。
无论您使用 <form>
还是 <Form>
,您编写的代码都是一样的。您可以从 <form>
开始,然后将其升级到 <Form>
,而无需更改任何内容。之后,添加特殊的加载指示器和乐观 UI。但是,如果您不想这样做,或者截止日期很紧,只需使用 <form>
并让浏览器处理用户反馈!Remix <Form>
是变异“渐进增强”的实现。
让我们从之前的项目表单开始,但使其可用
假设您有路由 app/routes/projects.new.tsx
,其中包含此表单
export default function NewProject() {
return (
<form method="post" action="/projects/new">
<p>
<label>
Name: <input name="name" type="text" />
</label>
</p>
<p>
<label>
Description:
<br />
<textarea name="description" />
</label>
</p>
<p>
<button type="submit">Create</button>
</p>
</form>
);
}
现在添加路由操作。任何“post”提交的表单都会调用您的数据“操作”。任何“get”提交(<Form method="get">
)将由您的“加载器”处理。
import type { ActionFunctionArgs } from "@remix-run/node"; // or cloudflare/deno
import { redirect } from "@remix-run/node"; // or cloudflare/deno
// Note the "action" export name, this will handle our form POST
export const action = async ({
request,
}: ActionFunctionArgs) => {
const formData = await request.formData();
const project = await createProject(formData);
return redirect(`/projects/${project.id}`);
};
export default function NewProject() {
// ... same as before
}
就是这样!假设 createProject
按照我们的预期执行,这就是您需要做的全部工作。请注意,无论您过去构建了哪种 SPA,您始终需要一个服务器端操作和一个表单来获取用户数据。Remix 的不同之处在于,**这就是您所需要的全部**(这也是 Web 过去的样子)。
当然,我们开始复杂化事情,试图创造比默认浏览器行为更好的用户体验。继续前进,我们会到达那里,但我们不必更改任何已经编写的代码来获得核心功能。
通常在客户端和服务器端验证表单。它也(不幸的是)通常只在客户端进行验证,这会导致各种数据问题,我们现在没有时间深入讨论。重点是,如果您只在一个地方进行验证,请在服务器上进行。您会发现,使用 Remix,这是您唯一需要关心的地方(发送到浏览器的越少越好!)。
我们知道,我们知道,您想用漂亮的动画来显示验证错误等等。我们会做到这一点。但现在我们只是构建一个基本的 HTML 表单和用户流程。我们先保持简单,然后使其变得花哨。
回到我们的操作中,也许我们有一个 API 可以返回这样的验证错误。
const [errors, project] = await createProject(formData);
如果有验证错误,我们希望返回表单并显示它们。
import { json, redirect } from "@remix-run/node"; // or cloudflare/deno
export const action = async ({
request,
}: ActionFunctionArgs) => {
const formData = await request.formData();
const [errors, project] = await createProject(formData);
if (errors) {
const values = Object.fromEntries(formData);
return json({ errors, values });
}
return redirect(`/projects/${project.id}`);
};
就像 useLoaderData
返回 loader
中的值一样,useActionData
将返回操作中的数据。它只会在导航是表单提交时存在,因此您始终需要检查它是否存在。
import type { ActionFunctionArgs } from "@remix-run/node"; // or cloudflare/deno
import { json, redirect } from "@remix-run/node"; // or cloudflare/deno
import { useActionData } from "@remix-run/react";
export const action = async ({
request,
}: ActionFunctionArgs) => {
// ...
};
export default function NewProject() {
const actionData = useActionData<typeof action>();
return (
<form method="post" action="/projects/new">
<p>
<label>
Name:{" "}
<input
name="name"
type="text"
defaultValue={actionData?.values.name}
/>
</label>
</p>
{actionData?.errors.name ? (
<p style={{ color: "red" }}>
{actionData.errors.name}
</p>
) : null}
<p>
<label>
Description:
<br />
<textarea
name="description"
defaultValue={actionData?.values.description}
/>
</label>
</p>
{actionData?.errors.description ? (
<p style={{ color: "red" }}>
{actionData.errors.description}
</p>
) : null}
<p>
<button type="submit">Create</button>
</p>
</form>
);
}
请注意,我们如何将 defaultValue
添加到所有输入中。请记住,这是普通的 HTML <form>
,因此它只是正常的浏览器/服务器操作。我们从服务器获取值,因此用户不必重新输入他们输入的内容。
您可以按原样发布此代码。浏览器将为您处理挂起的 UI 和中断。享受您的周末,并在周一将其变得花哨。
<Form>
并添加挂起 UI让我们使用渐进增强来使此 UX 更花哨。通过将其从 <form>
更改为 <Form>
,Remix 将使用 fetch
模拟浏览器行为。它还将让您访问挂起的表单数据,以便您可以构建挂起 UI。
import { json, redirect } from "@remix-run/node"; // or cloudflare/deno
import { useActionData, Form } from "@remix-run/react";
// ...
export default function NewProject() {
const actionData = useActionData<typeof action>();
return (
// note the capital "F" <Form> now
<Form method="post">{/* ... */}</Form>
);
}
如果您没有时间或动力来完成这里剩下的工作,请使用 <Form reloadDocument>
。这将允许浏览器继续处理挂起的 UI 状态(选项卡中的 favicon 中的旋转器,地址栏中的进度条,等等)。如果您只是使用 <Form>
而不实现挂起 UI,用户将不知道他们在提交表单时发生了什么。
<Form reloadDocument>
属性。
现在让我们添加一些挂起 UI,以便用户在提交时知道发生了什么。有一个名为 useNavigation
的钩子。当有挂起的表单提交时,Remix 将为您提供表单的序列化版本,作为一个 FormData
对象。您最感兴趣的是 formData.get()
方法。
import { json, redirect } from "@remix-run/node"; // or cloudflare/deno
import {
useActionData,
Form,
useNavigation,
} from "@remix-run/react";
// ...
export default function NewProject() {
// when the form is being processed on the server, this returns different
// navigation states to help us build pending and optimistic UI.
const navigation = useNavigation();
const actionData = useActionData<typeof action>();
return (
<Form method="post">
<fieldset
disabled={navigation.state === "submitting"}
>
<p>
<label>
Name:{" "}
<input
name="name"
type="text"
defaultValue={
actionData
? actionData.values.name
: undefined
}
/>
</label>
</p>
{actionData && actionData.errors.name ? (
<p style={{ color: "red" }}>
{actionData.errors.name}
</p>
) : null}
<p>
<label>
Description:
<br />
<textarea
name="description"
defaultValue={
actionData
? actionData.values.description
: undefined
}
/>
</label>
</p>
{actionData && actionData.errors.description ? (
<p style={{ color: "red" }}>
{actionData.errors.description}
</p>
) : null}
<p>
<button type="submit">
{navigation.state === "submitting"
? "Creating..."
: "Create"}
</button>
</p>
</fieldset>
</Form>
);
}
非常棒!现在,当用户点击“创建”时,输入将变为禁用状态,提交按钮的文本将更改。整个操作现在应该更快,因为只有一个网络请求发生,而不是完整的页面重新加载(这可能涉及更多网络请求,从浏览器缓存中读取资产,解析 JavaScript,解析 CSS,等等)。
我们在这个页面上没有对 navigation
做太多操作,但它包含有关提交的所有信息(navigation.formMethod
、navigation.formAction
、navigation.formEncType
),以及服务器上正在处理的所有值,这些值位于 navigation.formData
上。
现在我们使用 JavaScript 提交此页面,我们的验证错误可以动画化,因为页面是有状态的。首先,我们将创建一个花哨的组件,它可以对高度和不透明度进行动画处理
function ValidationMessage({ error, isSubmitting }) {
const [show, setShow] = useState(!!error);
useEffect(() => {
const id = setTimeout(() => {
const hasError = !!error;
setShow(hasError && !isSubmitting);
});
return () => clearTimeout(id);
}, [error, isSubmitting]);
return (
<div
style={{
opacity: show ? 1 : 0,
height: show ? "1em" : 0,
color: "red",
transition: "all 300ms ease-in-out",
}}
>
{error}
</div>
);
}
现在,我们可以将旧的错误消息包装在这个新的花哨组件中,甚至可以将有错误的字段的边框变成红色
export default function NewProject() {
const navigation = useNavigation();
const actionData = useActionData<typeof action>();
return (
<Form method="post">
<fieldset
disabled={navigation.state === "submitting"}
>
<p>
<label>
Name:{" "}
<input
name="name"
type="text"
defaultValue={
actionData
? actionData.values.name
: undefined
}
style={{
borderColor: actionData?.errors.name
? "red"
: "",
}}
/>
</label>
</p>
{actionData?.errors.name ? (
<ValidationMessage
isSubmitting={navigation.state === "submitting"}
error={actionData?.errors?.name}
/>
) : null}
<p>
<label>
Description:
<br />
<textarea
name="description"
defaultValue={actionData?.values.description}
style={{
borderColor: actionData?.errors.description
? "red"
: "",
}}
/>
</label>
</p>
<ValidationMessage
isSubmitting={navigation.state === "submitting"}
error={actionData?.errors.description}
/>
<p>
<button type="submit">
{navigation.state === "submitting"
? "Creating..."
: "Create"}
</button>
</p>
</fieldset>
</Form>
);
}
太棒了!花哨的 UI,无需更改我们与服务器通信的方式。它也能够抵御阻止 JS 加载的网络状况。
首先,我们在没有 JavaScript 的情况下构建了项目表单。一个简单的表单,发布到服务器端操作。欢迎来到 1998 年。
一旦它工作了,我们就使用 JavaScript 通过将 <form>
更改为 <Form>
来提交表单,但我们不必做任何其他事情!
现在,由于有一个带有 React 的有状态页面,我们通过简单地向 Remix 请求导航状态,添加了加载指示器和验证错误的动画。
从您的组件的角度来看,发生的所有事情是 useNavigation
钩子在表单提交时导致状态更新,然后在数据返回时导致另一个状态更新。当然,在 Remix 内部发生了更多的事情,但就您的组件而言,就是这样。只是一些状态更新。这使得装饰任何用户流程变得非常容易。