React Hook Form表单提交为何以application/json发送并返回400错误?
我用react-hook-form开发酒店信息添加表单,包含图片上传字段。提交时把表单数据转成FormData发送到API,但请求的Content-Type被自动设为application/json; charset=utf-8,后端返回所有字段必填的400错误。手动设置Content-Type为multipart/formdata又出现「Multipart: Boundary not found」错误。
前端代码
表单处理逻辑
const formMethods = useForm<HotelFormData>(); const { handleSubmit } = formMethods; const onSubmit = handleSubmit((data: HotelFormData) => { const formData = new FormData(); formData.append("name", data.name); formData.append("city", data.city); formData.append("country", data.country); formData.append("description", data.description); formData.append("type", data.type); formData.append("pricePerNight", data.pricePerNight.toString()); formData.append("starRating", data.starRating.toString()); formData.append("adultCapacity", data.adultCapacity.toString()); formData.append("childCapacity", data.childCapacity.toString()); data.facilities.forEach((facility, index) => { formData.append(`facilities[${index}]`, facility); }); Array.from(data.imageFiles).forEach((imageFile) => { formData.append("imageFiles", imageFile); }); onSave(formData); });
API请求函数
export const addMyHotel = async (formData: FormData) => { const response = await fetch(`${BASE_URL}/api/my-hotels`, { method: "POST", credentials: "include", body: formData, }); const resBody = await response.json(); if (!response.ok) { throw new Error(resBody.message); } return resBody; };
后端API代码(/api/my-hotels)
import express from "express"; import { Request, Response } from "express"; import { PutObjectCommand } from "@aws-sdk/client-s3"; import mime from "mime-types"; import { client } from "../index"; import multer from "multer"; import Hotel, { HotelType } from "../models/hotel"; import verifyToken from "../middleware/auth"; import { body, validationResult } from "express-validator"; const router = express.Router(); const storage = multer.memoryStorage(); const upload = multer({ storage: storage, limits: { fileSize: 5 * 1024 * 1024, }, }); router.post( "/", verifyToken, [ body("name").notEmpty().withMessage("Name is required"), body("city").notEmpty().withMessage("City is required"), body("country").notEmpty().withMessage("Country is required"), body("description").notEmpty().withMessage("Description is required"), body("type").notEmpty().withMessage("Type is required"), body("pricePerNight") .notEmpty() .isNumeric() .withMessage("Price per night must be a number"), body("adultCapacity") .notEmpty() .isNumeric() .withMessage("Adult capacity must be a number"), body("childCapacity") .notEmpty() .isNumeric() .withMessage("Child capacity must be a number"), body("starRating") .notEmpty() .isNumeric() .withMessage("Star rating must be a number"), body("facilities") .notEmpty() .isArray() .withMessage("Facilities must be an array"), ], upload.array("imageFiles", 6), async (req: Request, res: Response) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res .status(400) .json({ message: "Bad Request", errors: errors.array() }); } try { const files = req.files as Express.Multer.File[]; const promises = files.map(async (file) => { const fileExt = mime.extension(file.mimetype); if (!fileExt) { throw new Error("Invalid file type"); } const params = { Bucket: process.env.S3_BUCKET as string, Key: `${Date.now()}.${fileExt}`, Body: file.buffer, ContentType: file.mimetype, }; await client.send(new PutObjectCommand(params)); const link = `https://${process.env.S3_BUCKET_NAME}.${process.env.S3_REGION}.amazonaws.com/${Date.now()}`; return link; }); const imageUrls = await Promise.all(promises); const newHotel: HotelType = req.body; newHotel.imageUrls = imageUrls; newHotel.lastUpdated = new Date(); newHotel.userId = req.userId; const hotel = new Hotel(newHotel); await hotel.save(); res.status(201).send(hotel); } catch (error) { console.log("error creating hotel", error); res.status(500).json({ message: "Internal server error" }); } } ); export default router;
返回的错误信息
Bad Request - errors:
0: {type: "field", msg: "Name is required", path: "name", location: "body"}
1: {type: "field", msg: "City is required", path: "city", location: "body"}
2: {type: "field", msg: "Country is required", path: "country", location: "body"}
3: {type: "field", msg: "Description is required", path: "description", location: "body"}
4: {type: "field", msg: "Type is required", path: "type", location: "body"}
5: {type: "field", msg: "Invalid value", path: "pricePerNight", location: "body"}
6: {type: "field", msg: "Price per night must be a number", path: "pricePerNight", location: "body"}
7: {type: "field", msg: "Invalid value", path: "adultCapacity", location: "body"}
8: {type: "field", msg: "Adult capacity must be a number", path: "adultCapacity", location: "body"}
9: {type: "field", msg: "Invalid value", path: "childCapacity", location: "body"}
10: {type: "field", msg: "Child capacity must be a number", path: "childCapacity", location: "body"}
11: {type: "field", msg: "Invalid value", path: "starRating", location: "body"}
12: {type: "field", msg: "Star rating must be a number", path: "starRating", location: "body"}
13: {type: "field", msg: "Invalid value", path: "facilities", location: "body"}
14: {type: "field", msg: "Facilities must be an array", path: "facilities", location: "body"}
请求Payload
name: My Hotel city: Iwdj country: Pdihuf description: poiuytrewqasdfghjkllllllllllllllllkmnbvcxz type: hotel pricePerNight: 7 starRating: 3 adultCapacity: 8 childCapacity: 7 facilities[0]: free parking facilities[1]: swimming pool imageFiles: (binary) imageFiles: (binary)
解决方案
核心问题分析
- Content-Type自动设为application/json:大概率是表单组件未正确设置
enctype,或请求拦截器强制修改了请求头。 - 手动设置multipart/formdata导致Boundary缺失:浏览器会自动为FormData生成带boundary的Content-Type(格式为
multipart/form-data; boundary=----WebKitFormBoundaryXXXX),手动设置会覆盖该头,导致后端无法解析表单数据。 - 后端中间件顺序错误:multer需要先解析FormData,才能让
express-validator拿到req.body中的字段,当前顺序导致验证时字段未被填充。
具体修复步骤
1. 正确设置表单enctype
如果使用原生form标签,必须指定enctype="multipart/form-data":
<form onSubmit={handleSubmit(onSubmit)} enctype="multipart/form-data"> {/* 表单字段 */} </form>
2. 禁止手动设置Content-Type
在API请求函数中,不要手动添加Content-Type头,让浏览器自动处理:
export const addMyHotel = async (formData: FormData) => { const response = await fetch(`${BASE_URL}/api/my-hotels`, { method: "POST", credentials: "include", body: formData, // 移除手动设置的Content-Type头 }); // ... 其他代码 };
3. 调整后端中间件顺序
将multer解析中间件移到验证规则前面,确保先解析FormData再验证字段:
router.post( "/", verifyToken, upload.array("imageFiles", 6), // 先解析FormData [ // 所有验证规则 body("name").notEmpty().withMessage("Name is required"), // ... 其他验证规则 ], async (req: Request, res: Response) => { // ... 处理逻辑 } );
4. 修复S3链接生成bug
当前代码中Date.now()执行两次,导致S3存储的文件名和返回的链接不一致,修改为:
const timestamp = Date.now(); const params = { Bucket: process.env.S3_BUCKET as string, Key: `${timestamp}.${fileExt}`, Body: file.buffer, ContentType: file.mimetype, }; await client.send(new PutObjectCommand(params)); const link = `https://${process.env.S3_BUCKET_NAME}.${process.env.S3_REGION}.amazonaws.com/${timestamp}.${fileExt}`;
验证修复效果
提交表单后,请求头的Content-Type应显示为带boundary的multipart/form-data格式,后端可正确解析req.body和req.files,验证通过后正常创建酒店并返回结果。
内容的提问来源于stack exchange,提问作者Noor Fatima

