数据写入
On this page

数据写入

¥Data Writes

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

¥Data writes (some people call these mutations) in Remix are built on top of two fundamental web APIs: <form> and HTTP. We then use progressive enhancement to enable optimistic UI, loading indicators, and validation feedback--but the programming model is still built on HTML forms.

当用户提交表单时,Remix 将:

¥When the user submits a form, Remix will:

  1. 调用表单的操作

    ¥Call the action for the form

  2. 重新加载页面上所有路由的所有数据

    ¥Reload all the data for all the routes on the page

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

¥Many times people reach for global state management libraries in React like redux, data libs like apollo, and fetch wrappers like React Query to help manage getting server state into your components and keeping the UI in sync with it when the user changes it. Remix's HTML based API replaces the majority of use cases for these tools. Remix knows how to load the data as well as how to revalidate it after it changes when you use standard HTML APIs.

有几种方法可以调用操作并获取要重新验证的路由:

¥There are a few ways to call an action and get the routes to revalidate:

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

¥This guide only covers <Form>. We suggest you read the docs for the other two after this guide to get a sense of how to use them. Most of this guide applies to useSubmit but useFetcher is a bit different.

纯 HTML 表单

¥Plain HTML Forms

在多年与 React 培训 公司合作开展教学研讨会之后,我们了解到,许多新晋 Web 开发者(尽管这并不是他们的错)实际上并不了解 <form> 的工作原理!

¥After teaching workshops with our company React Training for years, we've learned that a lot of newer web developers (though no fault of their own) don't actually know how <form> works!

由于 Remix <Form> 的工作原理与 <form> 完全相同(此外还有一些额外的功能,例如优化 UI),我们 '我们将复习一下普通的 ol' HTML 表单,因此你可以同时学习 HTML 和 Remix。

¥Since Remix <Form> works identically to <form> (with a couple of extra goodies for optimistic UI etc.), we're going to brush up on plain ol' HTML forms, so you can learn both HTML and Remix at the same time.

HTML 表单 HTTP 动词

¥HTML Form HTTP Verbs

原生表单支持两个 HTTP 动词:GETPOST。Remix 使用这些动词来理解你的意图。如果是 GET 请求,Remix 会判断页面哪些部分发生了变化,并仅获取变化布局的数据,而对不变的布局使用缓存数据。当它是 POST 时,Remix 将重新加载所有数据以确保它捕获了来自服务器的更新。我们来分别看看这两个文件。

¥Native forms support two HTTP verbs: GET and POST. Remix uses these verbs to understand your intent. If it's a GET, Remix will figure out what parts of the page are changing and only fetch the data for the changing layouts, and use the cached data for the layouts that don't change. When it's a POST, Remix will reload all data to ensure it captures the update from the server. Let's take a look at both.

HTML 表单 GET

¥HTML Form GET

GET 只是一个普通的导航,表单数据通过 URL 搜索参数传递。你可以使用它进行常规导航,就像 <a> 一样,只是用户可以通过表单提供搜索参数中的数据。除了搜索页面外,它在 <form> 中的使用非常少见。

¥A GET is just a normal navigation where the form data is passed in the URL search params. You use it for normal navigation, just like <a> except the user gets to provide the data in the search params through the form. Aside from search pages, its use with <form> is pretty rare.

考虑此表单:

¥Consider this 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

¥When the user fills it out and clicks submit, the browser automatically serializes the form values into a URL search param string and navigates to the form's action with the query string appended. Let's say the user typed in "remix". The browser would navigate to /search?term=remix. If we changed the input to <input name="q"/> then the form would navigate to /search?q=remix.

它的行为与我们创建此链接的行为相同:

¥It's the same behavior as if we had created this link:

<a href="/search?term=remix">Search for "remix"</a>

唯一的区别在于用户需要提供信息。

¥With the unique difference that the user got to supply the information.

如果你有更多字段,浏览器会添加它们:

¥If you have more fields, the browser will add them:

<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:

¥Depending on which checkboxes the user clicks, the browser will navigate to URLs like:

/search?brand=nike&color=black
/search?brand=nike&brand=reebok&color=white

HTML 表单 POST

¥HTML Form POST

当你想要在网站上创建、删除或更新数据时,表单提交是最佳选择。我们指的不仅仅是像用户个人资料编辑页面这样的大型表单。即使是 "喜欢" 按钮也可以通过表单处理。

¥When you want to create, delete, or update data on your website, a form post is the way to go. And we don't just mean big forms like a user profile edit page. Even "Like" buttons can be handled with a form.

我们来考虑一个 "新项目" 表单。

¥Let's consider a "new project" form.

<form method="post" action="/projects">
  <label><input name="name" type="text" /></label>
  <label><textarea name="description"></textarea></label>
  <button type="submit">Create</button>
</form>

当用户提交此表单时,浏览器会将字段序列化为请求 "body"(而不是 URL 搜索参数),并将其 "POST" 发送到服务器。这仍然是一个正常的导航,就像用户点击链接一样。区别在于两点:用户向服务器提供了数据,并且浏览器以 "POST" 而不是 "GET" 发送了请求。

¥When the user submits this form, the browser will serialize the fields into a request "body" (instead of URL search params) and "POST" it to the server. This is still a normal navigation as if the user clicked a link. The difference is two-fold: the user provided the data for the server and the browser sent the request as a "POST" instead of a "GET".

数据可供服务器的请求处理程序使用,因此你可以创建记录。之后,返回响应。在这种情况下,你可能会重定向到新创建的项目。Remix 操作可能如下所示:

¥The data is made available to the server's request handler, so you can create the record. After that, you return a response. In this case, you'd probably redirect to the newly-created project. A remix action would look something like this:

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。当这一切发生时,浏览器将进入其正常的 "loading" 状态:地址进度条填满,图标变成旋转图标,等等。这实际上是一种不错的用户体验。

¥The browser started at /projects/new, then posted to /projects with the form data in the request, then the server redirected the browser to /projects/123. While this is all happening, the browser goes into its normal "loading" state: the address progress bar fills up, the favicon turns into a spinner, etc. It's actually a decent user experience.

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

¥If you're newer to web development, you may not have ever used a form this way. Lots of folks have always done:

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

如果你遇到这种情况,你会很高兴地发现,当你使用浏览器(和 Remix)内置的功能时,修改代码是多么容易!

¥If this is you, you're going to be delighted when you see just how easy mutations can be when you just use what browsers (and Remix) have built in!

Remix 修改,从头到尾

¥Remix Mutation, Start to Finish

我们将从头到尾构建一个变更,代码如下:

¥We're going to build a mutation from start to finish with:

  1. JavaScript 可选

    ¥JavaScript optional

  2. 验证

    ¥Validation

  3. 错误处理

    ¥Error handling

  4. 渐进式增强的加载指示器

    ¥Progressively-enhanced loading indicators

  5. 渐进式增强的错误显示

    ¥Progressively-enhanced error display

你可以使用 Remix <Form> 组件进行数据变更,就像使用 HTML 表单一样。区别在于,现在你可以访问待处理的表单状态,从而构建更佳的用户体验:例如上下文加载指示器和 "乐观 UI"。

¥You use the Remix <Form> component for data mutations the same way you use HTML forms. The difference is now you get access to pending form state to build a nicer user experience: like contextual loading indicators and "optimistic UI".

无论你使用 <form> 还是 <Form>,编写的代码都相同。你可以从 <form> 开始,然后将其升级到 <Form>,而无需进行任何更改。之后,添加特殊的加载指示器和乐观的 UI。但是,如果你不想这样做,或者截止日期很紧,只需使用 <form>,让浏览器处理用户反馈即可!Remix <Form> 是 "渐进增强" 的突变实现。

¥Whether you use <form> or <Form> though, you write the very same code. You can start with a <form> and then graduate it to <Form> without changing anything. After that, add in the special loading indicators and optimistic UI. However, if you're not feeling up to it, or deadlines are tight, just use a <form> and let the browser handle the user feedback! Remix <Form> is the realization of "progressive enhancement" for mutations.

构建表单

¥Building the form

我们先从之前的项目表单开始,但要让它可用:

¥Let's start with our project form from earlier but make it usable:

假设你有包含以下表单的路由 app/routes/projects.new.tsx

¥Let's say you've got the route app/routes/projects.new.tsx with this form in it:

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" 处理。

¥Now add the route action. Any form submissions that are "post" will call your data "action". Any "get" submissions (<Form method="get">) will be handled by your "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 过去也是如此)。

¥And that's it! Assuming createProject does what we want it to, that's all you have to do. Note that no matter what kind of SPA you may have built in the past, you always need a server-side action and a form to get data from the user. The difference with Remix is that's all you need (and that's how the web used to be, too.)

当然,我们开始使事情变得复杂,以尝试创造比默认浏览器行为更好的用户体验。与 钩子从 获取数据, 钩子从 获取数据一样, 从 钩子获取抛出的实例。

¥Of course, we started complicating things to try to create better user experiences than the default browser behavior. Keep going, we'll get there, but we won’t have to change any of the code we've already written to get the core functionality.

表单验证

¥Form Validation

通常会在客户端和服务器端验证表单。遗憾的是,只在客户端验证也很常见,这会导致数据出现各种问题,我们现在没时间深入探讨。重点是,如果你只在一个地方验证,请在服务器上进行。你会发现,有了 Remix,你现在唯一需要关心的地方就是它了(你发送到浏览器的信息越少越好!)。

¥It's common to validate forms both client-side and server-side. It's also (unfortunately) common to only validate client-side, which leads to various issues with your data that we don't have time to get into right now. Point is, if you're validating in only one place, do it on the server. You'll find with Remix that's the only place you care to anymore (the less you send to the browser the better!).

我们知道,我们知道你希望在验证错误和其他内容中添加动画效果。我们会做到的。但现在我们只是在构建一个基本的 HTML 表单和用户流程。我们先保持简单,然后再使其更美观。

¥We know, we know, you want to animate in nice validation errors and stuff. We'll get to that. But right now we're just building a basic HTML form and user flow. We'll keep it simple first, then make it fancy.

回到我们的操作中,也许我们有一个 API 会返回类似这样的验证错误。

¥Back in our action, maybe we have an API that returns validation errors like this.

const [errors, project] = await createProject(formData);

如果出现验证错误,我们希望返回表单并显示它们。

¥If there are validation errors, we want to go back to the form and display them.

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 类似,只是它不会引起导航。只有当导航是表单提交时,它才会出现,因此你必须始终检查是否已获取到它。

¥Just like useLoaderData returns the values from the loader, useActionData will return the data from the action. It will only be there if the navigation was a form submission, so you always have to check if you've got it or not.

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>,因此这只是浏览器/服务器的正常操作。我们正在从服务器获取值,这样用户就不必重新输入他们之前输入的内容了。

¥Notice how we add defaultValue to all of our inputs. Remember, this is regular HTML <form>, so it's just normal browser/server stuff happening. We're getting the values back from the server so the user doesn't have to re-type what they had.

你可以按原样发布此代码。浏览器将为你处理待处理的 UI 和中断。享受你的周末,并在周一让它变得精彩。

¥You can ship this code as-is. The browser will handle the pending UI and interruptions for you. Enjoy your weekend and make it fancy on Monday.

升级到 <Form> 并添加待处理 UI

¥Graduate to <Form> and add pending UI

我们来使用渐进式增强功能,让用户体验更加出色。通过将其从 <form> 更改为 <Form>,Remix 将使用 fetch 模拟浏览器行为。它还会允许你访问待处理的表单数据,以便你可以构建待处理的 UI。

¥Let's use progressive enhancement to make this UX a bit more fancy. By changing it from <form> to <Form>, Remix will emulate the browser behavior with fetch. It will also give you access to the pending form data, so you can build pending 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,用户体验会更差!

如果你没有时间或动力完成此处的其余工作,请使用 <Form reloadDocument>。这让浏览器能够继续处理待处理的 UI 状态(例如,标签页图标中的旋转器、地址栏中的进度条等)。如果你只是使用 <Form> 而不实现待处理的 UI,用户在提交表单时将不会知道发生了什么。

¥If you don't have the time or drive to do the rest of the job here, use <Form reloadDocument>. This lets the browser continue to handle the pending UI state (spinner in the favicon of the tab, progress bar in the address bar, etc.) If you simply use <Form> without implementing pending UI, the user will have no idea anything is happening when they submit a form.

我们建议始终使用大写字母 F 的 Form,如果你想让浏览器处理待处理的 UI,请使用 <Form reloadDocument> 属性。

现在,让我们添加一些待处理的 UI,以便用户在提交时了解发生了什么。有一个名为 useNavigation 的钩子。当有待提交的表单时,Remix 会将表单的序列化版本作为 FormData 对象返回。你最感兴趣的是 formData.get() 方法。

¥Now let's add some pending UI so the user has a clue something happened when they submit. There's a hook called useNavigation. When there is a pending form submission, Remix will give you the serialized version of the form as a FormData object. You'll be most interested in the formData.get() method.

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 等),因此整个操作现在应该会更快。

¥Pretty slick! Now when the user clicks "Create", the inputs go disabled, and the submit button's text changes. The whole operation should be faster now too since there's just one network request happening instead of a full page reload (which involves potentially more network requests, reading assets from the browser cache, parsing JavaScript, parsing CSS, etc.).

在这个页面上,我们没有对 navigation 做太多改动,但它包含了关于提交的所有信息(navigation.formMethodnavigation.formActionnavigation.formEncType),以及服务器上正在处理的所有值(navigation.formData)。

¥We didn't do much with navigation on this page, but it's got all the information about the submission (navigation.formMethod, navigation.formAction, navigation.formEncType), as well as all the values being processed on the server on navigation.formData.

验证错误动画

¥Animating in the Validation Errors

现在我们使用 JavaScript 提交此页面,由于页面是有状态的,因此验证错误可以添加动画效果。首先,我们将创建一个用于设置高度和透明度动画的精美组件:

¥Now that we're using JavaScript to submit this page, our validation errors can be animated in because the page is stateful. First we'll make a fancy component that animates height and opacity:

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>
  );
}

现在我们可以将旧的错误消息封装在这个新的组件中,甚至可以将出现错误的字段边框变为红色:

¥Now we can wrap our old error messages in this new fancy component, and even turn the borders of our fields red that have errors:

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 加载的网络状况。

¥Boom! Fancy UI without having to change anything about how we communicate with the server. It's also resilient to network conditions that prevent JS from loading.

审核

¥Review

  • 首先,我们在没有考虑 JavaScript 的情况下构建了项目表单。一个简单的表单,发布到服务器端操作。欢迎来到 1998。

    ¥First we built the project form without JavaScript in mind. A simple form, posting to a server-side action. Welcome to 1998.

  • 成功后,我们使用 JavaScript 将 <form> 更改为 <Form> 来提交表单,无需执行任何其他操作!

    ¥Once that worked, we used JavaScript to submit the form by changing <form> to <Form>, but we didn't have to do anything else!

  • 现在,我们已经有了一个使用 React 构建的有状态页面,我们只需向 Remix 获取导航状态,即可为验证错误添加加载指示和动画。

    ¥Now that there was a stateful page with React, we added loading indicators and animation for the validation errors by simply asking Remix for the state of the navigation.

从组件的角度来看,发生的一切都是 useNavigation 钩子在表单提交时引发了一次状态更新,然后在数据返回时又引发了一次状态更新。当然,Remix 内部还有很多其他功能,但就你的组件而言,仅此而已。仅包含一些状态更新。这使得修饰任何用户流程变得非常容易。

¥From your components perspective, all that happened was the useNavigation hook caused a state update when the form was submitted, and then another state update when the data came back. Of course, a lot more happened inside of Remix, but as far as your component is concerned, that's it. Just a couple of state updates. This makes it really easy to dress up any user flow.

另请参阅

¥See also

Remix v2.17 中文网 - 粤ICP备13048890号