十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

数组为null与空数组的区别:前端判空陷阱与防御性编程实践

数组为null与空数组的区别:前端判空陷阱与防御性编程实践 1. 一次线上白屏事故从Cannot read properties of null说起先讲个我印象很深的线上事故。某个周末晚上运营反馈后台管理系统的用户列表页白屏了控制台一行红色报错TypeError: Cannot read properties of null (reading length)定位到代码就一行if (res.data.list.length 0) { // 渲染列表 }后端接口返回的数据结构是这样{ code: 0, data: { list: null } }数据为空时后端给的list是null而前端代码里默认它永远是[]。于是null.length直接在解析阶段炸掉页面整个渲染不出来。这个bug修复起来就一行if (res.data.list res.data.list.length 0) { // 渲染列表 }但这行代码背后其实藏着前端开发里一个特别基础的认知问题数组对象是null和长度为0到底是不是一回事答案当然不是但在真实项目里很多bug的根源恰恰是把这两者画了等号。更麻烦的是这类问题不止出现在length判断上后续的.map()、.filter()、.forEach()包括数组去重、状态管理初始化、依赖构建流程都可能在null上栽跟头。网上搜Cannot read properties of null相关报错从reading length、reading edgesout到reading data一抓一大把。这篇文章我就围绕数组对象为null与长度为0的比较这件事从底层语义、日常判断写法、业务链路里的真实场景、工程化防御方法、以及两个典型报错的完整排查过程把这层窗户纸彻底捅破。2. 先厘清本质null是没有引用[]是有引用但没有内容很多人搞不清null和[]的区别核心原因是没有从语言底层去理解这两个东西到底是什么。我先用一个生活化的类比再逐层深入到代码层面。2.1 语义上的根本差异空地址和空柜子把arr想象成一个快递柜的柜门编号。arr null的意思是这个编号不存在柜门根本不存在你去敲这个门只会敲空气。而arr []的意思是柜门存在打开门里面空荡荡——柜子是好的地址是有效的只是暂时没放东西。这个类比直接对应到JavaScript里let arr1 null; // 引用变量存在的但它不指向任何对象 let arr2 []; // 引用变量存在且指向一个真实的数组对象arr1这个变量本身占用了一块内存但里面的值是一个特殊标记null表示什么都没有。arr2则不同它的值是一个引用地址指向堆内存里真实存在的一个数组对象只不过这个对象的length属性是0里面没有任何元素。所以null是没有引用[]是有引用但没有内容。两者在语义上差了整整一个存在性的维度。2.2 类型层面的判断差异在JavaScript里用最基础的类型判断就能看出两者的差异typeof null; // object —— 历史包袱别被它骗了 typeof []; // object Array.isArray(null); // false Array.isArray([]); // true Object.prototype.toString.call(null); // [object Null] Object.prototype.toString.call([]); // [object Array]typeof null object是JavaScript诞生之初就存在的历史遗留bug但正因为这个坑很多人下意识认为null和数组都属于对象应该可以互相替代。实际上Array.isArray()已经把话说得很清楚了null不是数组[]才是。null本身是JavaScript的七种原始类型之一和number、string、boolean、undefined、symbol、bigint并列。而数组是一种对象类型。原始类型和对象类型的区别意味着你没有办法对一个null值做任何属性访问、方法调用或遍历操作因为它本质上是不存在。2.3 布尔转换带来的反直觉陷阱这里有个非常容易踩坑的点就是两者在布尔上下文中的表现Boolean(null); // false Boolean([]); // true —— 注意空数组是真值很多人觉得数组里没东西那转成布尔应该是false吧结果Boolean([])返回true。这导致什么后果看这段代码let arr []; if (arr) { console.log(进入这里); // 会进入空数组也进来了 }如果你想用if (arr)来判断数组有数据那长度为0的空数组也会通过判断走进去之后取arr[0]又拿到undefined又是一堆隐藏问题。反过来if (!arr)只能排除null和undefined空数组照样进不去这个分支。这两种对象的布尔表现完全不一样很多人在这一步就已经晕了。2.4 JSON序列化与解析的表现差异日常开发中数组经常要经历JSON序列化和反序列化这里也能清楚看到两者的区别JSON.stringify(null); // null JSON.stringify([]); // [] JSON.parse(null); // null JSON.parse([]); // []如果你的接口返回的是一个JSON字符串前端JSON.parse之后得到null或[]后续代码对这两者的处理必须分开写。尤其是很多后台管理系统的列表页接口在没有数据时可能返回null有数据时返回一个数组这种不稳定往往是bug的温床。为了方便对比我把这两者的核心差异整理成一张表对比维度null[]类型原始类型对象类型ArrayArray.isArray()falsetrueBoolean值falsetrueJSON.stringifynull[]属性访问直接抛TypeError正常返回undefined.length属性不存在存在值为0.map() / .filter()抛出TypeError正常返回新数组语义没有引用、不存在有引用、内容是空的这张表值得保存在项目文档里或者贴在团队共享知识库中——很多争议和bug源头就是这张表没有深入人心。3. 判断空数组的七种写法为什么只有两种值得用在实际开发中判断数组是否为空是最常见的需求之一但写法的坑远比你想象的多。我梳理了日常代码里出现频率最高的几种写法逐个分析。3.1 if (!arr.length) —— 最常见但也最危险if (!arr.length) { // 空数组逻辑 }这种写法在arr确实是数组时很好用空数组[]的length是0!0为true能正确判断。但问题在于一旦arr为null或undefinedarr.length本身就会抛TypeError。报错信息就是你经常在控制台看到的那行TypeError: Cannot read properties of null (reading length)这行报错的含义是你试图在一个null值上读取length属性但这个值根本不是一个对象属性读取直接失败。3.2 if (arr.length 0) —— 语义清晰但同样不防nullif (arr.length 0) { // 空数组逻辑 }这个写法比!arr.length语义更明确专门判断长度为0并且能准确区分出[0]、[]、[false]这类极端情况这些虽然只有一个元素但length不为0不会误判。但在arr为null时同样会抛错。3.3 if (!arr || arr.length 0) —— 最常用的兜底写法if (!arr || arr.length 0) { // 数组为null、undefined或空数组 }这种写法先判断arr是否为假值null、undefined都会直接短路返回再判断length。逻辑上覆盖了三种情况null、undefined、空数组。它是目前项目里比较通用的写法也能放在各种工具函数里作为兜底判断。这里有个细节需要注意如果 arr 不是数组、而是其他有 length 属性的对象比如abc或{length: 0}arr.length 0的判断依然成立。所以这个写法的精度取决于你对arr类型是否有把握。3.4 Array.isArray(arr) arr.length 0 —— 最严谨的写法if (Array.isArray(arr) arr.length 0) { // 确实是数组且长度为0 }这种写法先通过Array.isArray()确认类型是数组再检查长度可以排除掉绝大多数误判场景。在需要处理非常规数据的场景中比如解析第三方接口返回、处理配置文件、兼容历史数据结构时这种写法最安全。但严谨的想法也有代价代码会更啰嗦如果全项目到处这么写会累死人。所以我的经验是——工具函数和公共方法用严谨写法业务代码里用兜底写法二者搭配使用。3.5 可选链和空值合并ES2020之后的新选择如果你用的是现代前端工程TypeScript或新版Babel还能用可选链直接规避most of这个问题// 可选链arr为null或undefined时不读取length表达式整体返回undefined if ((arr?.length ?? 0) 0) { // arr为null/undefined/空数组都会进来 } // 等价写法 if ((arr?.length || 0) 0) { // 注意如果length有值且大于0走false分支length为0时走true分支 }arr?.length在arr为null或undefined时不会抛错而是返回undefined。再配合?? 0把undefined转成0就能把null、undefined、空数组统一归到一个分支处理。不过要提醒一句?.和??需要编译环境的支持如果你的项目还停留在较老的webpack配置上记得先确认编译兼容性。我在一个维护了五年的老项目里遇到过这种情况加了可选链之后构建直接挂掉后来才知道是babel插件没配全。3.6 基于逻辑运算符的简写陷阱还有一种常见写法const count arr arr.length; // arr为null时返回nullarr为数组时返回length这个可以用来获取安全长度但要注意返回值在不同情况下不统一——可能是null、undefined或length数字。有次我在代码里看到if (arr arr.length 0) { ... }这个写法本身没问题但团队里新来的同事改成了if (arr?.length 0) { ... }看起来是等价简化但实际上arr为null时arr?.length是undefinedundefined 0为false虽然不影响这个分支的判断但可读性和语义反而变模糊了。这些小细节往往就是 code review 里该抓的点。3.7 各种写法在典型场景下的表现对照写法null[]undefined[a]if (!arr)truefalsetruefalseif (!arr.length)抛错true抛错falseif (arr.length 0)抛错true抛错falseif (!arr || arr.length 0)truetruetruefalseif (Array.isArray(arr) arr.length 0)falsetruefalsefalseif ((arr?.length ?? 0) 0)truetruetruefalseif (arr arr.length)null假值undefined假值undefined假值1真值从这张表能看出来没有任何一种写法是万能的关键看你到底想表达什么语义只关心有没有内容 →if (arr arr.length 0)/if (arr?.length)关心是否有空数组 →Array.isArray(arr) arr.length 0想覆盖null、undefined、空数组三种无数据处理 →if (!arr || arr.length 0)或if ((arr?.length ?? 0) 0)4. 业务代码里null在哪些环节防不胜防搞清楚了判断写法接下来看看这些坑在真实业务代码里是怎么爆发的。我总结了几类高频场景都是我实际在项目中见过或处理过的。4.1 接口返回字段缺失或显式为null这是最常见的源头。后端接口设计不规范、数据库字段为空、或者历史数据没有填值都会导致data.list为null。尤其是PHP后端网上搜php接口数组对象关联的热词也很多有些接口在没有数据时习惯返回null而不是[]前端拿到之后直接懵了。举个例子const response await fetch(/api/users); const data await response.json(); // data.userList: null 或 [ {name: 张三}, {name: 李四} ] 或 [] data.userList.map(user user.name); // 如果 userList 是 null这里就抛错 // TypeError: Cannot read properties of null (reading map)更隐蔽的情况是多级结构嵌套const rows res.data.pages[0].rows;如果pages是一个空数组pages[0]是undefinedundefined.rows直接抛错如果pages是null更早一步就炸了。这种多层嵌套的判空光靠一两个if根本防不住必须在前端适配层做统一清洗。4.2 数组处理方法链的连锁崩溃前端处理接口数据时经常会写一长串链式调用const names list .filter(item item.active) .map(item item.name) .sort((a, b) a.localeCompare(b));问题在于一旦list是null第一环.filter()就会直接抛TypeError后面所有逻辑全部短路。我在代码审查时见过很多同事在这里加各种奇奇怪怪的判断比如const names (list || []) .filter(...) .map(...) .sort(...);这个(list || [])的处理方式实际上已经是一种工程化解法了做法没啥问题。但我遇到过更离谱的const names list ? list.filter(...).map(...) : [];三元表达式写法正确但代码可读性确实比(list || [])差一些而且如果list为undefined三元表达式里的条件判断是false也能兜住倒不会出问题。只是提醒大家只要在一条链上动了手就要确保整条链的每个环节都用了安全的取值方式。4.3 数组去重等算法操作对空值的处理差异对象数组去重也是高频场景。很多人在去重工具函数里没有处理null输入function uniqueArray(arr) { return [...new Set(arr)]; } uniqueArray(null); // TypeError: null is not iterable (cannot read property Symbol(Symbol.iterator))有人可能会问new Set(null)应该也能运行实测不行Set的构造函数接收非可迭代对象会抛错。所以工具函数里必须有这一层保护function uniqueArray(arr) { if (!Array.isArray(arr)) return []; return [...new Set(arr)]; }这类问题在算法和工具函数里特别冤——明明逻辑算法没问题却因为输入值是null直接挂了。4.4 JSON.parse与空值的暗坑还有一类场景是手动解析JSON字符串const data JSON.parse(localStorage.getItem(userInfo) || null); // localStorage 里没有值getItem 返回 null字符串拼接后变成 null // JSON.parse(null) 结果是 null const names data.history.map(...); // data 是 null读取 data.history 直接抛错这里的坑在于JSON.parse(null)是完全合法的返回null不会报JSON.parse的错误但后续访问属性时就出问题了。这种错误往往在开发环境很少复现因为开发者本地总会往 localStorage 写数据一到线上就疯狂报错。4.5 前端框架状态管理的初始值设计在React或Vue项目中state 的初始值设计也经常踩这个坑// 错误示范 const [list, setList] useState(null); // 接口返回后 setList(response.data.list) // 如果接口返回 nulllist 变成 null // 然后在渲染时 list.map(...) 直接炸裂 // 更稳妥的写法 const [list, setList] useState([]);这里最关键的一点是接口返回的 list 如果是 null你应该在赋值之前把它转成空数组而不是让它以 null 的状态进入 state。否则你在组件里每处用到 list 的地方都要做判空这会让代码变得越来越难看。Vue里也一样data() { return { // 推荐用数组初始值 list: [] } }Vue 3 Composition API 里ref([])同样比ref(null)少很多麻烦。ref([])明确表示这是一个数组只是现在没有内容而ref(null)表示我现在还不知道这是什么类型后续使用时每次都要类型收窄。4.6 解构赋值默认值陷阱ES6的解构赋值默认值只对undefined生效对null无效这是一个非常经典的误区const { list [] } res.data; // 当 res.data.list 是 undefined 时list 会使用默认值 [] // 但当 res.data.list 是 null 时list 还是 null无数人在这里栽过跟头。你现在去翻项目代码很可能就能找到好几个 []的默认值写法它们在接口返回null的时候根本没有起到保护作用。这个问题跟refresh_token那个报错背后的逻辑一脉相承——很多系统报invalid refresh_token: empty string本质就是某个字段为null或空字符串时代码没有做好兼容把null传给了底层校验逻辑而底层期望的是至少1个字符的字符串。字段类型、默认行为、触发时机三者之间有一个链条断裂就会从表面看起来毫不相关的地方炸出来。正确处理方式有两类// 方式一手动收窄 const { list } res.data; const safeList list ?? []; // 方式二封装数据清洗函数后面第5节展开5. 工程化防御从每次判断到源头治理如果你发现项目里到处都在判空那不是某个人的问题而是数据链路本身缺乏统一约定。与其每次写了xx || []然后再祈祷别漏不如在源头把null全部拦截住。5.1 接口层统一兜底规范我先说后端规范层面。前后端接口设计阶段就该明确一个约定列表类字段如果业务允许为空返回[]不要返回null。null只留给真正语义上不适用、不存在的字段——比如一个用户没有手机号phone字段可以是null但一个用户的订单列表无论如何都应该是数组没有订单时就是[]。如果你在前端能推动这个约定代码会清爽很多// 后端返回 { code: 0, data: { orders: [] } } // 前端直接消费 orders.map(order ...) // 不会炸但现实是很多老接口、第三方接口根本改不了甚至部分后端同学会坚持null才是正确的空值表达。这时候前端需要有一层适配器把所有接口响应统一清洗后再交到业务层。5.2 封装安全取值工具函数我建议在项目里抽一个dataNormalizer工具模块专门负责把不可靠的数据转成可靠的数据// utils/normalize.js /** * 确保返回值是一个数组 * - null / undefined / 非数组值 → 返回空数组 * - 数组 → 原样返回 */ export function ensureArray(input) { if (Array.isArray(input)) return input; return []; } /** * 安全读取深层属性 * param {Object} obj 源对象 * param {string} path 点路径如 data.pages[0].rows * param {*} defaultVal 默认值 */ export function safeGet(obj, path, defaultVal undefined) { if (!obj || typeof obj ! object) return defaultVal; return path.split(.).reduce((acc, key) { // 处理数组下标写法pages[0] const arrMatch key.match(/^(.*?)\[(\d)\]$/); if (arrMatch) { const [, arrKey, indexStr] arrMatch; const arr arrKey ? acc?.[arrKey] : acc; return arr?.[Number(indexStr)]; } return acc?.[key]; }, obj) ?? defaultVal; }用了ensureArray之后之前的链式调用问题直接解决const orders ensureArray(res.data.orders); orders.map(order ...); // 安全而safeGet可以处理那种嵌套很深的场景const rows safeGet(res, data.pages[0].rows, []); // pages 为空数组、null、undefined 都不会抛错rows 默认空数组注意看safeGet的实现用了可选链?.这在较新运行环境里完全没问题。如果你的项目支持有限可以换成acc acc[key]的方式。5.3 解构默认值加上空值合并的双保险接口数据适配层做完之后组件内部还可以用空值合并运算符做二次保护const { list [] } res.data; const safeList list ?? [];第一层 []把undefined转成空数组第二层??把null也转成空数组。这种双保险虽然在单个字段上有点冗余但对于核心渲染数据我认为值得多写这一行——线上稳定性就是用这种看似琐碎的防御垒起来的。5.4 单元测试里补上空值用例写了工具函数就要有对应的测试不然下次重构的时候很容易改坏。我强烈建议至少给ensureArray和safeGet补上这些用例describe(ensureArray, () { it(数组原样返回, () { expect(ensureArray([1, 2])).toEqual([1, 2]); }); it(null返回空数组, () { expect(ensureArray(null)).toEqual([]); }); it(undefined返回空数组, () { expect(ensureArray(undefined)).toEqual([]); }); it(字符串返回空数组, () { expect(ensureArray(abc)).toEqual([]); }); });这些测试用例看起来简单但它们能确保你的防御地基是稳固的。很多项目不写测试然后工具函数越改越烂最后整个数据链路又回到到处判空的乱局。6. 两个真实报错的完整排查复盘前面讲完了方法论这一节我们来点实战复盘。网上搜Cannot read properties of null相关报错的高频现场除了刚才说的读取length之外还有两类特别典型一类是构建工具里的reading edgesout一类是各种各样读取属性/方法的连锁错误。我把排查思路完整走一遍。6.1 npm err! Cannot read properties of null (reading edgesout)有段时间很多人在npm/yarn构建前端工程时遇到这个报错npm error Cannot read properties of null (reading edgesout)从报错信息结构看edgesout是依赖图或模块图对象上的一个属性名。构建工具比如webpack、Rollup、Vite的底层依赖分析在遍历模块依赖时每个节点模块对象上有一个edgesout属性表示该节点指向哪些外部节点。正常情况每个节点对象都有这个属性但如果依赖树中存在某个异常节点为null遍历到这里就会报Cannot read properties of null (reading edgesout)。遇到这种构建类报错的排查步骤看报错栈是在哪个插件或模块解析阶段。edgesout通常来自依赖图遍历逻辑栈里会告诉你当前在处理什么文件。清缓存、重装依赖。node_modules目录损坏或 lockfile 与 package.json 不一致可能导致模块对象部分字段缺失npm ci比npm install更干净。逐个禁用怀疑的插件。如果你在 webpack/vite 配置里加了某个自定义插件或 loader且最近刚改过可以先注释掉再构建。检查 Node 版本和 npm 版本兼容性。版本不匹配也会导致依赖树遍历逻辑走进异常分支。定位到具体模块。如果报错前日志里有文件名或模块名直接打开那个文件检查是否存在循环依赖、异步import的异常写法。我遇到过一次类似情况最后发现是某个 npm 包版本发布异常下载下来的包内容不完整导致模块图的某个节点是空的。删掉 node_modules、升级package-lock.json里的对应依赖问题就消失了。总结一下reading edgesout这类报错的根因大概率不是你的业务代码而是依赖管理环境和工具链状态出了问题。排查思路是从环境入手再从依赖树逐层收窄不要试图在业务代码里找edgesout相关的东西找不到的。6.2 Cannot read properties of length (reading length)另一个高频报错就是读取length失败。这个报错在不同环境里对应的代码不同我梳理一个通用排查链路第一步确认哪个变量为null。浏览器控制台的报错会给出堆栈信息点开报错行直接看上下文if (list.length 0) { // ^^^^ 这里报错说明 list 是 null 或 undefined }第二步往回追溯数据来源。这个list是哪来的接口返回的props传的state里取的逐层往上找// 组件里 const { list } props; // props 从父组件 List data{userList} / // userList 从接口 const [userList, setUserList] useState(null); // 接口 setUserList(res.data.list);到这一步你往往就发现问题了res.data.list在接口返回里就是null一路传到子组件都没被拦截。第三步决定在哪一层修复。我的建议是能靠约定解决的不要靠补丁——后端能改就改后端后端改不了前端在接口层做ensureArray转换再不行组件里用安全取值。修复方案越往前端接口层靠你的业务代码就越干净。第四步补一个前端监控上报。如果这个列表数据很关键建议在上报日志里记录接口原始返回值if (!Array.isArray(res.data.list)) { monitor.report(userList data abnormal, { rawValue: res.data.list, api: /api/users }); }这种上报能在数据异常的第一时间暴露问题而不是等用户反馈白屏之后才去查。从我处理线上问题的经验来看这类报错90%以上都是接口返回null 前端没有防御的组合。剩下10%才是构建环境、第三方SDK之类的特殊情况。所以优先级很清楚先把业务代码的判空逻辑做扎实再考虑外部依赖的问题。写到这里我最后还是想啰嗦一句判断数组为空这件事本质上考验的不是你对API的熟悉程度而是你对数据可能存在各种状态有没有足够的心理准备和防御意识。我在项目里经常看到两种极端——要么是过度防御每行代码都用if包了三层代码没法看要么是完全没有防御一个空值就把整条链路带崩。这两者之间隔着的就是对null和[]这两个基础概念的理解深度。原子性的解决思路就一条团队约定好数据语义 源头统一清洗 工具函数兜底 关键渲染数据加保护。把这四步走完你的代码会稳定一大截。
返回列表