React Router v7 已发布。 查看文档
数据写入
本页内容

数据写入

Remix 中的数据写入(有些人称之为突变)构建在两个基本的 Web API 之上:<form> 和 HTTP。然后,我们使用渐进增强来启用乐观 UI、加载指示器和验证反馈 - 但编程模型仍然构建在 HTML 表单之上。

当用户提交表单时,Remix 将

  1. 调用表单的操作
  2. 重新加载页面上所有路由的所有数据

很多时候,人们会使用 React 中的全局状态管理库(如 redux)、数据库(如 apollo)以及 fetch 封装器(如 React Query)来帮助管理将服务器状态引入组件并在用户更改时保持 UI 与之同步。Remix 基于 HTML 的 API 取代了这些工具的大部分用例。当您使用标准 HTML API 时,Remix 知道如何加载数据以及如何在更改后重新验证数据。

有几种方法可以调用操作并使路由重新验证

本指南仅涵盖 <Form>。我们建议您阅读本指南后面的其他两个文档,以了解如何使用它们。本指南的大部分内容适用于 useSubmit,但 useFetcher 有些不同。

纯 HTML 表单

在我们的公司 React Training 多年举办研讨会之后,我们了解到许多较新的 Web 开发人员(虽然他们没有错)实际上并不知道 <form> 的工作原理!

由于 Remix <Form> 的工作方式与 <form> 相同(有一些额外的优化 UI 等),我们将复习一下普通的 HTML 表单,以便您可以同时学习 HTML 和 Remix。

HTML 表单 HTTP 动词

原生表单支持两个 HTTP 动词:GETPOST。Remix 使用这些动词来理解您的意图。如果是 GET,Remix 将找出页面中哪些部分正在更改,并且仅获取正在更改的布局的数据,并将缓存的数据用于未更改的布局。如果是 POST,Remix 将重新加载所有数据,以确保它捕获来自服务器的更新。让我们看一下这两个。

HTML 表单 GET

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

HTML 表单 POST

当您想在您的网站上创建、删除或更新数据时,表单 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 开始,然后使用请求中的表单数据 POST 到 /projects,然后服务器将浏览器重定向到 /projects/123。在这一切发生时,浏览器会进入正常的“加载”状态:地址进度条会填满,网站图标会变成微调器等。这实际上是一种不错的用户体验。

如果您是 Web 开发新手,您可能从未以这种方式使用过表单。很多人一直这样做

<form onSubmit={(event) => { event.preventDefault(); // good
luck! }} />

如果您是这样,当您看到仅使用浏览器(和 Remix)内置的功能可以轻松进行突变时,您会感到高兴!

Remix 突变,从头到尾

我们将从头到尾构建一个突变,包括

  1. 可选的 JavaScript
  2. 验证
  3. 错误处理
  4. 渐进增强的加载指示器
  5. 渐进增强的错误显示

您使用 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”的表单提交都会调用您的数据“action”。任何“get”提交 (<Form method="get">) 都将由您的“loader”处理。

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 表单和用户流程。我们先保持简单,然后再变得花哨。

回到我们的 action 中,假设我们有一个 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 将返回 action 中的数据。它只会在导航是表单提交时才会存在,所以你必须始终检查是否得到了它。

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,那么你只会使 UX 变得更糟!

如果你没有时间或动力完成这里剩下的工作,请使用 <Form reloadDocument>。这可以让浏览器继续处理待处理的 UI 状态(标签页的图标中的微调器、地址栏中的进度条等)。如果只是使用 <Form> 而不实现待处理 UI,用户将不知道他们在提交表单时发生了任何事情。

我们建议始终使用大写的 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.formMethodnavigation.formActionnavigation.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 的情况下构建了项目表单。一个简单的表单,发布到服务器端的 action。欢迎来到 1998 年。

  • 一旦它起作用,我们就通过将 <form> 更改为 <Form> 来使用 JavaScript 提交表单,但我们不必做任何其他事情!

  • 现在,由于有一个带有 React 的有状态页面,我们通过简单地向 Remix 请求导航状态来为验证错误添加了加载指示器和动画。

从你的组件的角度来看,所有发生的事情都是当表单提交时 useNavigation 钩子导致了状态更新,然后当数据返回时又进行了另一个状态更新。当然,Remix 内部发生了很多事情,但就你的组件而言,仅此而已。只是几个状态更新。这使得修饰任何用户流程都非常容易。

另请参阅

文档和示例的许可协议为 MIT