You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

基于React-hook-form与Zod的增改产品表单图片上传方案咨询

在支持产品新增与编辑的表单中,基于React-hook-form和Zod处理图片上传的推荐方案是什么?现有两种实现方式,求优化建议?

方案一:将图片URL转换为File对象并设为表单默认值

const getFileFromUrl = async (url: string) => {
  const res = await fetch(url);
  const blob = await res.blob();
  return new File([blob], 'image', { type: blob.type });
};

const productForm1Schema = z.object({
  name: z.string().min(1, { message: 'Name is required' }),
  image: z
    .custom<File>((v) => v instanceof File, {
      message: 'Image is required',
    })
});

export type ProductForm1Values = z.infer<typeof productForm1Schema>;

interface ProductForm1Props {
  product?: Product;
}

export const ProductForm1 = ({ product }: ProductForm1Props) => {
  const isAddMode = !product;

  const {
    register,
    handleSubmit,
    control,
    watch,
    reset,
    formState: { errors, isSubmitting, isDirty },
  } = useForm<ProductForm1Values>({
    resolver: zodResolver(productForm1Schema),
    defaultValues: product
      ? async () => ({
          name: product.name,
          image: await getFileFromUrl(product.image),
        })
      : {
          name: '',
          image: undefined,
        },
  });

  const image = watch('image');
  const imagePreview = image ? URL.createObjectURL(image) : null;

  // 释放ObjectURL避免内存泄漏
  useEffect(() => {
    return () => {
      if (imagePreview) URL.revokeObjectURL(imagePreview);
    };
  }, [imagePreview]);

  const onSubmitHandler = async (data: ProductForm1Values) => {
    console.log(data);

    // 构建FormData用于图片上传
    const formData = new FormData();
    formData.append('file', data.image);

    // 模拟上传图片到服务器获取URL
    const imageUrl = await new Promise<string>((resolve) => {
      setTimeout(() => {
        resolve('https://via.placeholder.com/150');
      }, 1000);
    });

    if (isAddMode) {
      // 创建产品
      console.log({ ...data, image: imageUrl });
    } else {
      // 更新产品
      console.log({ id: product!.id, ...data, image: imageUrl });
    }

    reset();
  };

  return (
    <form onSubmit={handleSubmit(onSubmitHandler)}>
      <input {...register('name')} />
      {errors.name && <span>{errors.name.message}</span>}

      <Controller
        name="image"
        control={control}
        render={({ field: { ref, name, onBlur, onChange } }) => (
          <input
            type="file"
            ref={ref}
            name={name}
            onBlur={onBlur}
            onChange={(e) => onChange(e.target.files?.[0])}
          />
        )}
      />
      {imagePreview && <img src={imagePreview} alt="preview" />}
      {errors.image && <span>{errors.image.message}</span>}

      <button type="submit" disabled={(!isAddMode && !isDirty) || isSubmitting}>
        {isSubmitting ? 'Submitting...' : 'Submit'}
      </button>
    </form>
  );
};

方案二:为新增和更新产品分别创建Schema,更新Schema中图片为可选

const createProductSchema = z.object({
  name: z.string().min(1, { message: 'Name is required' }),
  image: z
    .custom<File>((v) => v instanceof File, {
      message: 'Image is required',
    })
});

const updateProductSchema = createProductSchema.extend({
  image: createProductSchema.shape.image.optional(),
});

export type ProductForm2Values =
  | z.infer<typeof createProductSchema>
  | z.infer<typeof updateProductSchema>;

interface ProductForm2Props {
  product?: Product;
}

export const ProductForm2 = ({ product }: ProductForm2Props) => {
  const [imagePreview, setImagePreview] = useState<string | null>(
    product ? product.image : null,
  );

  const isAddMode = !product;

  const {
    register,
    handleSubmit,
    control,
    reset,
    formState: { errors, isSubmitting, isDirty },
  } = useForm<ProductForm2Values>({
    resolver: zodResolver(
      isAddMode ? createProductSchema : updateProductSchema,
    ),
    defaultValues: {
      name: product?.name ?? '',
      image: undefined,
    },
  });

  // 释放ObjectURL避免内存泄漏
  useEffect(() => {
    return () => {
      if (imagePreview) URL.revokeObjectURL(imagePreview);
    };
  }, [imagePreview]);

  const onSubmitHandler = async (data: ProductForm2Values) => {
    console.log(data);

    let imageUrl: string | undefined;
    if (data.image) {
      // 构建FormData用于图片上传
      const formData = new FormData();
      formData.append('file', data.image);

      // 模拟上传图片到服务器获取URL
      imageUrl = await new Promise<string>((resolve) => {
        setTimeout(() => {
          resolve('https://via.placeholder.com/150');
        }, 1000);
      });
    }

    if (isAddMode) {
      // 创建产品
      console.log({ ...data, image: imageUrl! });
    } else {
      // 更新产品
      console.log({ id: product!.id, ...data, image: imageUrl });
    }

    reset();
    setImagePreview(product?.image ?? null);
  };

  return (
    <form onSubmit={handleSubmit(onSubmitHandler)}>
      <input {...register('name')} />
      {errors.name && <span>{errors.name.message}</span>}

      <Controller
        name="image"
        control={control}
        render={({ field: { ref, name, onBlur, onChange } }) => (
          <input
            type="file"
            ref={ref}
            name={name}
            onBlur={onBlur}
            onChange={(e) => {
              const file = e.target.files?.[0];
              onChange(e.target.files?.[0]);
              setImagePreview(file ? URL.createObjectURL(file) : null);
            }}
          />
        )}
      />
      {imagePreview && <img src={imagePreview} alt="preview" />}
      {errors.image && <span>{errors.image.message}</span>}

      <button type="submit" disabled={(!isAddMode && !isDirty) || isSubmitting}>
        {isSubmitting ? 'Submitting...' : 'Submit'}
      </button>
    </form>
  );
};

推荐方案与优化建议

推荐方案:优先选择方案二

方案二更贴合实际业务场景:

  • 编辑模式下用户无需重新上传原有图片,仅在需要更换时选择新文件,交互更友好;
  • 避免了方案一中从URL下载图片转File的额外网络请求和内存开销,性能更优;
  • 分场景定义Schema的方式逻辑清晰,符合Zod的类型安全设计原则。

针对方案二的优化点

  1. 统一表单值类型
    目前ProductForm2Values是联合类型,处理数据时需额外判断类型,建议合并为通用Schema,结合refine实现动态校验:

    const productFormSchema = z.object({
      name: z.string().min(1, { message: 'Name is required' }),
      image: z.custom<File | undefined>((v) => v === undefined || v instanceof File, {
        message: 'Image must be a valid file',
      }),
      _isAddMode: z.boolean()
    }).refine((data) => {
      // 新增模式下图片必填
      if (!data._isAddMode) return true;
      return data.image instanceof File;
    }, {
      message: 'Image is required',
      path: ['image']
    });
    

    在useForm中传入默认值:

    useForm({
      defaultValues: {
        name: product?.name ?? '',
        image: undefined,
        _isAddMode: isAddMode
      },
      resolver: zodResolver(productFormSchema)
    })
    
  2. 封装预览图逻辑为自定义Hook
    复用预览图管理逻辑,避免重复代码:

    const useImagePreview = (initialUrl?: string) => {
      const [previewUrl, setPreviewUrl] = useState<string | null>(initialUrl || null);
      
      useEffect(() => {
        return () => {
          // 仅释放通过File创建的URL,避免误删初始图片URL
          if (previewUrl && !initialUrl) {
            URL.revokeObjectURL(previewUrl);
          }
        };
      }, [previewUrl, initialUrl]);
      
      const updatePreview = (file?: File) => {
        setPreviewUrl(file ? URL.createObjectURL(file) : initialUrl || null);
      };
      
      return { previewUrl, updatePreview };
    };
    

    组件内使用:

    const { previewUrl, updatePreview } = useImagePreview(product?.image);
    // 在input onChange中调用updatePreview(file)
    
  3. 优化表单重置逻辑
    重置时传入明确的默认值,避免丢失编辑模式下的初始数据:

    reset({
      name: product?.name ?? '',
      image: undefined
    });
    
  4. 增强类型安全
    避免非空断言,通过类型守卫确保编辑模式下product存在:

    if (!isAddMode && !product) {
      return <div>编辑模式下产品数据不能为空</div>;
    }
    

方案一的核心问题说明

方案一将已上传的图片URL重新转换为File对象,会带来不必要的网络请求和内存消耗,且不符合用户操作逻辑——编辑时用户无需更换图片,却要强制表单持有File对象,体验较差。

内容的提问来源于stack exchange,提问作者monotype

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.28 23:47:36