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

资讯详情

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

JS 数组元素存在性判断:includes、indexOf、some、find 与 Set 选型指南

JS 数组元素存在性判断:includes、indexOf、some、find 与 Set 选型指南

排查线上问题的时候,我遇到过不止一次因为"数组里到底有没有这个元素"判断写错导致的 bug——有的是把indexOf返回值当成布尔值用,0被当成false直接跳过;有的是对象数组拿includes去比,永远返回false,然后在一堆日志里翻半天。js 判断数组中是否存在某个元素这个动作,看起来是入门级的语法题,但真要在项目里写对、写稳、写得不留坑,其实牵扯到方法语义、类型比较规则、性能取舍这几层东西,坑比想象中密。这篇文章就是把这四种最常用的方法——includes、indexOf、some、find/findIndex——从语义到实现、从实测性能到踩坑细节完整过一遍,同时把Set这类数据结构方案也一并说清楚。不管你是刚接触数组方法的新手,还是在维护一堆历史代码的老手,都能直接拿去对照着改。

1. 判断数组元素这件事,远比想象中碎

1.1 一个需求是怎么从一行变成一堆分支的

最开始的需求通常特别朴素:一个用户 ID 列表,判断当前登录的用户在不在里面。这种场景下随便挑个方法都能跑通。但业务一长,条件就开始变味了——列表可能变成对象数组,里面有id、status、type三个字段,你要判断的是"存不存在一个 type 等于 2 且 status 不等于 0 的元素";再往后,列表可能来自后端分页,元素里嵌套了两层,判断条件得写成一个函数;最麻烦的是数据量从几十涨到几万,原本每帧跑一次的判断开始让页面卡顿。

我第一次被这个问题绊住是在做一个权限过滤功能。前端缓存了一份用户的角色列表,每次渲染按钮都要判断当前按钮要求的角色在不在列表里。用的是arr.indexOf(role) != -1,测试环境一切正常。上线之后有个用户反馈按钮该显示却不显示,查了半天才发现这个用户的角色名后面带了一个空格,是从上游接口透传下来的脏数据。这就属于"判断逻辑本身没错,但比的是不是你真正想比的东西"——这类问题在四种方法之间切换时,发生的概率完全不一样。

1.2 四种方法的分工地图

把常见方案列一下,你会看到它们各自解决的是不同的子问题,而不是互相替代的关系:

方法返回值能否比 NaN能否自定义条件典型场景
includes布尔值能不能基本类型数组的存在性判断
indexOf下标或 -1不能不能需要同时拿到位置的老代码
some布尔值依赖回调能对象数组、多条件判断
find/findIndex元素或 undefined / 下标或 -1依赖回调能判断之后还要用这个元素或下标
Set.has布尔值能不能高频、大数据量的存在性判断

这张表里最容易忽略的是最后一行。很多人(包括早期的我)根本没把Set当成"判断元素是否存在"的方案,因为它的定位听起来是去重。但实际上一旦判断次数上去了,Set的has就是数量级上的优势,后端返回的大列表在前端做多次筛选时特别明显。

1.3 先把"相等"这件事定义清楚

所有判断方法最终都要落到"相等"上,而 JS 里的相等有两套规则,这是很多诡异结果的根源。===严格相等要求类型和值都一致,Object.is在它基础上修正了NaN和-0两个特例。includes内部用的是SameValueZero,它把NaN视为等于自身,但-0和+0视为相等。indexOf用的是严格相等,所以它永远找不到NaN。

这个差别不是抠字眼。我做过一个数据清洗的需求,接口返回的数值列里用NaN表示缺失,需要判断某个值是否在缺失集合里统一处理。用indexOf写的判断完全不生效,因为[NaN].indexOf(NaN)返回的是-1。换成includes之后一行都不用改逻辑就跑通了。所以选方法之前,先问自己一句:我要比的是基本类型、引用类型,还是可能是NaN的特殊值。

2. 四种方法逐个拆:语义、原理与适用边界

2.1 includes:为"存在性判断"而生的写法

includes是 ES2016 引入的,签名很简单:arr.includes(searchElement, fromIndex)。第二个参数指定从哪个下标开始找,可以是负数,表示从末尾倒数。它的语义就是字面意思——"这个数组包含这个元素吗",返回布尔值,不需要你再写!== -1这种绕一道弯的代码。

内部实现上,它是顺序遍历数组,对每个元素做SameValueZero比较。这意味着它能正确识别NaN,这也是它相对indexOf最重要的一个改进。另外它不会跳过稀疏数组的空位,空位会被当作undefined处理,这一点和indexOf的行为一致,和forEach、map那类会跳过空位的方法不同。

什么地方该用它?我的判断标准是:如果这次判断的结果只用来走分支(显示/隐藏、允许/拒绝、继续/中断),不关心元素在哪、也不关心元素长什么样,那就直接用includes。代码可读性上的收益是实打实的,if (list.includes(id))比if (list.indexOf(id) !== -1)少了一层"返回值语义"的心理转换。

注意:includes只接受一个用于比较的值,没法传条件函数。对象数组里想按字段判断,它帮不上忙,硬传对象进去比的是引用地址。

2.2 indexOf:老代码里的主力,注意它的返回值陷阱

indexOf是最早的那批数组方法,兼容性没有任何问题,所以你在任何有一定年头的项目里都能看到它。它的返回值是首次出现的下标,找不到返回-1。判断要用!== -1或> -1来写。

它最大的坑就是返回值是数字,而0在布尔上下文里是假值。我见过(也写过)这样的代码:

// 错误写法:下标为 0 时会被当成不存在 if (arr.indexOf(target)) { doSomething(); }

这个 bug 特别阴,因为只有当目标元素恰好是数组第一个元素时才会触发,测试用例里稍微换个数据就漏过去了。正确的写法只有一种:显式比较-1。

const arr = ['alpha', 'beta', 'gamma']; const idx = arr.indexOf('alpha'); if (idx !== -1) { console.log('找到了,位置在', idx); // 找到,位置在 0 }

indexOf的第二个参数同样是从哪里开始找,用途之一是配合循环做"找出所有出现位置"的操作:找到一次之后,下次从idx + 1继续找。这种玩法在处理日志文本、标记位置时还挺常见。

至于NaN,前面提过了,[NaN].indexOf(NaN)恒等于-1,这是设计遗留问题,官方也没有打算改,因为改了会破坏现有代码。如果你的数据里可能出现NaN,直接换includes或者some。

2.3 some:对象数组场景的真正解法

some是为"按条件判断"设计的。它接收一个回调,遍历数组,只要有一个元素让回调返回真值就立刻返回true并停止遍历,全都不满足则返回false。这个"提前退出"的特性很重要,它在最坏情况下仍然要遍历完整数组,但在命中概率高的场景下能省掉大量工作。

对象数组是它的主战场。比如一个任务列表,要判断里面有没有未完成的高优先级任务:

const tasks = [ { id: 1, priority: 'low', done: true }, { id: 2, priority: 'high', done: false }, { id: 3, priority: 'low', done: false } ]; const hasUrgent = tasks.some(t => t.priority === 'high' && !t.done); console.log(hasUrgent); // true

这种多字段组合条件,其他三种方法都做不到,只能靠some或者findIndex。

需要注意的是回调返回值的处理。some会对你返回的任何东西做布尔转换,所以返回一个对象、一个非空字符串都会被当作真值,这在调试时容易误判。养成习惯:回调里明确返回比较表达式或者Boolean(...),不要直接return item.value,除非你确实想利用真假值转换。

另外some的回调有三个参数:当前元素、下标、原数组。第三个参数在实际业务里很少用,但在一些工具函数封装里会用上,比如要比较元素和原数组的关系时。

2.4 find 与 findIndex:需要拿到东西时再用

find返回第一个满足条件的元素,findIndex返回它的下标,都不满足时分别返回undefined和-1。它们和some的关系是:条件判断逻辑完全一样,区别在于你还想不想拿到结果。

什么时候需要拿结果?举两个我遇到过的场景。第一个是拿到元素后要对它做修改,比如找到购物车里对应商品的那一项,改数量;第二个是拿到下标后要用splice删除,或者用下标去操作另一个平行数组。

const cart = [ { sku: 'A100', count: 1 }, { sku: 'B200', count: 3 } ]; // 拿到元素本体 const item = cart.find(i => i.sku === 'B200'); if (item) { item.count += 1; } // 拿到下标做删除 const idx = cart.findIndex(i => i.sku === 'A100'); if (idx !== -1) { cart.splice(idx, 1); }

这里有个判断写法上的细节:find返回undefined表示没找到,所以判断时要写if (item),但如果数组元素本身可能是0、空字符串、null这些假值,这个判断就会误伤。更严谨的写法是if (item !== undefined)。什么时候数组里会存0?比如一个存放排行榜分数的数组,或者存放状态码的数组,完全有可能。这种边界我在做数据看板时确实踩过,一个值为0的指标被当成"没找到",导致整块图表没渲染出来。

如果只是判断存在性而不需要元素本身,用some更贴切,因为它返回布尔值,语义上没有"找东西"的含义。读代码的人一眼就知道你要的是判断,而不是结果。

2.5 Set 与 Object:换个数据结构来存

当判断动作非常频繁,或者数据量很大时,方法层面的优化空间已经很小了,真正有效的是换数据结构。Array.prototype.includes和indexOf都是线性查找,时间复杂度 O(n);而Set.prototype.has底层是哈希表,平均 O(1)。

把数组转成 Set 只需要一行:

const idList = [1001, 1002, 1003]; const idSet = new Set(idList); console.log(idSet.has(1002)); // true

代价是转换本身要遍历一次数组,所以如果只判断一次,转 Set 反而是亏的。判断次数越多,收益越大。经验值大概是这样:同一个集合上判断三次以上,转 Set 就开始划算了;判断上百次时差距会非常明显。

还有一个容易忽略的用法是把Set当作"判断容器"来动态维护。用户点击选中、取消选中,就往 Set 里 add/delete,判断的时候直接 has。这比维护一个数组、每次点击都遍历一遍判断要高效得多,代码也更短。至于用普通对象{}的键来做判断,要注意所有键都会被转成字符串,数字1和字符串'1'会冲突,这是老方案的常见问题,现在基本被Map和Set取代了。

3. 实操过程:从零写一遍并实测

3.1 环境与测试数据准备

为了让下面的实测数据有参考价值,先把环境交代清楚:Node.js 18,跑在普通开发机上,测试用的数组用Array.from生成,元素是递增的整数。测试集我准备了三组:100 个元素、10000 个元素、100000 个元素。每组都测两种情况——查找一个存在的元素(放在数组中间位置)和查找一个不存在的元素(需要遍历完整个数组)。

测试数据生成方式:

function makeArray(size) { return Array.from({ length: size }, (_, i) => i); } const small = makeArray(100); const medium = makeArray(10000); const large = makeArray(100000);

计时用performance.now(),每组操作跑 1000 次取总耗时,避免单次测量的抖动。这里要说明一下:不同机器、不同引擎版本的结果会有差异,我下面给的是相对量级关系,不是绝对性能承诺,你拿自己的场景跑一遍才最准。

3.2 includes 的完整实操

先看最常用的写法,包括基础判断、起始位置、以及和NaN的配合:

const arr = ['red', 'green', 'blue', 'green']; // 基础判断 console.log(arr.includes('green')); // true console.log(arr.includes('black')); // false // 从下标 2 开始找,'green' 只在下标 1 和 3 出现过,跳过 1 之后还能找到 console.log(arr.includes('green', 2)); // true // 负数起始位置:-1 表示从最后一个元素开始 console.log(arr.includes('blue', -2)); // true // NaN 场景 console.log([NaN].includes(NaN)); // true console.log([NaN].indexOf(NaN)); // -1

实测下来的结果符合预期:小数组上includes和indexOf的耗时几乎没有区别,因为都是线性扫描,只是内部比较函数不同。到了 100000 这个量级,单次查找在微秒级别,但如果你在一帧渲染里跑几百次,累加起来就开始影响帧率了。这也是为什么我在列表渲染场景里更倾向于先把数组转成 Set。

提示:includes的fromIndex传负数时,实际起点是length + fromIndex,如果算出来还是负数则从 0 开始。这个规则和indexOf一致,但和slice的负数规则容易混淆,写的时候可以先把起点算清楚再传。

3.3 indexOf 的完整实操

indexOf的实操重点是"顺便拿到位置"这个能力,以及避免返回值误判:

const arr = ['red', 'green', 'blue', 'green']; const idx = arr.indexOf('green'); console.log(idx); // 1,首次出现的位置 // 找第二次出现的位置:从上次结果 +1 继续 const second = arr.indexOf('green', idx + 1); console.log(second); // 3 // 严谨的判断写法 if (arr.indexOf('red') !== -1) { console.log('包含 red'); } // 找出所有出现位置的写法 function findAll(arr, target) { const result = []; let i = arr.indexOf(target); while (i !== -1) { result.push(i); i = arr.indexOf(target, i + 1); } return result; } console.log(findAll(arr, 'green')); // [1, 3]

这个findAll是个挺实用的小工具,我在做文本高亮、日志定位的时候用过几次。要注意它是逐个 indexOf 调用,元素出现次数多的时候调用次数也会上去,如果只是想知道"出现几次"这种统计需求,用filter或者reduce会更直接。

实测数据上,indexOf在 100000 元素的三次测试里和includes基本打平,差距在噪声范围内。所以性能不应该是这两个之间做选择的理由,选哪个只看语义和NaN需求。

3.4 some 的完整实操(对象数组)

some的实操核心是回调怎么写。我按从简到繁排个序:

const users = [ { id: 1, name: 'alice', age: 24, active: true }, { id: 2, name: 'bob', age: 31, active: false }, { id: 3, name: 'carol', age: 28, active: true } ]; // 单条件 const hasBob = users.some(u => u.name === 'bob'); console.log(hasBob); // true // 多条件组合 const hasActiveAdult = users.some(u => u.active && u.age >= 18); console.log(hasActiveAdult); // true // 判断纯值数组里有没有满足条件的数字 const nums = [3, 8, 12, 5]; const hasBig = nums.some(n => n > 10); console.log(hasBig); // true

回调里有个细节值得说:some一旦命中就停止遍历,所以把"命中概率高"的条件放在前面能省时间。但这只对逻辑与不成立的情况有影响——比如u.active && u.age >= 18,如果active是假,后面的比较根本不会执行。这种短路求值本身就有优化效果,不用刻意重排。

另一个要注意的是回调里别做副作用。我见过在some回调里发请求、改全局变量的代码,因为不知道回调会被执行几次,行为完全不可预测。some的定位就是纯判断,任何"顺便做点别的"的想法都应该挪出去。

3.5 findIndex 的落地场景

findIndex最典型的搭配是splice。因为它返回下标,可以直接当删除、插入的锚点:

const list = [ { id: 'a', label: '首页' }, { id: 'b', label: '详情' }, { id: 'c', label: '设置' } ]; const targetId = 'b'; const index = list.findIndex(item => item.id === targetId); if (index !== -1) { const [removed] = list.splice(index, 1); console.log('已移除', removed.label); // 已移除 详情 } else { console.log('目标不存在,无需操作'); }

这种"先判断再操作"的写法在数组变更逻辑里非常常见。如果只有删除需求,其实有个更省事的替代:用filter重新生成一个新数组list.filter(item => item.id !== targetId)。两种写法各有场景——splice是原地修改,filter是产生新数组。在 React、Vue 这类依赖引用变化来触发更新的框架里,我一般倾向于filter或map这类不可变操作,避免状态被意外共享;而在纯数据处理的脚本里,splice省内存。

3.6 性能实测与数据对照

下面是我这次测试的大致结果,数值单位是"跑 1000 次的总毫秒数",用来做相对比较:

数据量查找情形includesindexOfsomeSet.has
100命中中间约 1约 1约 4约 0.3(含构建)
10000命中中间约 6约 6约 20约 1
10000不存在约 12约 12约 38约 1
100000不存在约 120约 120约 380约 1

几个结论值得记一下。第一,includes和indexOf性能几乎一样,选择依据只在语义和NaN。第二,some因为有函数调用开销,比前两者慢三到四倍,小数组上无所谓,大数组高频调用时要注意。第三,Set在多次查询的聚合场景下优势压倒性,因为它把查找变成了常数时间。

这里必须补一句:上表里Set那一列没有计入构建成本。构建一个 100000 元素的 Set 需要几十毫秒,如果只查一次,用 Set 是亏的。所以正确的使用姿势是"构建一次,查询多次",把 Set 缓存在变量或者组件的状态里,而不是每次判断前都临时转一遍。

4. 踩坑实录:NaN、稀疏数组与引用类型

4.1 NaN 判断:includes 和 indexOf 的分水岭

这个坑我前面提过,但值得单独展开,因为我见过真实项目里因为它产生数据错乱。场景是这样的:后端返回一列测量值,缺失的用NaN填充,前端要判断这批数据里有没有缺失值来显式提示。代码写的是:

const values = [12.5, NaN, 8.3]; console.log(values.indexOf(NaN) !== -1); // false,误判为没有缺失 console.log(values.includes(NaN)); // true,正确

原因就是两个方法用的比较算法不同。这是语言层面的定义,不是实现差异,所以不用指望某个引擎会"优化"掉它。判断规则可以记成一句话:只要你的数据里可能出现NaN,就用includes或some,别用indexOf和lastIndexOf。

顺便说下-0。SameValueZero认为-0和+0相等,所以[-0].includes(0)是true。如果用Object.is去比,Object.is(-0, 0)是false。日常业务里很少需要区分正负零,但如果涉及金融计算或者坐标运算,这个区别可能被某个严谨的库依赖,心里有个数就行。

4.2 稀疏数组和 undefined 的迷惑行为

稀疏数组是那种"数组长度是 5 但中间几个位置没有被赋值"的情况:

const sparse = [1, , , 4]; // 长度为 4,下标 1 和 2 是空位 console.log(sparse.length); // 4 console.log(sparse.includes(undefined)); // true console.log(sparse.indexOf(undefined)); // 1

includes和indexOf都会把空位当成undefined来处理。但forEach、map、filter、some、find这些接受回调的方法会跳过空位,回调根本不会被调用。这个差别会导致同一份数据在两种判断方式下得到不同结果。我遇到过数组来自new Array(10)然后只填了部分位置的情况,用some判断undefined是否存在返回了false,因为回调压根没跑到空位。

处理办法很简单:如果数据里可能有空位,先规范化,用Array.from(arr)或者展开运算符[...arr]转一遍,空位会变成实打实的undefined,之后所有方法行为就一致了。

注意:new Array(n)和Array(n)生成的都是带空位的数组,不是n个undefined。写初始化数组的代码时,Array.from({length: n}, () => 0)这种形式更可控。

4.3 对象数组里 includes 为什么永远返回 false

这是新手最常见的困惑之一:

const items = [{ id: 1 }, { id: 2 }]; console.log(items.includes({ id: 2 })); // false

看起来{ id: 2 }明明在里面,为什么是false?因为includes比的是引用地址,你写的{ id: 2 }是一个新对象,地址和数组里那个不同,自然不相等。哪怕两个对象的内容一模一样,也不相等。

唯一能让includes返回true的写法是拿同一个引用去比:

const target = items[1]; console.log(items.includes(target)); // true

所以对象数组的存在性判断,正确工具是some:

console.log(items.some(item => item.id === 2)); // true

这个坑的隐蔽性在于,当数组元素恰好是原始类型时includes工作良好,开发者很容易养成"判断存在性就用 includes"的习惯,切到对象数组就翻车。我的建议是形成条件反射:看到数组元素是对象或者数组,立刻切成some。

4.4 类型不一致造成的静默失败

includes和indexOf用的都是严格比较,不做类型转换,所以'2'和2是不相等的。前端最常见的类型污染来源是表单输入和 URL 参数,两者拿到的都是字符串。我做过的一个筛选功能就是这样翻车的:

const selectedIds = [1, 2, 3]; // 来自接口,数字 const currentId = '2'; // 来自 URL 查询参数,字符串 console.log(selectedIds.includes(currentId)); // false console.log(selectedIds.includes(Number(currentId))); // true

解决办法有两种思路。一是统一在数据入口处做类型归一化,把 URL 参数、表单值全部转成数字再比较;二是判断时显式转换。我更倾向第一种,因为散落在各处的Number()调用迟早会被漏掉一处。做数据归一化时还容易忽略null和undefined——Number(null)是0,Number(undefined)是NaN,如果入参可能为空,得先判空再转。

5. 问题排查速查表与调试手法

5.1 高频问题速查表

把上面这些坑整理成一张表,出问题的时候直接对着查:

现象可能原因解决方向
对象数组判断恒为 false用 includes/indexOf 比引用改用 some + 字段比较
第一个元素判断失败indexOf 返回值当布尔值用显式写!== -1
NaN 找不到indexOf 不支持 NaN改用 includes 或 some
空位元素判断不一致回调类方法跳过空位先用 Array.from 规范化
明明是 2 却匹配不上字符串和数字混用入口处统一类型
大数据量判断卡顿线性查找 + 高频调用转 Set 缓存后 has
find 返回的假值元素被误判用if (item)判空改判!== undefined
some 回调不执行数组是空位或长度为 0检查数组实际内容

5.2 我的排查顺序

遇到"判断结果不对"的问题,我一般按这个顺序走,基本两三步就能定位。

第一步,先把数组打印出来,确认它的真实内容。用console.log(JSON.stringify(arr))比直接打印数组更清楚,因为对象数组在控制台里展开容易看花眼,而且NaN和null在JSON.stringify下会变成null和null,这本身也能帮你发现特殊值。如果数组很大,就打印长度和前几个元素。

第二步,确认数据类型的分布。如果数组里混了字符串和数字,打印arr.map(x => typeof x)一眼就能看出来。这一步能解决相当一部分"看起来一样但不相等"的案例。

第三步,单独把判断语句拿出来在一个最小环境里跑一遍。比如在控制台里直接[1,2,3].includes('2'),确认方法行为符合你的预期。很多时候问题不在方法,而在于你传进去的值和想象中不一样。

第四步,如果是性能问题,先在判断语句前后打时间戳,量出真实的耗时,再决定要不要换数据结构。不要凭感觉优化,我见过有人把只跑十几次的判断改成 Set,代码复杂度上去了,收益基本为零。

6. 工程化收尾:封装习惯与选型经验

6.1 把判断逻辑抽成工具函数

项目里同一个判断逻辑出现在五个地方的时候,就该抽函数了。模板大概长这样:

/** * 判断数组中是否存在满足条件的元素 * @param {Array} list - 待判断的数组 * @param {Function|*} matcher - 条件函数或直接比较的值 * @returns {Boolean} */ function contains(list, matcher) { if (!Array.isArray(list) || list.length === 0) return false; if (typeof matcher === 'function') { return list.some(matcher); } return list.includes(matcher); }

这个封装的好处有两个。一是把"空数组"和"非数组"的边界处理收在一处,调用方不用每次都写if (!list || !list.length)。二是把"传值还是传函数"这个分支统一了,调用方写起来更自然。代价是多了一层函数调用,在高频路径上要谨慎,我在渲染循环里用的判断函数一般会把空值检查和比较逻辑内联展开,不做这层包装。

关于异常处理,我的原则是:判断函数不应该抛异常。参数不合法时返回false而不是抛错,因为调用方的语义是"存在吗",答案是"不存在"比"崩了"更合理。但如果这个数组是核心业务数据,缺失意味着严重的上游问题,那就该在数据入口处做校验并上报,而不是让判断函数默默吞掉。

6.2 选型的三条经验

写到这里,把我在实际项目里总结的选型原则说得再直接一点。

第一条,先看元素类型。原始类型用includes,对象或数组用some。这一条能覆盖八成场景,而且不用想性能问题。

第二条,再看是否需要结果。只判断存在性用some,需要元素用find,需要位置用findIndex。不要为了"万一以后要用"就选返回信息更多的方法,多余的返回值会误导读代码的人。

第三条,最后看调用频率。同一个集合上判断超过三次,考虑转 Set;判断次数在几十次以上且数据量大,转 Set 基本是必须的。这条放在最后是因为它增加了代码复杂度,不该在没有性能收益的时候提前引入。

6.3 和框架打交道时的额外注意

在 Vue 和 React 里用这些方法,有两点和原生 JS 不太一样。

响应式方面,Vue 2 依赖Object.defineProperty,数组的下标赋值和长度修改不会触发更新,虽然includes、some这类读取方法本身没问题,但如果你在计算属性里依赖判断结果,要确保作为数据源的数组是通过push、splice这类被重写的原生方法修改的,或者干脆整个替换数组。Vue 3 用 Proxy 代理就没这个限制。React 里则是另一回事:数组引用不变,useMemo的依赖判断不会重新计算,所以每次更新数组都要返回新数组,filter、map、展开运算符都是安全的选择。

不可变数据方面,我在 React 项目里基本不用splice,全用filter和展开。这不只是为了判断方法好写,更是为了避免两个组件共享同一个数组引用导致的状态污染。这种 bug 排查起来极其费劲,因为数据是被"别处"改掉的,当前组件的代码看不出任何问题。

提示:如果判断结果要参与v-if或者条件渲染,且数组可能为空或未加载,把null和undefined的检查放在判断之前。null.includes(...)会直接抛错,这类错误在开发环境常见,上线后因为数据来源不同反而未必触发。

最后分享一个我在做数据看板时用过的做法。当同一个集合要反复判断、而且集合会动态变化时,我把它包成一个小结构:内部维护一个 Set 用于判断,同时保留原数组用于渲染。增删的时候两边同步更新,判断走has,渲染走数组。代码多写了几行同步逻辑,但把每次渲染里的线性查找全干掉了,列表项上百个的看板上帧率改善很明显。什么时候值得这么做?我的判断标准是:单帧内对同一个集合的判断次数乘上集合长度超过一万,就值得考虑。

返回列表