在企业级应用的表单场景中,我们常常面临一种 “宽进严出” 的设计矛盾:
- 输入过程(宽):允许用户暂时输入不完整或格式错误的数据并自动保存,防止因页面刷新或意外跳转导致的数据丢失。
- 加载过程(唤醒):当用户刷新页面后,需要立即恢复之前的“错误状态”(如红色边框、校验提示),明确告知还有哪些项未完成。
- 提交过程(严):在最终提交(Finalize)时,必须通过所有严格的 Schema 校验,确保数据完整性与正确性。
本文将介绍一套基于 Schema-First(Orval) 的架构,并提供一个完整的 Hook 封装方案(useSmartForm) 来解决上述问题。

核心架构设计
下图清晰地展示了数据流与核心逻辑:

1. 基础设施配置 (Orval)
首先,确保你的 orval.config.ts 配置正确,以生成我们所需的 Zod Schema 和 React Query Hook。
// orval.config.ts
export default defineConfig({
sales: {
input: './docs/openapi-sales.yaml',
output: {
mode: 'tags-split',
target: './src/api/sales/generated',
client: 'react-query',
override: {
zod: true, // 必须开启Zod生成
mutator: {
path: './src/lib/axios-instance.ts',
name: 'customInstance'
},
},
},
},
});
这是本方案的灵魂所在。它封装了“数据回显”、“自动保存”、“校验唤醒”的所有核心逻辑。创建一个新文件:src/hooks/useSmartForm.ts。
import { useEffect, useRef, useCallback, useState } from "react";
import { useForm, UseFormProps, UseFormReturn, FieldValues, Path } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { ZodSchema } from "zod";
import { debounce, isEqual, cloneDeep } from "lodash";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
// 定义配置接口
interface UseSmartFormOptions<TData extends FieldValues> {
schema: ZodSchema<TData>; // Orval 生成的 Zod Schema
defaultValues?: Partial<TData>; // 数据源配置
queryData?: TData; // 来自 React Query useQuery 的 data
queryKey?: string[]; // 用于 invalidateQuery
// 自动保存配置
enableAutoSave?: boolean;
onAutoSave?: (data: Partial<TData>) => Promise<void>; // 你的 PATCH 方法
autoSaveDebounce?: number;
}
export function useSmartForm<TData extends FieldValues>({
schema,
defaultValues,
queryData,
queryKey,
enableAutoSave = true,
onAutoSave,
autoSaveDebounce = 1500
}: UseSmartFormOptions<TData>) {
// 1. 初始化 React Hook Form
const form = useForm<TData>({
resolver: zodResolver(schema),
defaultValues,
mode: "onChange", // 实时校验,为了让用户立即看到错误提示
});
const [isSaving, setIsSaving] = useState(false);
const [lastSavedTime, setLastSavedTime] = useState<Date | null>(null);
// Ref 用于防抖和 Diff 比较
const lastSavedRef = useRef<TData | null>(null);
const isFirstLoadRef = useRef(true);
// 2. 关键策略:数据回显与校验唤醒
useEffect(() => {
if (queryData) {
// A. 重置表单数据
// keepDirty: false 意味着重置后,当前数据被视为“干净”的基准数据
form.reset(queryData, { keepDirty: false });
// B. 初始化 Diff 基准
lastSavedRef.current = cloneDeep(queryData);
// C. 【核心需求】重新加载时保留校验提醒
// 如果不是第一次挂载,执行 trigger 以触发校验
setTimeout(() => {
form.trigger();
}, 0);
isFirstLoadRef.current = false;
}
}, [queryData, form]);
// 3. 自动保存逻辑 (基于 watch 而非 handleSubmit)
const watchedValues = form.watch(); // 监听所有字段变动
const debouncedSave = useRef(
debounce(async (currentValues: TData) => {
if (!onAutoSave) return;
if (!lastSavedRef.current) return;
// 3.1 Diff 算法 (只保存变动部分)
// 注意:这里需要根据业务实现具体的 sanitize (如去除只读字段/清洗空字符串)
const dirtyFields = Object.keys(currentValues).filter(key =>
!isEqual(currentValues[key], lastSavedRef.current?.[key])
);
if (dirtyFields.length === 0) return;
console.log("📝 Auto-saving fields:", dirtyFields);
setIsSaving(true);
try {
// 3.2 构造 Partial Payload
const payload: Partial<TData> = {};
dirtyFields.forEach(key => {
payload[key as keyof TData] = currentValues[key];
});
// 3.3 执行保存 (不走校验!)
await onAutoSave(payload);
// 3.4 更新状态
setLastSavedTime(new Date());
lastSavedRef.current = cloneDeep(currentValues);
// 可选:静默刷新 React Query 缓存
// if (queryKey) queryClient.invalidateQueries({ queryKey });
} catch (error) {
console.error("Auto-save failed", error);
toast.error("自动保存失败");
} finally {
setIsSaving(false);
}
}, autoSaveDebounce)
).current;
// 监听变化触发防抖保存
useEffect(() => {
// 首次加载不触发自动保存
if (isFirstLoadRef.current) return;
// 只有当有数据且开启自动保存时
if (enableAutoSave && onAutoSave && queryData) {
debouncedSave(watchedValues as TData);
}
return () => debouncedSave.cancel();
}, [watchedValues, enableAutoSave, onAutoSave, debouncedSave, queryData]);
return {
...form,
isSaving,
lastSavedTime,
// 封装一个 finalSubmit,用于最终的提交按钮
onFinalSubmit: (fn: (data: TData) => void) => form.handleSubmit(fn)
};
}
3. 业务侧使用示例
以下是在实际业务页面(如销售案例编辑)中的使用方式,代码变得极其简洁。
import { useSmartForm } from "@/hooks/useSmartForm";
// Orval 生成的导入
import {
useGetSalesCasesCaseIdWorkspace,
usePatchSalesCasesCaseIdWorkspace
} from "@/api/sales/generated/workbench";
import {
visaApplicationDataBody
} from "@/api/sales/generated/workbench.zod"; // 自动生成的 Zod Schema
export const CaseEditor = ({ caseId }: { caseId: string }) => {
// 1. React Query 获取数据
const { data: workspace } = useGetSalesCasesCaseIdWorkspace(caseId);
// 2. React Query Mutation
const { mutateAsync: patchWorkspace } = usePatchSalesCasesCaseIdWorkspace();
// 3. 使用 Smart Form
const {
register,
control,
formState: { errors, isValid }, // RHF 原生状态
isSaving,
lastSavedTime,
onFinalSubmit
} = useSmartForm({
// 绑定 Orval 生成的 Schema
schema: visaApplicationDataBody,
// 绑定数据源
queryData: workspace?.data,
// 定义自动保存逻辑
onAutoSave: async (partialData) => {
// 此处进行 API 适配,例如将嵌套对象转为后端需要的 targetKey 格式
const promises = Object.entries(partialData).map(([key, value]) =>
patchWorkspace({
caseId,
data: {
targetKey: key,
operation: 'UPDATE',
updateData: value
}
})
);
await Promise.all(promises);
}
});
// 4. 最终提交处理函数 (只有校验通过才能触发)
const handleFinalize = (data: any) => {
console.log("全量校验通过,提交归档!", data);
// 调用最终的提交归档 API...
};
if (!workspace) return <div>Loading...</div>;
return (
<div className="space-y-8">
{/* 顶部状态栏 */}
<div className="flex justify-between items-center sticky top-0 bg-white p-4 z-10 border-b">
<div className="text-sm text-gray-500">
{isSaving ? "正在保存..." : lastSavedTime ? `上次保存: ${lastSavedTime.toLocaleTimeString()}` : "准备就绪"}
</div>
<div className="flex gap-2">
{/* 此处的 isValid 是实时计算的。如果有校验错误(即便已自动保存),按钮也为禁用状态 */}
<Button
onClick={onFinalSubmit(handleFinalize)}
disabled={!isValid || isSaving}
>
提交归档
</Button>
</div>
</div>
{/* 表单区域 */}
<form>
<div className="grid gap-4">
{/* 场景模拟:
1. 用户输入 “invalid-email”,RHF 实时报错 (UI变红)。
2. useSmartForm 监听到变化,触发 onAutoSave。
3. API 发送 PATCH { email: “invalid-email” },后端接受并存储。
4. 用户刷新页面。
5. useSmartForm 加载数据 “invalid-email”。
6. useEffect 调用 form.trigger() 重新触发校验。
7. 页面再次显示红色错误提示:“Invalid Email”。
*/}
<Input {...register(“applicantName”)} placeholder=“申请人姓名” />
{errors.applicantName && <span className=“text-red-500”>{errors.applicantName.message}</span>}
<Input {...register(“email”)} placeholder=“邮箱” />
{errors.email && <span className=“text-red-500”>{errors.email.message}</span>}
</div>
</form>
</div>
);
};
4. 关键技术点解析
A. 如何实现“刷新后保留校验错误”?
传统的 form.reset(data) 仅填充数据,React Hook Form 默认认为初始数据是有效的。我们在 useSmartForm 的 useEffect 中添加了关键一行:
setTimeout(() => form.trigger(), 0);
这行代码强制在数据回显后,立即运行一遍 Zod Schema 校验。因为从后端加载回来的可能是“脏数据”,Zod 会立即发现错误并更新 formState.errors,从而在 UI 上恢复错误提示状态。
B. 为什么自动保存不会被前端校验拦截?
我们使用了 form.watch() 结合 debounce 手动触发 onAutoSave 回调,而没有使用 form.handleSubmit。form.handleSubmit 内部会先进行校验,失败则中止。我们的手动监听逻辑完全绕过了 RHF 的提交校验流程,从而实现了“带病保存”。
C. 方案的扩展性
对于新项目,你只需要遵循以下步骤:
- 编写 OpenAPI 规范。
- 运行
npx orval 生成代码。
- 在组件中引入
useSmartForm。
- 传入生成的 Schema 和相应的 Query Hook。
无需再手动编写校验逻辑、防抖逻辑或复杂的数据回显逻辑,极大地提升了开发效率。
5. 后端配合建议
为了完美支持这套前端方案,后端的 PATCH 接口设计应遵循以下原则:
- 宽松校验(Loose Validation):允许字段缺失(Nullable),在保存阶段对格式的校验可以适当放宽(例如日期字段暂时存为字符串)。
- 严格终审(Strict Finalize):提供一个独立的
POST /finalize 或 POST /submit 接口,在此接口执行所有严格的业务规则校验。如果前端已通过 Zod 校验,后端严格校验通常也能通过,这构成了数据准确性的双重保障。