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 | [] |
|---|---|---|
| 类型 | 原始类型 | 对象类型(Array) |
| Array.isArray() | false | true |
| Boolean值 | false | true |
| JSON.stringify | "null" | "[]" |
| 属性访问 | 直接抛TypeError | 正常返回undefined |
| .length属性 | 不存在 | 存在,值为0 |
| .map() / .filter() | 抛出TypeError | 正常返回新数组 |
| 语义 | 没有引用、不存在 | 有引用、内容是空的 |
这张表值得保存在项目文档里,或者贴在团队共享知识库中——很多争议和bug,源头就是这张表没有深入人心。
3. 判断空数组的七种写法,为什么只有两种值得用
在实际开发中,判断数组是否为空是最常见的需求之一,但写法的坑远比你想象的多。我梳理了日常代码里出现频率最高的几种写法,逐个分析。
3.1 if (!arr.length) —— 最常见但也最危险
if (!arr.length) { // 空数组逻辑 }这种写法在arr确实是数组时很好用,空数组[]的length是0,!0为true,能正确判断。但问题在于:一旦arr为null或undefined,arr.length本身就会抛TypeError。报错信息就是你经常在控制台看到的那行:
TypeError: Cannot read properties of null (reading 'length')这行报错的含义是:你试图在一个null值上读取length属性,但这个值根本不是一个对象,属性读取直接失败。
3.2 if (arr.length === 0) —— 语义清晰但同样不防null
if (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时返回null,arr为数组时返回length这个可以用来获取安全长度,但要注意返回值在不同情况下不统一——可能是null、undefined或length数字。有次我在代码里看到:
if (arr && arr.length > 0) { ... }这个写法本身没问题,但团队里新来的同事改成了:
if (arr?.length > 0) { ... }看起来是等价简化,但实际上arr为null时arr?.length是undefined,undefined > 0为false,虽然不影响这个分支的判断,但可读性和语义反而变模糊了。这些小细节往往就是 code review 里该抓的点。
3.7 各种写法在典型场景下的表现对照
| 写法 | null | [] | undefined | ['a'] |
|---|---|---|---|---|
if (!arr) | true | false | true | false |
if (!arr.length) | 抛错 | true | 抛错 | false |
if (arr.length === 0) | 抛错 | true | 抛错 | false |
if (!arr || arr.length === 0) | true | true | true | false |
if (Array.isArray(arr) && arr.length === 0) | false | true | false | false |
if ((arr?.length ?? 0) === 0) | true | true | true | false |
if (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]是undefined,undefined.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) // 如果接口返回 null,list 变成 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和[]这两个基础概念的理解深度。原子性的解决思路就一条:团队约定好数据语义 + 源头统一清洗 + 工具函数兜底 + 关键渲染数据加保护。把这四步走完,你的代码会稳定一大截。