返回博客

下拉 API 字段投影过窄,undefined 静默渲染成 0

某天运营反馈:某个后台管理页面里选完礼品后,弹窗显示"当前库存仅 0 份"——但打开商品列表页,同一个礼品库存明明显示 7 份。数据库直查也是 7。没有报错,没有 console warn,Network 返回 200,前端代码也没 bug……

这种"一边正常一边为 0"的症状,根因几乎必定在 API 字段投影层

现象

  • 列表页 / 详情页:库存、价格、封面图正常显示
  • 新做的选品弹窗:选中后所有数字字段显示 0,图片字段空白
  • 浏览器 Network 面板:两个接口都返回 HTTP 200,数据结构看似正常
  • Console:无任何 undefinedNaN 错误

根因

这个"轻量下拉"接口最初只用于纯下拉框——只要 id 和显示文本,后端就这样写死了:

// ThinkPHP 查询构造器
$list = $query->field('id, title')->select();

field('id, title')显式白名单投影,ORM 生成的 SQL 是 SELECT id, title FROM ...,其他字段在数据库层就已经被丢弃,根本不会出现在返回的 JSON 里。

后来新页面复用了这个接口,需要从返回数据里读 gift_number(库存)、integral(积分价)、cover_image(封面)。但字段白名单没同步更新,前端拿到的每条 record 结构其实是:

{ "id": "xxx", "title": "精美礼品A" }

访问 record.gift_number 得到 undefined。然后前端的展示代码是这样写的:

// 常见的"防空"写法
const stock = +record.gift_number || 0
坑:undefined 被 JavaScript 静默转换为 0

+undefinedNaNNaN 是 falsy,NaN || 00

所以 +record.gift_number || 0 在字段缺失时返回 0,而不是抛错。

同理:

  • record.field ?? 0 → 仅拦截 null/undefined,确实返回 0
  • Vue 模板 {{ row.gift_number }}undefined 渲染为空字符串
  • Number(undefined)NaN,再被格式化函数兜底为 0

这层隐式转换让"字段根本没返回"和"字段值确实是 0"在 UI 上完全无法区分,也不会触发任何运行时错误。

这也解释了为什么列表页正常:列表页用的是另一个接口,那个接口的 field() 白名单是完整的。两个接口返回的字段集合不同,表现自然不同。

排查

在浏览器 console 里打印选中后的 record 对象:

// 挂在下拉 change 事件里
console.log('record keys:', Object.keys(selectedRecord))
console.log('gift_number:', selectedRecord.gift_number)

如果输出只有 ['id', 'title'],缺少业务字段,就确认是本问题。

正解

后端 field() 补齐下游消费所需的字段:

// 修复前
$list = $query->field('id, title')->select();
 
// 修复后:补全字段,对老消费者是 superset,不影响老用法
$list = $query
    ->field('id, title, cover_image, gift_number, integral, sku, start_time, end_time')
    ->select();

前端无需任何改动——原本读 record.gift_number 的代码已经在那里,字段回来了就自然显示正确值。

正解:后端白名单加字段,向后兼容

新增字段对老消费者是 superset:老页面不读这些字段,行为不变。

不要为了省带宽加一层兼容参数,也不要复制一个新 endpoint,直接在原 field() 里追加即可。

唯一例外:白名单里有手机号、证件号之类的隐私字段,不应扩散到下拉接口——那种情况才考虑新建专用 endpoint。

预防:新页面复用老 API 前先看 field() 白名单

每次把一个旧的"下拉/选项"接口复用给新页面时,先在 controller 里 grep 该方法的 field() 调用,对比新页面需要的字段清单。

不要直接在浏览器里"看一眼数据没报错就以为 OK"——缺字段时 UI 看起来正常,但 0 就是 0,等运营投诉才发现。

一句话外卖

后端 field() 白名单是"不查不返回",前端 || 0 兜底是"不报错只归零"——两者叠加产生完美的静默数据错误,排查必须从 Network 响应的 JSON 结构入手,而不是看 console 报不报错。