找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1871

积分

0

好友

259

主题
发表于 2025-12-25 08:55:23 | 查看: 30| 回复: 0

在企业级应用的表单场景中,我们常常面临一种 “宽进严出” 的设计矛盾:

  1. 输入过程(宽):允许用户暂时输入不完整或格式错误的数据并自动保存,防止因页面刷新或意外跳转导致的数据丢失。
  2. 加载过程(唤醒):当用户刷新页面后,需要立即恢复之前的“错误状态”(如红色边框、校验提示),明确告知还有哪些项未完成。
  3. 提交过程(严):在最终提交(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'
        },
      },
    },
  },
});

2. 核心封装:useSmartForm Hook

这是本方案的灵魂所在。它封装了“数据回显”、“自动保存”、“校验唤醒”的所有核心逻辑。创建一个新文件: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 默认认为初始数据是有效的。我们在 useSmartFormuseEffect 中添加了关键一行:

setTimeout(() => form.trigger(), 0);

这行代码强制在数据回显后,立即运行一遍 Zod Schema 校验。因为从后端加载回来的可能是“脏数据”,Zod 会立即发现错误并更新 formState.errors,从而在 UI 上恢复错误提示状态。

B. 为什么自动保存不会被前端校验拦截?

我们使用了 form.watch() 结合 debounce 手动触发 onAutoSave 回调,而没有使用 form.handleSubmitform.handleSubmit 内部会先进行校验,失败则中止。我们的手动监听逻辑完全绕过了 RHF 的提交校验流程,从而实现了“带病保存”。

C. 方案的扩展性

对于新项目,你只需要遵循以下步骤:

  1. 编写 OpenAPI 规范。
  2. 运行 npx orval 生成代码。
  3. 在组件中引入 useSmartForm
  4. 传入生成的 Schema 和相应的 Query Hook。

无需再手动编写校验逻辑、防抖逻辑或复杂的数据回显逻辑,极大地提升了开发效率。

5. 后端配合建议

为了完美支持这套前端方案,后端的 PATCH 接口设计应遵循以下原则:

  1. 宽松校验(Loose Validation):允许字段缺失(Nullable),在保存阶段对格式的校验可以适当放宽(例如日期字段暂时存为字符串)。
  2. 严格终审(Strict Finalize):提供一个独立的 POST /finalizePOST /submit 接口,在此接口执行所有严格的业务规则校验。如果前端已通过 Zod 校验,后端严格校验通常也能通过,这构成了数据准确性的双重保障。



上一篇:Spark数仓开发简历优化指南:技术深度、业务价值与大厂面试核心
下一篇:MyBatis-Flex ORM框架深度解析:轻量设计、高性能CRUD与多表查询
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-10 18:28 , Processed in 0.305250 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2025 云栈社区.

快速回复 返回顶部 返回列表