数组为null与空数组的区别:前端判空陷阱与防御性编程实践
2026/9/15 23:11:22 网站建设 项目流程

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 } }

数据为空时,后端给的listnull,而前端代码里默认它永远是[]。于是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的七种原始类型之一,和numberstringbooleanundefinedsymbolbigint并列。而数组是一种对象类型。原始类型和对象类型的区别意味着:你没有办法对一个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)只能排除nullundefined,空数组照样进不去这个分支。这两种对象的布尔表现完全不一样,很多人在这一步就已经晕了。

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()falsetrue
Boolean值falsetrue
JSON.stringify"null""[]"
属性访问直接抛TypeError正常返回undefined
.length属性不存在存在,值为0
.map() / .filter()抛出TypeError正常返回新数组
语义没有引用、不存在有引用、内容是空的

这张表值得保存在项目文档里,或者贴在团队共享知识库中——很多争议和bug,源头就是这张表没有深入人心。

3. 判断空数组的七种写法,为什么只有两种值得用

在实际开发中,判断数组是否为空是最常见的需求之一,但写法的坑远比你想象的多。我梳理了日常代码里出现频率最高的几种写法,逐个分析。

3.1 if (!arr.length) —— 最常见但也最危险

if (!arr.length) { // 空数组逻辑 }

这种写法在arr确实是数组时很好用,空数组[]length是0,!0true,能正确判断。但问题在于:一旦arrnullundefinedarr.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,不会误判)。但在arrnull时,同样会抛错。

3.3 if (!arr || arr.length === 0) —— 最常用的兜底写法

if (!arr || arr.length === 0) { // 数组为null、undefined或空数组 }

这种写法先判断arr是否为假值(nullundefined都会直接短路返回),再判断length。逻辑上覆盖了三种情况:nullundefined、空数组。它是目前项目里比较通用的写法,也能放在各种工具函数里作为兜底判断。

这里有个细节需要注意:如果 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?.lengtharrnullundefined时不会抛错,而是返回undefined。再配合?? 0,把undefined转成0,就能把"null、undefined、空数组"统一归到一个分支处理。

不过要提醒一句:?.??需要编译环境的支持,如果你的项目还停留在较老的webpack配置上,记得先确认编译兼容性。我在一个维护了五年的老项目里遇到过这种情况,加了可选链之后构建直接挂掉,后来才知道是babel插件没配全。

3.6 基于逻辑运算符的简写陷阱

还有一种常见写法:

const count = arr && arr.length; // arr为null时返回null,arr为数组时返回length

这个可以用来获取安全长度,但要注意返回值在不同情况下不统一——可能是nullundefinedlength数字。有次我在代码里看到:

if (arr && arr.length > 0) { ... }

这个写法本身没问题,但团队里新来的同事改成了:

if (arr?.length > 0) { ... }

看起来是等价简化,但实际上arrnullarr?.lengthundefinedundefined > 0false,虽然不影响这个分支的判断,但可读性和语义反而变模糊了。这些小细节往往就是 code review 里该抓的点。

3.7 各种写法在典型场景下的表现对照

写法null[]undefined['a']
if (!arr)truefalsetruefalse
if (!arr.length)抛错true抛错false
if (arr.length === 0)抛错true抛错false
if (!arr || arr.length === 0)truetruetruefalse
if (Array.isArray(arr) && arr.length === 0)falsetruefalsefalse
if ((arr?.length ?? 0) === 0)truetruetruefalse
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.listnull。尤其是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直接抛错;如果pagesnull,更早一步就炸了。这种多层嵌套的判空,光靠一两个if根本防不住,必须在前端适配层做统一清洗。

4.2 数组处理方法链的连锁崩溃

前端处理接口数据时,经常会写一长串链式调用:

const names = list .filter(item => item.active) .map(item => item.name) .sort((a, b) => a.localeCompare(b));

问题在于:一旦listnull,第一环.filter()就会直接抛TypeError,后面所有逻辑全部短路。我在代码审查时见过很多同事在这里加各种奇奇怪怪的判断,比如:

const names = (list || []) .filter(...) .map(...) .sort(...);

这个(list || [])的处理方式实际上已经是一种工程化解法了,做法没啥问题。但我遇到过更离谱的:

const names = list ? list.filter(...).map(...) : [];

三元表达式写法正确,但代码可读性确实比(list || [])差一些,而且如果listundefined,三元表达式里的条件判断是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 接口层统一兜底规范

我先说后端规范层面。前后端接口设计阶段,就该明确一个约定:列表类字段如果业务允许为空,返回[],不要返回nullnull只留给真正语义上"不适用、不存在"的字段——比如一个用户没有手机号,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 单元测试里补上空值用例

写了工具函数就要有对应的测试,不然下次重构的时候很容易改坏。我强烈建议至少给ensureArraysafeGet补上这些用例:

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')

遇到这种构建类报错的排查步骤:

  1. 看报错栈是在哪个插件或模块解析阶段edgesout通常来自依赖图遍历逻辑,栈里会告诉你当前在处理什么文件。
  2. 清缓存、重装依赖node_modules目录损坏或 lockfile 与 package.json 不一致,可能导致模块对象部分字段缺失,npm cinpm install更干净。
  3. 逐个禁用怀疑的插件。如果你在 webpack/vite 配置里加了某个自定义插件或 loader,且最近刚改过,可以先注释掉再构建。
  4. 检查 Node 版本和 npm 版本兼容性。版本不匹配也会导致依赖树遍历逻辑走进异常分支。
  5. 定位到具体模块。如果报错前日志里有文件名或模块名,直接打开那个文件,检查是否存在循环依赖、异步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[]这两个基础概念的理解深度。原子性的解决思路就一条:团队约定好数据语义 + 源头统一清洗 + 工具函数兜底 + 关键渲染数据加保护。把这四步走完,你的代码会稳定一大截。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询