过去,用户在使用Adobe Photoshop或Microsoft Word等应用程序时经常明确按下保存按钮是很常见的。
如果他们忘记保存,或者程序崩溃,或者他们覆盖了保存文件,他们将失去数小时或更长时间的工作。
但随着世界转向基于云的应用程序,自动保存成为常态,用户开始期望如果他们进行更改,它会被记住。
以编程方式提交表单的自然选择是 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>
)
}
我们将使用增强的 useDebounceFetcher 钩子,它将自动对单个输入的提交进行去抖动。
如果用户快速键入并无缝地按 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>
)
}
您还需要对名称输入执行相同的操作,但现在是考虑如何抽象并避免重复的好时机。
我喜欢组件级抽象,因为它将所有内容都保存在一个地方,它还提供了一个添加其他功能或放置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)
}}
/>
)
}
现在,您可以使用去抖动输入组件代替常规输入,它将自动保存更改和模糊。
return (
<fetcher.Form method="POST" {...form.props}>
<DebouncedInput {...conform.input(fields.email)} />
<DebouncedInput {...conform.input(fields.name)} />
</fetcher.Form>
)
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"]),
})
在您的表单组件中,使用 Conform 的 useForm 钩子来获取使表单工作所需的道具。
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 ( … )
}
接下来,我们将使用该 fields 对象来获取表单中每个字段所需的道具。
每个输入在 中 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>
)
}
最后一步是创建一个将处理表单提交的操作。
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")
}
环境变量是在运行时配置应用程序的一种方法。你可以告诉应用从环境中读取值,而不是将值硬编码到代码中。
这对于 API 密钥、数据库凭据和其他不希望存储在代码库中的敏感信息非常有用。
由于这些变量是在运行时提供的,而不是在构建时提供的,因此我们无法静态地保证将设置某些变量或它们将具有正确的类型。
这意味着,如果您尝试在 IDE 中访问 process.env ,它不会为您自动完成变量,并且您不会进行任何类型检查。当您尝试访问变量时,您需要检查以确保在使用之前已定义它。
创建名为 env.server.ts
的文件,我们将使用它来:
为您的环境变量定义 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(),
})
默认情况下, process.env 只是一个具有未知值的普通对象。由于我们强制要求环境变量与我们的 Zod 模式匹配,因此我们可以告诉 TypeScript 将其视为 process.env 具有这些类型。
Zod的 TypeOf 实用程序会将我们的Zod模式转换为我们需要的Typescript类型。
import { TypeOf } from "zod"
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)
}
}
在应用程序中尽早导入此模块。
此代码在任何函数或类之外定义,因此它将在导入时立即运行。
如果你正在做一个Node/Express应用程序,最好的位置是在 server.ts 文件中,或者与Remix App Server一起。 entry.server.ts
中加入:
import "~/env.server"
完整代码:
// 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)
}
}