React+TypeScript+Next.js表单文件提交客户端报错排查
问题排查:编辑表单图片上传客户端提前报错
问题背景
开发活动编辑表单模态组件时,其他字段可正常更新服务器,但上传新图片时请求未到达服务器就报错,try/catch捕获到的错误为undefined。创建活动的表单使用几乎相同的逻辑可正常上传图片,服务端通过sharp+S3处理文件并保存URL字符串。
相关代码
图片上传上传输入组件
<div className="mb-4"> <label className="block text-gray-700 text-sm font-bold mb-2">Image Upload</label> <input type="file" name="image_url" accept="images/*" key={inputKey} onChange={handleFileChange} className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" /> {error && <div style={{color: 'red'}}>{error}</div>} </div>
handleFileChange 方法
const handleFileChange = (event: any) => { const files = event.target.files; if (files) { const uploadedFile = files[0]; const fileExtention = uploadedFile.name.split(".").pop()?.toLocaleLowerCase(); if (fileExtention === 'heic' || fileExtention === 'heif') { setInputKey(Date.now()); setError('HEIC/HEIF images are not supported. Please upload a different file.') setFile(null) } else { setError(null) setFile(uploadedFile) } } }
clientAction 方法
const clientAction = async (formData: FormData) => { let formDate = formData.get("date") as string; const newDate = formDate.length > 1 ? new Date(formData.get("date") as unknown as Date) : eventData.date const newImg = file as File; const img = newImg && newImg.size ? newImg : formValues["image_url"] const updatedEvent = { title: formData.get("title") as string, date: newDate, doors_open: newDoorsOpenTime !== null ? newDoorsOpenTime : formData.get("doors_open") as string, start_time: newStartTime !== null ? newStartTime : formData.get("start_time") as string, description: formData.get("description") as string, venue_name: formData.get("venue_name") as string, address: formData.get("address") as string, image_url: img, ticket_link: formData.get("ticket_link") as string, ticket_price: formData.get("ticket_price") as string, } try { const response = await updateCurrentUserEvent(updatedEvent, eventData.id) if (response?.error) { console.log("ERROR!!!!") toast.error(response.error) } } catch (error) { console.error("ERROR!", error) } }
updateCurrentUserEvent 服务端方法
export async function updateCurrentUserEvent(eventData: any, eventId: number) { const currentEvent = await prisma.event.findUnique({ where: { id: eventId } }) let image_url: string | File | null = null let date; // if there is no image uploaded // use the oringal image_url if (!(eventData["image_url"] as File).size) { if (currentEvent?.image_url) { image_url = currentEvent.image_url } } else { image_url = eventData["image_url"] as File } // if there is no date chosen, // use the original date if (!(eventData["date"])) { date = currentEvent?.date; } const convertedStartTime = to24HourFormat(eventData["start_time"] as string) const convertedDoorsOpen = to24HourFormat(eventData["doors_open"] as string) const newEvent = { title: eventData["title"] as string, date: date ? date : new Date(eventData["date"] as unknown as Date), doors_open: convertedDoorsOpen, start_time: convertedStartTime, description: eventData["description"] as string, venue_name: eventData["venue_name"] as string, address: eventData["address"] as string, image_url, ticket_link: eventData["ticket_link"] as string, ticket_price: eventData["ticket_price"] as string, } // valitdate eventData to find errors const result = EventSchema.safeParse(newEvent); if (!result.success) { let errorMessage = ""; result.error.issues.forEach((issue) => { errorMessage = errorMessage + "•" + issue.message + "\n"; }) return { error: errorMessage } } // if data passed validation, take the uploaded file and // upload to S3 to get url. try { const file = result.data["image_url"] let newImage_url; if (file instanceof File) { const buffer = Buffer.from(await file.arrayBuffer()); newImage_url = await uploadFileToS3(buffer, file.name) } else { newImage_url = currentEvent?.image_url; } const newData = { ...result.data } await prisma.event.update({ where: { id: eventId }, data: { ...newData, image_url: newImage_url, } }) } catch (error) { console.error("ERROR", error); return { error: "The was an error Updating your event"} } }
控制台错误信息
ERROR! undefined app-index.js:33:21 error app-index.js:33 error hydration-error-info.js:45 overrideMethod myshows:13441 clientAction EditEventModal.tsx:116 startHostTransition react-dom.development.js:12693 startTransition react-dom.development.js:12590 startHostTransition react-dom.development.js:12690 submitForm react-dom.development.js:29444 callCallback react-dom.development.js:20498 invokeGuardedCallbackImpl react-dom.development.js:20547 invokeGuardedCallback react-dom.development.js:20622 invokeGuardedCallbackAndCatchFirstError react-dom.development.js:20636 executeDispatch react-dom.development.js:31986 processDispatchQueueItemsInOrder react-dom.development.js:32018 processDispatchQueue react-dom.development.js:32031 dispatchEventsForPlugins react-dom.development.js:32042 dispatchEventForPluginEventSystem react-dom.development.js:32232 batchedUpdates$1 react-dom.development.js:24843 batchedUpdates react-dom.development.js:28703 dispatchEventForPluginEventSystem react-dom.development.js:32231 dispatchEvent react-dom.development.js:29999 dispatchDiscreteEvent react-dom.development.js:29970 (Async: EventListener.handleEvent) addEventBubbleListener react-dom.development.js:30219 addTrappedEventListener react-dom.development.js:32135 listenToNativeEvent react-dom.development.js:32074 listenToAllSupportedEvents react-dom.development.js:32085 listenToAllSupportedEvents react-dom.development.js:32080 hydrateRoot$1 react-dom.development.js:37768 hydrateRoot react-dom.development.js:38354 hydrateRoot client.js:20 hydrate app-index.js:248 startTransition react.development.js:2661 hydrate app-index.js:248 <anonymous> app-next-dev.js:10 appBootstrap app-bootstrap.js:57 loadScriptsInSequence app-bootstrap.js:23 appBootstrap app-bootstrap.js:56 <anonymous> app-next-dev.js:8 NextJS 7

问题排查与修复方案
核心问题:File对象序列化失败
编辑表单中,你将File对象直接塞进了普通JS对象updatedEvent中传递给服务端,但Next.js的Client/Server Action无法直接序列化File类型,导致请求在客户端就抛出错误。创建表单能正常工作,大概率是直接传递FormData而非普通对象。
修复步骤
- 修改clientAction,用FormData包装所有数据
const clientAction = async (formData: FormData) => { // 构建新的FormData,整合原有字段和上传文件 const submitData = new FormData(); // 复制原表单所有字段 for (const [key, value] of formData.entries()) { submitData.append(key, value); } // 添加上传的文件(如果存在) if (file) { submitData.append('image_url', file); } try { const response = await updateCurrentUserEvent(submitData, eventData.id); if (response?.error) { toast.error(response.error); } } catch (error) { console.error("ERROR!", error); } }
- 调整服务端方法,从FormData中读取数据
export async function updateCurrentUserEvent(formData: FormData, eventId: number) { const currentEvent = await prisma.event.findUnique({ where: { id: eventId } }); // 从FormData中获取字段值 const title = formData.get('title') as string; const dateStr = formData.get('date') as string; const date = dateStr.length > 1 ? new Date(dateStr) : currentEvent?.date; const doors_open = formData.get('doors_open') as string; const start_time = formData.get('start_time') as string; const description = formData.get('description') as string; const venue_name = formData.get('venue_name') as string; const address = formData.get('address') as string; const ticket_link = formData.get('ticket_link') as string; const ticket_price = formData.get('ticket_price') as string; // 获取上传文件(可能为null) const imageFile = formData.get('image_url') as File | null; // 处理图片逻辑 let image_url: string | null = currentEvent?.image_url; if (imageFile instanceof File && imageFile.size > 0) { // 保留原S3上传逻辑 const buffer = Buffer.from(await imageFile.arrayBuffer()); image_url = await uploadFileToS3(buffer, imageFile.name); } // 时间格式转换 const convertedStartTime = to24HourFormat(start_time); const convertedDoorsOpen = to24HourFormat(doors_open); // 构建待验证的事件对象 const newEvent = { title, date, doors_open: convertedDoorsOpen, start_time: convertedStartTime, description, venue_name, address, image_url, ticket_link, ticket_price, }; // 验证逻辑不变 const result = EventSchema.safeParse(newEvent); if (!result.success) { const errorMessage = result.error.issues.map(issue => `•${issue.message}`).join("\n"); return { error: errorMessage }; } // 更新数据库 try { await prisma.event.update({ where: { id: eventId }, data: result.data, }); } catch (error) { console.error("ERROR", error); return { error: "There was an error updating your event" }; } }
其他排查点
- 检查
EventSchema是否对image_url的类型做了限制,确保兼容字符串(URL)和File对象的场景 - 错误栈中出现
hydration-error-info.js,可临时移除inputKey的动态更新,排查是否存在 hydration 不匹配问题
内容的提问来源于stack exchange,提问作者NattyCode
相关产品推荐
相关产品推荐

