• Remix 开发小技巧(三)


    自动保存表单输入

    过去,用户在使用Adobe Photoshop或Microsoft Word等应用程序时经常明确按下保存按钮是很常见的。

    如果他们忘记保存,或者程序崩溃,或者他们覆盖了保存文件,他们将失去数小时或更长时间的工作。

    但随着世界转向基于云的应用程序,自动保存成为常态,用户开始期望如果他们进行更改,它会被记住。

    useSubmit 钩子在导航时被取消,但 useFetcher 没有

    以编程方式提交表单的自然选择是 useSubmit 挂钩,但它不是自动保存表单的好选择。

    Remix 使用全局导航状态,因此,如果您单击指向一个页面的链接,然后在加载之前单击指向其他页面的链接,则对第一页的请求将被取消。

    不幸的是, useSubmit 也使用相同的导航状态。如果您用于 useSubmit 提交表单,然后在表单完成之前导航到其他页面,则表单提交将被取消。

    这对于您明确提交的表单可能有意义,但对于预期会自动保存的输入,您不希望仅因为用户单击链接太快而保存失败。

    每个 useFetcher 钩子都有自己的状态,因此您可以使用它来发出请求,如果用户导航离开,该请求不会被取消。

    使用提取程序设置表单

    不要使用常规的 Remix 组件,而是使用提取程序返回的组件 fetcher.Form 。

    import { useFetcher } from "@remix-run/react"
    import { conform, useForm } from "@conform-to/react"
    export default function Example() {
      const fetcher = useFetcher()
      const [form, fields] = useForm<{
        email: string
        name: string
      }>()
      return (
        <fetcher.Form method="POST" {...form.props}>
          <input {...conform.input(fields.email)} />
          <input {...conform.input(fields.name)} />
          <button type="submit">
            {fetcher.state === "submitting"
              ? "Saving…"
              : "Save"}
          </button>
        </fetcher.Form>
      )
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    添加已退回的自动保存

    我们将使用增强的 useDebounceFetcher 钩子,它将自动对单个输入的提交进行去抖动。

    如果用户快速键入并无缝地按 Tab 键转到每个下一个输入,在填写大表单时从不暂停,则很多时间可能会因未保存而工作而过去。为了避免这种情况,我喜欢确保每个输入都是单独自动保存的。

    • 如果他们在键入时暂停片刻,输入将被保存。
    • 如果他们按 Tab 键转到下一个输入,则上一个输入将立即保存,而无需等待去抖动延迟通过。

    多亏了useDebounceFetcher钩子,我们只需要几行代码就可以做到这一点。

    创建一个 emailFetcher,并在电子邮件输入的 onChange 和 onBlur 处理程序中调用它。

    import { useFetcher } from "@remix-run/react"
    import { conform, useForm } from "@conform-to/react"
    import { useDebounceFetcher } from "./use-debounce-fetcher"
    export default function Example() {
      const fetcher = useFetcher()
      const [form, fields] = useForm<{
        email: string
        name: string
      }>()
      const emailFetcher = useDebounceFetcher()
      return (
        <fetcher.Form method="POST" {...form.props}>
          <input
            {...conform.input(fields.email)}
            onChange={(event) => {
              emailFetcher.debounceSubmit(
                event.currentTarget.form,
                {
                  replace: true,
                  debounceTimeout: 500,
                },
              )
            }}
            onBlur={(event) => {
              emailFetcher.debounceSubmit(
                event.currentTarget.form,
                {
                  replace: true,
                },
              )
            }}
          />
          <input {...conform.input(fields.name)} />
          <button type="submit">
            {fetcher.state === "submitting"
              ? "Saving…"
              : "Save"}
          </button>
        </fetcher.Form>
      )
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41

    您还需要对名称输入执行相同的操作,但现在是考虑如何抽象并避免重复的好时机。

    • prop 级抽象 – 创建一个接受获取器并返回对象的 { onChange(), onBlur() } 函数可以很好地工作。这将类似于函数 conform.input 。不过,您仍然需要为每个输入创建一个钩子。
    • 组件级抽象 – 创建一个输入组件,然后在组件中创建提取器和事件处理程序。

    我喜欢组件级抽象,因为它将所有内容都保存在一个地方,它还提供了一个添加其他功能或放置Tailwind CSS的地方。

    function CustomInput(
      props: React.InputHTMLAttributes<HTMLInputElement>,
    ) {
      const fetcher = useDebounceFetcher()
      return (
        <input
          className="block w-96 rounded border border-gray-300 px-4 py-3 focus:ring-1 focus:ring-indigo-600"
          {...props}
          onChange={(event) => {
            fetcher.debounceSubmit(event.currentTarget.form, {
              replace: true,
              debounceTimeout: 500,
            })
            // optional: call the onChange prop if it exists
            props.onChange?.(event)
          }}
          onBlur={(event) => {
            fetcher.debounceSubmit(event.currentTarget.form, {
              replace: true,
            })
            props.onBlur?.(event)
          }}
        />
      )
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    现在,您可以使用去抖动输入组件代替常规输入,它将自动保存更改和模糊。

    return (
      <fetcher.Form method="POST" {...form.props}>
        <DebouncedInput {...conform.input(fields.email)} />
        <DebouncedInput {...conform.input(fields.name)} />
      </fetcher.Form>
    )
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    使用 Conform、Zod 进行表单验证

    Conform 是一个表单验证库,可帮助您使用服务器端验证和客户端错误处理构建表单。它与Remix和Zod配合得很好。

    使用 Conform,您可以使用 Zod 表示您的表单架构。

    import { z } from "zod"
    const schema = z.object({
      title: z.string(),
      description: z.string().optional(),
      status: z.enum(["todo", "doing", "done"]),
    })
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    在您的表单组件中,使用 Conform 的 useForm 钩子来获取使表单工作所需的道具。

    • 该方法 onValidate 是我们使用它使用 zod 模式的地方。
    • lastSubmission 道具从动作中获取响应,以便它可以为我们处理错误。
    import { conform, useForm } from "@conform-to/react"
    import {
      getFieldsetConstraint,
      parse,
    } from "@conform-to/zod"
    export default function Example() {
      const actionData = useActionData<typeof action>()
      const [form, fields] = useForm({
        id: "example",
        onValidate({ formData }) {
          return parse(formData, { schema })
        },
        lastSubmission: actionData?.submission,
        shouldRevalidate: "onBlur",
      })
      return ()
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    接下来,我们将使用该 fields 对象来获取表单中每个字段所需的道具。

    • 传递给 form.props 组件。
    • 每个输入都会获得一个自动生成的 HTML ID。传递 fields.title.id 到 htmlFor 道具上的
    • 传递到 conform.input(fields.title)

    每个输入在 中 fields.title.errors 都有自己的错误列表。

    export default function Example() {
      const [form, fields] = useForm({})
      return (
        <Form method="POST" {...form.props}>
          <div>
            <label htmlFor={fields.title.id}>
              Title
            </label>
            <input {...conform.input(fields.title)} />
          </div>
          <div>
            <label htmlFor={fields.description.id}>
              Description
            </label>
            <input {...conform.input(fields.description)} />
            {fields.description.errors ? (
              <div role="alert">
                {fields.description.errors[0]}
              </div>
            ) : null}
          </div>
          <div>
            <label htmlFor={fields.status.id}>
              Status
            </label>
            <select {...conform.select(fields.status)}>
              <option value="todo">Todo</option>
              <option value="doing">Doing</option>
              <option value="done">Done</option>
            </select>
          </div>
          <button type="submit"> Submit </button>
        </Form>
      )
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35

    最后一步是创建一个将处理表单提交的操作。

    • 将 中的 @conform-to/zod parse 方法与架构一起使用
    • 失败的提交将具有空 value 属性,因此您可以使用该属性来处理错误。在响应中返回提交对象,以便表单可以显示错误。
    • 如果提交有效,则可以使用该 value 属性获取输入数据库所需的数据。
    import { parse } from "@conform-to/zod"
    export async function action({ request }: ActionArgs) {
      const formData = await request.formData()
      const submission = await parse(formData, { schema })
      if (!submission.value) {
        return json(
          { status: "error", submission },
          { status: 400 },
        )
      }
      const { title, description, status } = submission.value
      await db.todos.create({
        title,
        description,
        status,
      })
      return redirect("/todos")
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    使用 Zod 类型安全的环境变量

    环境变量是在运行时配置应用程序的一种方法。你可以告诉应用从环境中读取值,而不是将值硬编码到代码中。

    这对于 API 密钥、数据库凭据和其他不希望存储在代码库中的敏感信息非常有用。

    由于这些变量是在运行时提供的,而不是在构建时提供的,因此我们无法静态地保证将设置某些变量或它们将具有正确的类型。

    这意味着,如果您尝试在 IDE 中访问 process.env ,它不会为您自动完成变量,并且您不会进行任何类型检查。当您尝试访问变量时,您需要检查以确保在使用之前已定义它。

    创建名为 env.server.ts 的文件,我们将使用它来:

    • 如果缺少任何必需的环境变量,则在启动时使应用程序崩溃
    • 并为环境变量添加类型定义,以便我们可以在 IDE 中获得自动完成和类型检查

    为您的环境变量创建 Zod 数据结构

    为您的环境变量定义 Zod 架构。最简单的选择是将它们全部设置为 ,但如果需要 z.string() ,您可以通过格式检查和其他验证来获得更高级的服务。

    import { z } from "zod"
    const zodEnv = z.object({
      // Database
      DATABASE_URL: z.string(),
      // Cloudflare
      CLOUDFLARE_IMAGES_ACCOUNT_ID: z.string(),
      CLOUDFLARE_IMAGES_API_TOKEN: z.string(),
      // Sentry
      SENTRY_DSN: z.string(),
      SENTRY_RELEASE: z.string().optional(),
    })
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    修复进程.env以使用这些类型

    默认情况下, process.env 只是一个具有未知值的普通对象。由于我们强制要求环境变量与我们的 Zod 模式匹配,因此我们可以告诉 TypeScript 将其视为 process.env 具有这些类型。

    Zod的 TypeOf 实用程序会将我们的Zod模式转换为我们需要的Typescript类型。

    import { TypeOf } from "zod"
    declare global {
      namespace NodeJS {
        interface ProcessEnv extends TypeOf<typeof zodEnv> {}
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    如果缺少任何环境变量,则崩溃

    应用运行时无法修改环境变量。它们在启动时严格设置,因此如果不存在任何设置,我们将希望立即关闭应用程序。

    try {
      zodEnv.parse(process.env)
    } catch (err) {
      if (err instanceof z.ZodError) {
        const { fieldErrors } = err.flatten()
        const errorMessage = Object.entries(fieldErrors)
          .map(([field, errors]) =>
            errors ? `${field}: ${errors.join(", ")}` : field,
          )
          .join("\n  ")
        throw new Error(
          `Missing environment variables:\n  ${errorMessage}`,
        )
        process.exit(1)
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    在应用程序中尽早导入此模块。

    此代码在任何函数或类之外定义,因此它将在导入时立即运行。

    如果你正在做一个Node/Express应用程序,最好的位置是在 server.ts 文件中,或者与Remix App Server一起。 entry.server.ts 中加入:

    import "~/env.server"
    
    • 1

    完整代码:

    // app/env.server.ts
    import { z, TypeOf } from "zod"
    const zodEnv = z.object({
      // Database
      DATABASE_URL: z.string(),
      // Cloudflare
      CLOUDFLARE_IMAGES_ACCOUNT_ID: z.string(),
      CLOUDFLARE_IMAGES_API_TOKEN: z.string(),
      // Sentry
      SENTRY_DSN: z.string(),
      SENTRY_RELEASE: z.string().optional(),
    })
    declare global {
      namespace NodeJS {
        interface ProcessEnv extends TypeOf<typeof zodEnv> {}
      }
    }
    try {
      zodEnv.parse(process.env)
    } catch (err) {
      if (err instanceof z.ZodError) {
        const { fieldErrors } = err.flatten()
        const errorMessage = Object.entries(fieldErrors)
          .map(([field, errors]) =>
            errors ? `${field}: ${errors.join(", ")}` : field,
          )
          .join("\n  ")
        throw new Error(
          `Missing environment variables:\n  ${errorMessage}`,
        )
        process.exit(1)
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
  • 相关阅读:
    客户端存储localStorage和sessionStorage以及Cookie
    如何将OFD文件转成WORD?每天免费用
    Android View拖拽startDragAndDrop,Kotlin
    计算机内功修炼:程序的机器级表示(C与汇编)
    特斯拉/小鹏「调整」软件策略,智能驾驶「标配」进入全新周期
    重组件的优化和页面渲染十万条数据
    C++-头文件书写规范(二):头文件中的保护措施【#ifndef #define...#endif 】【防止多个源文件同时包含同一个头文件时产生的声明冲突】
    nodejs 简介
    高斯滤波算法及例程
    键盘切换不出中文输入法的解决方法
  • 原文地址:https://blog.csdn.net/jslygwx/article/details/133812848