前端列表字段为null?一文讲透成因、兜底与排查
有一类报错做前端的人隔三差五就会撞上接口通了、列表出来了、控制台也没红但页面某一行就是空着点开详情直接白屏。打开 Network 面板一看返回的列表里某个字段是 null。更气人的是同一个接口第一行有值第二行是 null第三行是 0第四行干脆连字段都不给你返。这不是后端存心跟你作对而是只要数据链路里存在可空列、聚合统计、第三方 SDK、多端写入列表字段为 null 就是必然事件。这篇文章我想把跟 null 打交道这几年攒下的经验完整捋一遍从字段为什么是 null到渲染层、计算层、表格组件里怎么优雅兜底再到那些一看就头疼的报错怎么排查。新手能直接照着写老手也能顺手补几个平时容易漏的边界。1. 先搞清楚 null 到底是怎么混进返回结果的1.1 不是所有 null 都是脏数据做前端拿到 null 第一反应是“后端有问题”但蹲过几次接口联调就会发现null 在真实业务里相当一部分是合理的。数据库设计阶段某个字段就允许为空比如用户表的备注字段、订单表的取消原因字段没填的时候存进库里的就是 NULL。后端做聚合计算时也经常产出 nullSUM 一个空集合、AVG 一个没有任何行的分组、窗口函数里缺省分区结果天然就是 NULL。这种情况属于业务上“无值”的正常表达你需要做的是展示层兜底而不是逼着后端改成空字符串。还有一类是跨系统同步产生的 null。比如主系统写库时有值但通过消息队列同步到另一个库时字段映射丢了或者上游接口文档写了某个字段实际上线后老版本服务没这个逻辑直接序列化成 null。这类 null 的问题不在前端但往往要前端在联调期主动暴露出来。我的习惯是拿到接口文档后先扫一遍所有返回字段凡是标注“可空”的再看一眼页面设计稿上这个字段的展示要求提前圈出需要兜底的位置而不是等测试用例打过来再补。1.2 字段缺失和字段为 null 其实是两码事联调时容易忽略的一个细节JSON 里field: null和整个字段不出现对前端来说是不一样的状态。前者说明后端意识到这个字段存在只是当前这条记录没有值后者通常意味着后端的数据结构里根本没有这个字段或者是老版本接口还没升级。很多前端会用obj.field直接取值字段缺失时得到 undefined字段为 null 时得到 null虽然都不会立刻抛错但后续判断逻辑很容易被带偏。举个例子列表里的用户头像字段。老用户可能没有 avatar 这个属性新用户上传过头像但被删了后端可能返回avatar: null。如果前端统一用filters.avatar过滤默认头像那 undefined 和 null 都会被过滤逻辑接住看起来没问题但如果你要做“该用户是否设置过头像”的统计上报null 和 undefined 语义完全不同。我建议所有经过清洗层的数据都统一成一个状态自己定规则要么都转成 null要么都转成 undefined不要一个项目里两种混着用否则排查问题时会多花双倍时间。1.3 null 和 undefined 的边界要刻在脑子里前端面试题十次有八次会问 null 和 undefined 的区别放在真实项目里这不是咬文嚼字是会影响代码走向的。undefined 表示“变量声明了但没赋值或者对象里根本没有这个属性”是 JavaScript 引擎层面的空null 表示“有明确的空值”是开发者或者后端主动放进去的。两者在比较下相等在比较下不相等这个基础如果没吃透后面用??和||时就会莫名其妙踩坑。还有一个信息安全领域常提到的“null 注入”概念也跟这有关。后端如果直接把前端传过来的 null 拼进 SQL某些数据库方言里WHERE field NULL是永远查不出数据的正确写法是IS NULL。前端在传参时如果没把空值处理干净后端再用“等于号”去匹配就会触发这类查询结果为空的问题表现出来就是列表突然什么都查不到。所以传参层我一般会统一过滤查询参数里值为 null、undefined、空字符串的一律不传或者明确转成用户可感知的“全部”条件。2. 不要在渲染层到处判断先做数据清洗和分层兜底2.1 数据进门之前先过一遍适配层很多前端拿到接口数据直接在模板里写{{ row.price }}等 price 为 null 时页面就显示空白然后开始到处补v-if、三元表达式补到最后模板里全是判断看都不想看。我自己的做法是所有接口响应在进入页面之前先过一层适配函数把需要展示的字段统一赋默认值。这一步不是过度设计而是把“数据长什么样”和“页面怎么展示”彻底解耦。比如详情页需要一个用户展示对象我会在拿到接口数据后这样处理function normalizeUser(raw) { return { name: raw.name ?? 未命名用户, avatar: raw.avatar || DEFAULT_AVATAR, phone: raw.phone ?? , age: typeof raw.age number ? raw.age : 0, tags: Array.isArray(raw.tags) ? raw.tags : [], }; }注意这里我故意区分了??和||。phone 字段空字符串是合理的所以用??兜底avatar 空字符串没意义直接用||换默认头像更省心。tags 字段要特别小心如果后端返回 null直接.map会报错所以先用Array.isArray判断再兜底为空数组。列表数据就res.data.list.map(normalizeUser)整体走一遍后面模板里不再需要任何判空。这套思路对接口返回的列表字段为 null、子对象为 null、数组为 null 都能一次性解决。2.2 渲染层三种兜底写法怎么选即使做了清洗层总会有漏网之鱼尤其是第三方组件、嵌套子组件直接接收原始数据时。渲染层常见的兜底写法有三种适用场景完全不同。第一种是模板里的v-if适合“字段不存在时整块区域都不展示”的场景比如没有简介就不显示简介模块。第二种是可选链?.适合深层嵌套属性读取比如detail?.user?.profile?.bio中间任何一环是 null 或 undefined 都不会抛错只会返回 undefined。第三种是表达式兜底??和||适合“要有默认显示值”的场景。判断数据里data: null时整个列表为空我就用const list data?.list ?? []组件内部完全不需要感知 this.list 可能为空。JSX 和模板语法略有差异Vue 模板里可选链的支持取决于编译器和目标浏览器多数现代工程没问题但如果你在维护一个老 Vue 2 项目建议模板里只写简单三元复杂取值逻辑放到 computed 或方法里。React 的 JSX 则完全走 JavaScript 语法.和?.随便用但我还是建议别在 render 里堆太长的链式读取可读性太差封装成函数更舒服。2.3 计算和接口传参时的空值处理列表字段为 null 不只影响展示还会在计算和传参时埋雷。最常见的是把 null 当数字做加法订单列表里优惠金额字段为 null前端算实付价时直接price - discountnull 会被隐式转换成 0结果虽说不报错但语义上把“没优惠”和“优惠金额缺失”混为一谈了。更危险的是用 null 做字符串拼接${userId}-${orderNo}如果 userId 是 null拼出来就是null-20250101传到后端查不到数据查半天还以为是权限问题。我的处理原则是所有参与数学运算的字段进入计算前必须先过Number()或parseFloat()并确认结果不是 NaN所有参与拼接、路由跳转、接口查询的字段先判断 null/undefined/空串必要时抛错或打日志。比如查询页的分页参数const params { page: Number(raw.page) || 1, pageSize: Number(raw.pageSize) || 20, keyword: raw.keyword || , };Number(raw.page) || 1这个写法能同时接住 null、undefined、NaN、0 四种情况写起来短语义也清晰。还有前端做排序、过滤、分组统计时null 字段经常被忽略或误分组下一节单独展开讲。3. 表格与列表场景里 null 的实战处理3.1 给单元格一个安全的默认展示管理后台的列表页是 null 的重灾区。el-table、vxe-table 这类组件渲染单元格时字段为 null 默认显示空白业务方看到了会说“这个数据丢了”实际上只是没做展示兜底。el-table 最常见的做法是formatter函数const priceFormatter (row) { if (row.price null || row.price undefined) return --; return row.price.toFixed(2); };vxe-table 的写法更灵活一点可以用插槽统一处理。比如我项目里所有金额列都走同一个 slots模板里写{{ formatMoney(row.price) }}函数内部把 null、undefined、空串全部映射成--数值则补全小数位。推荐把这类函数提取到全局 filters 或工具函数里别每个页面复制一份。展示层还有一个容易忽略的地方单元格里有图片或文件时null 会导致组件直接 Error。头像、附件列表、预览图这几个字段我都强制在清洗层给默认值或空数组绝不允许 null 流到组件内部。之前排查过一个线上问题就是附件字段为 null图片组件内部读取url.length直接崩了页面一半白屏加一行attachments: raw.attachments ?? []就解决。3.2 排序、筛选、分组时 null 的位置有讲究表格开了 sortable 之后null 的排序位置经常让人摸不着头脑。JavaScript 原生Array.prototype.sort在比较两个值时会做隐式转换null 会被转成 0字符串会被转成 NaN所以默认排序下 null 可能夹在中间也可能排在最后不同浏览器行为还不一样。要稳定排序必须自己写 comparator核心思路是把 null 和 undefined 统一映射成排序区间之外的值。const compareWithNull (a, b) { const av a null ? -Infinity : Number(a); const bv b null ? -Infinity : Number(b); if (av bv) return 0; return av bv ? 1 : -1; };升序时把 null 排在最前降序时把 null 排在最后这符合大多数业务直觉。如果后端直接返回排好序的列表前端还要注意一个问题后端 SQL 排序对 NULL 的处理各有差异Oracle 默认 NULL 最大MySQL 默认 NULL 最小所以同样一段 ORDER BY不同库出来的顺序完全反的。接口文档里如果写了排序字段最好顺带问一句 null 的排序规则不然前端排序和后端排序会互相打架。筛选和分组同理。前端对列表做groupBy多个字段时null 会成为一个独立的组别而且组名显示成空字符串用户根本看不懂。我的做法是把 null 和 undefined 统一替换成“未设置”之类的文案再分组。尤其注意多字段分组时被分组的字段如果是 null整条记录的归属会变得不可预期一定要在分组函数开头处理掉。3.3 修改列表某一项字段时别被 null 骗了做表格编辑功能时经常需要“改变 list 中某一项的指定字段”比如把某一行的状态从待审核改成已审核。这里面有个隐蔽的坑如果原字段是 null新赋的值是空字符串或 undefined你用Object.is或者去比对“有没有变化”结果会因为 null 和 undefined 不相等而误判为已修改触发多余的状态更新和接口提交。vxe-table 这类带编辑功能的组件尤其要注意。它内部在单元格编辑结束后会对比新旧值如果你的列字段原值是 null编辑器给的空值是 就会被判定为 cell-change然后触发 change 事件。解决方案是进入编辑态之前把这一行的 null 字段统一初始化成空字符串或者监听 change 事件时先归一化再比较。我自己习惯写一个isSameValue工具函数const isSameValue (a, b) { if (a null b null) return true; if (a null || b null) return false; // 时间对象、数组按需补充 return a b; };另外修改指定字段时如果直接用item.field newValueVue 2 的响应式系统可能检测不到新增属性导致视图不更新。正确做法是用this.$set或展开运算符生成新对象。这条经验在 vxe-table 修改 list 中某一项的指定字段时经常会卡人说到底是“直接改了内存对象但没驱动视图更新”的问题。检查一个字段是否为 null 再决定给默认值避免把“未设置”和“空字符串”混成一个状态。4.1 一个 safeGet 就够用了别每个页面都写一遍可选链null 处理中最恶心的其实不是 null 本身而是嵌套太深模板里写一排?.根本没法看。我项目里会固化一个safeGet工具函数按路径读取对象属性取不到就返回默认值内部用 reduce 实现不依赖 lodashconst safeGet (obj, path, defaultVal) { const keys Array.isArray(path) ? path : path.split(.); let result obj; for (const key of keys) { if (result null) return defaultVal; result result[key]; } return result undefined ? defaultVal : result; };调用方式safeGet(row, user.profile.bio, --)。这个函数的好处是即便中间某层是 null 也不会抛错而且路径支持字符串和数组两种写法在动态生成列配置时特别有用。有些场景下后端返回的字段名本身可能是关键字比如class、delete直接row.class在部分浏览器和严格模式下会有问题用row[class]或 safeGet 传字符串路径就能绕开。热词里的“字段为关键字”说的就是这种坑尤其在写通用组件时字段名完全由后端决定用方括号访问比点语法安全得多。4.2??和||的区别再讲一次我怕你还是混这俩运算符在 null 兜底里都常用但行为差异必须刻在脑子里。||判断的是“假值”所以 0、、false、NaN 都会被替换??判断的是“nullish”只有 null 和 undefined 会被替换。实际项目里最大的翻车现场就是一个字段合法值可能为 0你用|| --兜底结果金额为 0 时显示成了--业务方会认为数据丢了。反过来的坑是空字符串是合法值你用?? 默认值不会兜底页面显示出空白用户又觉得是 bug。写法会被替代的值推荐场景a || 默认null、undefined、0、、false、NaN需要同时兜住空串和 0 的展示类字段a ?? 默认仅 null、undefined数字 0、空字符串是合法值的场景a ? a : 默认同时覆盖但可读性差不推荐直接上面二选一还有一个语法细节很多人都忽略??不能和||、直接混用a ?? b || c这种写法在 JavaScript 里直接报语法错误必须加括号。我见过同事因为这行代码被构建工具卡了十分钟报错提示还看不太懂。所以团队代码规范里我会直接规定默认选??只有明确要兜空串时才用||实在要混用必须加括号。4.3 组件库场景里的 null 处理以 el-table 和 vxe-table 为例用组件库时null 处理的最佳位置不是组件的 API 配置里而是数据进组件之前。el-table 的formatter只是展示层挡板如果你有多处列要用同一个兜底逻辑建议统一住const columns [ { prop: name, formatter: (row) row.name ?? -- }, { prop: price, formatter: (row) formatMoney(row.price) }, ];vxe-table 更推荐 slots因为它能拿到{ row }整个上下文灵活性最高vxe-table-column fieldprice title成交价 template #default{ row } span{{ formatMoney(row.price) }}/span /template /vxe-table-column要留意的是表格组件本身有时也会因为 null 炸掉。如果你用过 uview-plus 这类移动端组件库可能遇到过cannot read properties of null (reading matches)这种报错表面看是组件内部某处调用了字符串的 matches 方法实际上传进去的是 null。排查方向就是找哪个 props 在父组件里没兜底比如某些插件把输入框的值绑定到了一个未初始化的 ref 上。把组件需要的 props 都看一眼默认值再用?? 接住这类报错就能批量消灭。5. 常见报错与排查实录5.1 Cannot read properties of null 到底在说什么英文报错看得人头皮发麻拆开其实就一句话某个对象是 null你还想读它的属性。最常见的是点击表格行跳详情时详情页拿到的 id 字段是 null接口返回的 detail 是 nulldetail.name直接炸。解决方式很简单渲染前判断或者用可选链。关键是找到“哪一行代码在哪个字段上炸的”浏览器控制台会精准指向文件行号点开看那一行基本就是答案。还有一种情况跟列表字段为 null 的关系更直接列表项本身不是 null列表项下的某个子对象是 null。比如row.userinfo在部分行里是 null你在模板里写{{ row.userinfo.nickname }}只有那几行会报错其他行正常。这种问题在测试时最容易漏因为第一屏数据往往都完整翻到后面某一条脏数据才炸。我的排查技巧是用 try-catch 包住表格渲染的辅助函数捕获到错误后 console.error 打印一整条 row 数据一眼就能看出是哪个字段捣乱。5.2 排查思路先定位是数据源还是消费方遇到列表字段为 null 引发的问题我按三步排查。第一步看 Network 面板原始响应确认后端返回的就是 null还是前端加工过程弄丢了。有时候是前端 map 的时候解构赋值写错了字段名导致新对象里根本没有这个属性不是 null 而是 undefined。第二步看报错发生的层级是渲染层、事件回调、还是计算属性。如果是渲染层优先考虑清洗层兜底和模板判断如果是事件回调里读取 null可能是用户交互发生在数据尚未加载完成时要考虑 loading 态。第三步最容易被忽略看有没有其他地方修改了原始数组。列表页常见的 bug 是排序、筛选组件直接操作了 props 传入的数组把原来有值的字段偷偷改成了 null。JavaScript 数组是引用类型sort()会原地修改如果你用props.list.sort(...)而不是先浅拷贝原始数据的字段会被污染后面其他组件再读就拿到了 null。排查这个问题的方法也简单在 Network 里对比接口原始响应和当前页面数据字段值不一致基本就是某个流程动了数组。5.3 避坑速查清单把这几年代码 review 里常见的 null 相关问题总结成一张表新人照着查能省很多时间典型问题根因解法模板直接读obj.a.bobj.a 为 null 报错深层属性无兜底可选链obj.a?.b或清洗层合并对象数值计算得到 NaN 或显示异常null 被当数字用计算前Number()并判 NaN表格排序位置混乱sort 默认转数字规则不一致自定义 comparatornull 映射到极值编辑时 null 被误判为修改null 与 不相等isSameValue归一化比较接口传参带了 null 导致查不到数据查询参数未过滤空值统一删掉 null/undefined/JSON.stringify 后字段消失或残留undefined 会被丢弃明确序列化规则传输前统一转 null 或剔除为清理 null 用了深拷贝结果 Date/RegExp 丢失JSON.parse(JSON.stringify)的副作用不要再依赖这个方式来清洗数据用 map 显式处理还有一个非常重要的工作习惯后端返回的列表里如果混有 null前端在处理时不要一味“容忍”应该把明显异常的数据记到日志里。详情页渲染失败、白屏这类问题很多都是数据异常被前端强行兜底后隐藏了线上用户不说你永远不知道有脏数据在流转。我会在清洗层加一个validateList函数检查必填字段的非空率低于阈值就抛到监控平台告警这比等用户反馈再排查效率高得多。接着前面那个 null 排序的问题很多刚转前端的人理解不了为什么后端 SQL 排序结果和前端 sort 结果不一样。补充一个常识SQL 的索引排序规则是数据库层定义的NULL 在 MySQL 里默认最小在 Oracle 里默认最大而前端 sort 比较的是 JavaScript 运行时的数字/字符串转换结果三者根本不在一个规则体系里。所以只要排序发生在后端前端就只管按后端返回的顺序渲染别再用自己的 comparator 二次排序两边规则冲突时数据顺序会很跳。如果排序必须在前端优先把所有 null 统一映射后排序并把这套规则写进注释里防止后面的人“优化”掉。字段取值还有一种边角情况值得单独说。后端返回的字段名经常和前端约定不一致比如数据库里叫is_delete接口给的是isDeleted或者干脆字段本身不对齐前端解构时拿到了 undefined再传给第三方 SDK 时被当成 null。很多“某个字段为 null”其实是“前端取错字段名导致 undefined 后又被序列化成了 null”。排查这类问题直接打印原始响应和清洗后的对象做字段名 diff 就能发现。关于 null 处理还有一个容易引发线上故障的点是 JSON.stringify 的行为差异。某些场景下前端需要把整条列表数据缓存到本地或者传给别的系统JSON.stringify遇到值为 undefined 的属性时会直接丢弃该属性遇到 null 时则会保留为field: null。如果你没有意识到这个区别缓存的旧数据和新接口的数据结构可能不一致下次启动读取缓存时某些字段从“缺失”变成 null表现就是同样的代码新接口没问题读了缓存就报错。从缓存或同步数据恢复列表时我建议强制再过一次清洗函数不要直接信任存储里的字段完整性。很多前端在面试的时候能背出 null 和 undefined 的区别??和||的区别但到了真实项目里还是靠猜。依我看根子在于没有形成“数据永远不值得信任”的防御式编程习惯。接口返回列表、缓存返回列表、组件事件传出来的列表每一层都可能引入 null。我自己这几年的体会是不在模板里解决数据问题数据问题在数据层解决不在组件内部解决字段问题字段问题在传参之前解决。把这一条原则贯彻下去null 相关的报错至少能少一半。最后再分享一个小技巧改 line 列表字段的时候如果是通过下标更新的先检查这个下标对应的对象是不是 null。有些列表因为过滤、拖拽排序下标和原始数据已经不对齐了直接list[index].field xxx可能踩到 undefined 上。用数组方法find去找唯一标识比存下标稳妥得多。这个坑我和组员一起踩过两次一次是列表项被删除后没刷新下标一次是拖拽排序后数据错位写出来给大家避个雷。