如何用Zod与react-hook-form实现多图片上传?解决提交报错问题
React Hook Form + Zod 多文件上传问题解决与最佳实践
问题背景
我正在用React 18构建注册表单,采用react-hook-form和Zod做表单管理。
遇到的问题
我想允许用户上传多个附件,但不知道怎么用Zod处理这个需求。提交表单时遇到报错:Expected array, received object。
现有代码
Zod Schema
const submitSchema = z.object({ // Applicant company/organization, required org_name: z.string().min(1, { message: "Please describe the applicant company/organization." }), // Status, optional status: z.enum(["public", "private"]), // Item picture, required images: z .array(z.instanceof(File)) .refine( (files) => files.every((file) => file.size <= MAX_FILE_SIZE && ACCEPTED_IMAGE_TYPES.includes(file.type)), { message: "Item photo: Only .jpeg, .jpg, .png files of 2MB or less are accepted", } ), });
ImagesField 组件
type ImagesFieldProps = { className?: string; }; const ImagesField = ({ className }: ImagesFieldProps) => { const { control } = useFormContext<SubmitSchemaType>(); return ( <FormField label={ <div className="md:min-w-[150px]"> <BrowserView> 품목사진<div className="text-sm">(선택사항)</div> </BrowserView> <MobileView>품목사진 (선택사항)</MobileView> </div> } valueContent={ <div className="file-input-set-container flex flex-col gap-3"> <Controller name="images" control={control} render={({ field: { onChange, onBlur, name }, fieldState: { error } }) => ( <FileInput name={name} onChange={(e) => onChange([...(e.target.files ?? [])][0])} onDelete={() => onChange(undefined)} onBlur={onBlur} errorMessages={error?.message} /> )} /> <Controller name="images" control={control} render={({ field: { onChange, onBlur, name }, fieldState: { error } }) => ( <FileInput name={name} onChange={(e) => onChange([...(e.target.files ?? [])][0])} onDelete={() => onChange(undefined)} onBlur={onBlur} errorMessages={error?.message} /> )} /> </div> } className={className} noticeMessage="품목사진은 2M이하만 등록 가능합니다." /> ); };
问题原因
报错的核心原因是:
- 两个
Controller绑定了同一个name="images"字段,每次选择文件时只传入单个文件([...(e.target.files ?? [])][0]),删除时甚至传入undefined,和Zod定义的数组类型不匹配,导致验证失败。 - 组件标注图片为“선택사항”(可选),但Zod的
images字段是必填数组,未处理undefined的情况。
解决方案
1. 修正Zod Schema
先调整Schema,处理可选性,同时优化验证逻辑:
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB const ACCEPTED_IMAGE_TYPES = ["image/jpeg", "image/jpg", "image/png"]; const submitSchema = z.object({ org_name: z.string().min(1, { message: "请填写申请公司/组织名称。" }), status: z.enum(["public", "private"]), images: z .array(z.instanceof(File)) .optional() // 标记为可选字段 .default([]) // 或者用default给空数组,避免undefined .refine( (files) => { if (!files) return true; // 可选字段允许undefined return files.every(file => ACCEPTED_IMAGE_TYPES.includes(file.type)); }, { message: "仅支持 .jpeg, .jpg, .png 格式的图片" } ) .refine( (files) => { if (!files) return true; return files.every(file => file.size <= MAX_FILE_SIZE); }, { message: "每张图片大小不能超过2MB" } ), });
2. 重构ImagesField组件
根据需求选择以下两种方案:
方案一:支持动态添加/删除多文件(推荐)
使用useFieldArray更便捷地管理数组字段:
import { useFormContext, useFieldArray } from 'react-hook-form'; type ImagesFieldProps = { className?: string; }; const ImagesField = ({ className }: ImagesFieldProps) => { const { control } = useFormContext<SubmitSchemaType>(); const { fields, append, remove } = useFieldArray({ control, name: "images" }); return ( <FormField label={ <div className="md:min-w-[150px]"> <BrowserView> 품목사진<div className="text-sm">(선택사항)</div> </BrowserView> <MobileView>품목사진 (선택사항)</MobileView> </div> } valueContent={ <div className="file-input-set-container flex flex-col gap-3"> {fields.map((item, index) => ( <div key={item.id} className="flex items-center gap-2"> <Controller name={`images.${index}`} control={control} render={({ field: { onChange, value }, fieldState: { error } }) => ( <FileInput onChange={(e) => onChange(Array.from(e.target.files ?? [])[0])} onDelete={() => remove(index)} onBlur={() => {}} errorMessages={error?.message} /> )} /> </div> ))} <button type="button" className="mt-2 px-4 py-2 border rounded" onClick={() => append(null)} > 添加图片 </button> </div> } className={className} noticeMessage="품목사진은 2M이하만 등록 가능합니다." /> ); };
方案二:固定最多2张图片(适配原组件结构)
如果需要保留两个独立的输入框,修改onChange逻辑维护数组:
type ImagesFieldProps = { className?: string; }; const ImagesField = ({ className }: ImagesFieldProps) => { const { control } = useFormContext<SubmitSchemaType>(); return ( <FormField label={ <div className="md:min-w-[150px]"> <BrowserView> 품목사진<div className="text-sm">(선택사항)</div> </BrowserView> <MobileView>품목사진 (선택사항)</MobileView> </div> } valueContent={ <Controller name="images" control={control} render={({ field: { onChange, value, onBlur }, fieldState: { error } }) => ( <div className="file-input-set-container flex flex-col gap-3"> <FileInput onChange={(e) => { const file = Array.from(e.target.files ?? [])[0]; if (file) { const newImages = [...(value ?? [])]; newImages[0] = file; onChange(newImages); } }} onDelete={() => { const newImages = [...(value ?? [])]; newImages.splice(0, 1); onChange(newImages); }} onBlur={onBlur} errorMessages={error?.message} /> <FileInput onChange={(e) => { const file = Array.from(e.target.files ?? [])[0]; if (file) { const newImages = [...(value ?? [])]; newImages[1] = file; onChange(newImages); } }} onDelete={() => { const newImages = [...(value ?? [])]; newImages.splice(1, 1); onChange(newImages); }} onBlur={onBlur} errorMessages={error?.message} /> </div> )} /> } className={className} noticeMessage="품목사진은 2M이하만 등록 가능합니다." /> ); };
最佳实践
- 明确字段可选性:根据业务需求标记字段为
optional()或设置default([]),避免类型不匹配。 - 拆分验证规则:将文件类型、大小的验证拆分为独立的
refine,给出更精准的错误提示,提升用户体验。 - 使用
useFieldArray管理动态数组:对于需要动态添加/删除文件的场景,useFieldArray比手动维护数组更简洁、不易出错。 - 添加预览功能:上传后展示图片预览,允许用户删除对应文件,提升交互体验。
- 前端重复校验:可以通过文件名或文件hash检查是否已上传相同文件,避免重复提交。
- 结合后端校验:前端验证仅作为用户体验优化,后端必须再次校验文件的类型、大小和内容,确保安全性。
内容的提问来源于stack exchange,提问作者Byeongin Yoon
相关产品推荐
相关产品推荐

