基于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的类型安全设计原则。
针对方案二的优化点
统一表单值类型
目前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) })封装预览图逻辑为自定义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)优化表单重置逻辑
重置时传入明确的默认值,避免丢失编辑模式下的初始数据:reset({ name: product?.name ?? '', image: undefined });增强类型安全
避免非空断言,通过类型守卫确保编辑模式下product存在:if (!isAddMode && !product) { return <div>编辑模式下产品数据不能为空</div>; }
方案一的核心问题说明
方案一将已上传的图片URL重新转换为File对象,会带来不必要的网络请求和内存消耗,且不符合用户操作逻辑——编辑时用户无需更换图片,却要强制表单持有File对象,体验较差。
内容的提问来源于stack exchange,提问作者monotype
相关产品推荐
相关产品推荐

