对象展开顺序让服务端 UUID 被空字符串 id 悄悄覆盖
在一个后台管理系统的新增表单里,点击「保存」总是弹出「保存失败:id 缺失」。神奇的是,同一套 API handler,其他表单都好好的,偏偏这个表单不行。服务端日志里明明看到 INSERT 成功,200 响应也返回了,但前端拿到响应后判断 if (!created.id) 直接静默退出——弹窗没关、列表没刷新。
现象
- POST 请求返回 200,但前端的「新建成功」流程没有走完(弹窗未关闭、列表未刷新)
- 在响应体里打印
created.id,值是空字符串'' - 服务端确实执行了
INSERT,数据库里有这行数据,且 id 是正确的 UUID - 只有「新建」场景复现,「编辑」场景(id 有真实值)完全正常
根因
表单状态初始化时,create 模式的 id 字段默认值为空字符串:
const [form, setForm] = useState({ id: '', name: '', ... })这个 id: '' 随着表单 POST 到服务端。服务端 API Route 里:
const id = randomUUID()
await pool.execute('INSERT INTO items (id, name, ...) VALUES (?, ?, ...)', [id, body.name, ...])
return NextResponse.json({ id, ...body }) // 问题在这里{ id, ...body } 先写入 id(生成的 UUID),然后 ...body 把整个请求体展开——而 body.id 是 '',后展开的同名 key 覆盖了先写的 key,最终响应体变成 { id: '', name: '...', ... }。
JavaScript 对象展开的语义是「同名 key 取最后一个值」。
const id = 'uuid-123'
const body = { id: '', name: 'test' }
{ id, ...body } // => { id: '' } ← body.id 覆盖了生成的 id
{ ...body, id } // => { id: 'uuid-123' } ← 生成的 id 覆盖了 body.id{ id, ...body } 和 { ...body, id } 看起来只差位置,效果却截然相反。而且 id: '' 不会触发任何报错——它是合法的字符串,JSON.stringify 正常序列化,if (body.id) 还会因为空字符串 falsy 而在某些判断里静默跳过,总之没有任何噪音,只有数据悄悄出错。
正解
修复展开顺序:在响应体里让生成的 id 排在最后,覆盖掉 body.id:
// 修复前(生成的 id 被 body.id='' 覆盖)
return NextResponse.json({ id, ...body })
// 修复后(生成的 id 覆盖 body.id)
return NextResponse.json({ ...body, id })更防御的写法:在写入数据库前先清理 body 中的 id,杜绝歧义:
const rawClientId = typeof body.id === 'string' ? body.id.trim() : ''
const id = rawClientId || randomUUID() // 客户端有合法值就用,否则生成新 UUID
await pool.execute('INSERT INTO items (id, name, ...) VALUES (?, ?, ...)', [id, body.name, ...])
return NextResponse.json({ ...body, id })注意:不能只写 body.id || randomUUID()——纯空格字符串 ' ' 在 || 判断里是 truthy,会漏过去,.trim() 之后再判断才安全。
响应构造时用 { ...body, id } 而非 { id, ...body },保证服务端值优先级最高。同时在读取 body.id 前 .trim() 再判断长度,防止空白字符串绕过 falsy 检查。
排查
这类 bug 特别难发现,因为:
- HTTP 状态码是 200,服务端日志无报错
- 数据库里数据写进去了,记录存在
- 「编辑」模式(已有真实 id)完全正常,只有「新建」模式触发
- 前端的
if (!created.id)静默退出,没有任何错误提示
快速定位手段:在前端 POST 响应后立即 console.log(created) 打印完整响应体,对比 id 字段是否为空——一眼就能发现是响应体出了问题而非请求本身。
一句话外卖
服务端生成的
id要么不放进响应展开、要么放在展开的最后——永远让权威值覆盖外部输入,而不是被外部输入覆盖。