☰
ES6 数组方法 find 与 findIndex 详解
2026/10/15 2:53:10 网站建设 项目流程

先问一个我面试时经常抛出去的问题:给你一个对象数组,要按某个字段取值,你的第一反应写什么?我见过太多同行下意识就是arr.filter(item => item.id === id)[0],然后花半天排查为什么结果永远不对。而find()和findIndex()就是为"按条件找元素"这个场景而生的:find()返回第一个符合条件的元素本身,findIndex()返回第一个符合条件的下标。这篇文章把这两个方法从参数、返回值、边界行为到业务组合用法完整拆一遍,顺带把近几年踩过的坑全交代出来。适合刚接触 ES6 数组方法的新人,也适合写过不少代码但没细究过稀疏数组、遍历副作用这些细节的老手。

1. 数组查找的旧时代:filter()[0]、for循环和indexOf的别扭之处

1.1 filter()[0]:看起来聪明,实际做了三件多余的事

很多人在 find 出现之前都写过这种代码:

const list = [ { id: 1, name: '甲' }, { id: 2, name: '乙' }, { id: 3, name: '丙' }, ]; const target = list.filter(item => item.id === 3)[0];

这段代码能跑,结果也对,但在代码评审里基本属于会被打回去重写的级别。原因有三个:

第一,filter()会遍历完整个数组,哪怕第一个元素就命中条件,它也会把后面所有元素全部扫一遍。而你的真实意图是"找到第一个就收工",语义和实际行为完全对不上。

第二,filter()会构造一个全新的数组,把命中的所有元素都装进去,然后你只取[0]。如果数组很大且命中项很多,这个中间数组又大又没必要,纯粹给垃圾回收增加压力。

第三,从可读性角度讲,filter(...)[0]让读者先看到"我要筛选出一批符合条件的",再看"哦原来只要第一个"。绕了一层弯。find()把"只要第一个"这个意图直接写在方法名里,一眼就能读懂。

有人会说:"我这样写多少年也没出问题。"没出问题不代表没问题,它只是把浪费和歧义藏在了习惯里。尤其是后面要接判空逻辑时,filter(...)[0]的.length和[0]会让人反复确认边界,非常容易埋 bug。

1.2 手写 for 循环:逻辑没错,但样板代码太多

在 find 出现之前的另一种常见写法是 for 循环加 break:

let target = null; for (let i = 0; i < list.length; i++) { if (list[i].id === 3) { target = list[i]; break; } }

这个写法最大的问题不是性能,而是样板代码把"我要找什么"这个真实意图给淹没了。你需要手动声明一个外部变量target,在循环体里自己判断条件、自己 break,阅读代码的人得逐行确认你有没有漏写 break、有没有把变量初始化对。

如果这样的查找逻辑在业务代码里出现三五次,每段都是八九行,那整个文件就被这些流程控制占满了,真正想表达的业务逻辑反而看不清。for 循环当然没错,它是一种最底层的工具,但用它来表达"取第一个符合条件的元素"这种高频需求,就像用螺丝刀拧螺栓——能用,但明明有更顺手的工具。

1.3 find 家族的上场时机:与 some、filter、indexOf 的分工

ES6 里加入的find()和findIndex(),补齐的就是这个高频场景。它们和已有的几个方法形成了明确分工:

方法返回内容是否提前停止典型用途
some()true / false是只判断是否存在
filter()新数组(全部匹配项)否取所有匹配项
find()第一个匹配元素 / undefined是取单个匹配项
findIndex()第一个匹配下标 / -1是需要下标做增删操作

注意,find()和findIndex()都会在找到第一个匹配项后立刻停止遍历,这是它们和filter()最本质的性能差异。后面讲业务实战时我会再展开说。

2. find() 的完整语义:参数、返回值与边界行为

2.1 回调函数的三个参数到底分别什么时候用

find()的完整签名是这样:

arr.find(callbackFn(element, index, array), thisArg)

callbackFn接收三个参数,但绝大多数时候你只用得上第一个:

  • element:当前遍历到的元素。95% 的场景到这里就够了。
  • index:当前下标。需要限定查找范围,或者想找"第二次出现"时用。
  • array:调用find()的那个原始数组。只有回调里需要反向引用整个数组时才用。

举两个用到第二和第三参数的例子。第一个,找数组里第二个值为 3 的元素:

const nums = [1, 3, 3, 5]; const secondThree = nums.find((item, index) => item === 3 && index > 0); // 返回 3

第二个,找第一对相邻重复的元素:

const values = [5, 8, 8, 3]; const firstDup = values.find((value, index, arr) => value === arr[index + 1]); // 返回 8

代码评审里我见过有人为了拿下标专门绕一圈:先用find()拿到元素,再用indexOf()找下标。其实第三个参数就在回调里,不用白不用。

2.2 返回值的设计逻辑:元素本身,而不是包装数组

find()返回的是命中元素的值本身,不是包含这个元素的数组,也不是下标。如果遍历完都没有命中,返回undefined。

这个设计有一个容易忽视的坑:当"没有匹配"和"第一个匹配的元素恰好是 undefined"两种情况,find()都会返回undefined,从返回值上无法区分。

const list = [undefined, 1, 2]; const found = list.find(item => item === undefined); console.log(found); // undefined,这到底是找到了还是没找到?

当然,如果数组里存在undefined元素,并且你的条件就是匹配它,那返回值是 undefined 也说得通。只是业务代码里一旦牵扯到"拿到值之后继续处理",就得先想清楚这一层。想要明确判断,可以用findIndex()看返回的是-1还是真实下标。

因为find()返回的是元素本身,所以可以直接在后面接属性访问或链式调用:

const item = list.find(i => i.type === 'VIP'); console.log(item?.name); // 配合可选链,没找到也不会报错

2.3 稀疏数组的空槽位:find 和 filter 的行为不一样

这是我见过绝大多数人不知道的细节。find()在遍历稀疏数组时的行为和filter()、map()、forEach()不一样。

先看一个例子:

const sparse = [1, , 3]; // 中间的索引 1 是空槽 sparse.find((item, index) => { console.log(index, item); return false; }); // 输出:0 1 // 输出:1 undefined // 输出:2 3

看到了吗?find()会访问索引 1 这个空槽,只是回调拿到的item是undefined。但同样的数组,换成filter():

sparse.filter(() => true); // 返回 [1, 3],空槽被直接跳过

为什么会这样?规范层面,find()内部是直接读取当前下标的值(类似arr[i]),没有先检查这个下标是否存在;而filter()会先做存在性检查,确认下标存在才读取。读取一个不存在的下标,结果就是undefined,所以回调照样被调用,只是拿到的值是undefined。

实际开发中,空槽出现的场景不算多,但new Array(5)、delete arr[1]都可能制造出来。一旦数组里混入空槽,find()就可能让你"找到"一个假的 undefined,而filter()则是悄悄忽略它。如果代码里对稀疏数组有依赖,这个差异早晚会坑你一次。想消除空槽,可以用Array.from()把它转成显式的 undefined:Array.from([1, , 3])的结果是[1, undefined, 3]。

3. findIndex() 与 indexOf():两个"找下标"方法的真实差距

3.1 indexOf 的优势与天花板

indexOf()大家用得早、用得多,它的机制很简单:拿参数去和数组里每个元素做严格相等比较,返回第一个相等的下标,找不到就返回 -1。

const fruits = ['apple', 'banana', 'orange']; console.log(fruits.indexOf('banana')); // 1

这个方法的优势是"零学习成本、速度快",但它有两个硬伤。

第一个硬伤:NaN永远找不到。因为NaN !== NaN,严格相等拿它没办法:

const nums = [1, NaN, 3]; console.log(nums.indexOf(NaN)); // -1,找不到 console.log(nums.findIndex(Number.isNaN)); // 1,找到了

第二个硬伤:它做不了对象属性匹配。两个内容一样的对象,引用各不相同,indexOf()不可能帮你按字段找:

const users = [{ id: 1 }, { id: 2 }]; console.log(users.indexOf({ id: 2 })); // -1,两个对象不是同一个引用 console.log(users.findIndex(item => item.id === 2)); // 1

3.2 findIndex 处理复杂条件的思路

findIndex()的回调和find()完全一样,接收(element, index, array)三个参数,返回的是第一个让回调返回真值的元素下标。

它能做的事情明显比indexOf()多一个维度:indexOf()只能比"值",findIndex()可以比"条件"。这个条件可以是对象属性、可以是范围判断、可以是多个字段组合:

const orders = [ { id: 1, status: 'pending', amount: 20 }, { id: 2, status: 'paid', amount: 50 }, { id: 3, status: 'pending', amount: 80 }, ]; const idx = orders.findIndex(item => item.status === 'pending' && item.amount > 50); // 返回 2

不过findIndex()没有indexOf()那样的fromIndex参数。想从指定位置开始找,得自己在回调里加个下标判断:

// 从下标 2 开始找值大于 10 的元素 const idx = list.findIndex((item, index) => index >= 2 && item.value > 10);

indexOf()只用一个参数就能表达"从第几个开始找",这里需要手动处理。这是它俩在能力边界上一个不大不小的差异,面试里偶尔会问到。

3.3 返回 -1 之后:常见业务姿势

findIndex()找不到时返回 -1,这和indexOf()保持一致。拿到下标之后最典型的操作是配合splice()做删除:

const list = [{ id: 1 }, { id: 2 }, { id: 3 }]; const idx = list.findIndex(item => item.id === 2); if (idx !== -1) { list.splice(idx, 1); }

另一个常见场景是"选中/取消选中"的切换逻辑:如果已经在列表里就移除,不在就加入。这种需求用findIndex()判断下标,比用find()拿元素再想办法找位置要干净得多。

这里有一个我自己踩过的坑:findIndex()返回 -1 的时候,直接拿去当数组下标用,会访问到数组的倒数第一个元素(arr[-1]是 undefined),然后代码里可能出现一些莫名其妙的"假成功"。养成习惯:findIndex()的返回值先和 -1 比较,再决定下一步操作。

4. 业务场景复现:选中态确认、配置查询与引用修改

4.1 下拉选中项的即时确认

业务里最常用的场景之一,是拿到一个 ID 后,要在选项数组里反查出完整对象,用来展示名称、分组或者其他关联字段。

const options = [ { value: 'A', label: '方案A', group: 'G1' }, { value: 'B', label: '方案B', group: 'G1' }, { value: 'C', label: '方案C', group: 'G2' }, ]; const value = 'B'; const selected = options.find(item => item.value === value); if (selected) { console.log(`当前选中:${selected.label},所属分组:${selected.group}`); }

注意这里的选择逻辑:如果你只想知道"这个值在不在选项里",用some()更合适,它返回布尔值,语义更直接;如果你还要拿对象的其他字段,find()一步到位,少写一次查找。

4.2 用 find 取代 filter()[0]:不仅是风格问题,也是性能问题

前面说了find()会提前终止,filter()不会。在数据量大的场景,这个差异非常直观。

假设一个列表有 1 万条记录,要找第一个满足条件的数据。filter()在最坏情况下要遍历全部 1 万条,然后构造一个包含所有匹配项的新数组;find()从下标 0 开始,命中即返回。如果命中项出现在数组前部,后面几千条根本不需要碰。

但我也得说句公道话:日常业务数据量通常不大,几百条记录的性能差异可以忽略不计。所以我在代码评审里要求别人把filter(...)[0]改成find(),第一理由不是性能,而是语义:filter()表达的是"把所有符合条件的都筛出来",你实际想要的却是"拿第一个"。让代码表达真实意图,后续维护的人读起来才不会想歪。性能收益算是顺手拿到的红利。

4.3 返回值是引用:一个改错数据的经典事故

find()返回的是数组元素的引用,不是拷贝。这意味着你拿到的对象和数组里那个对象是同一个,改它等于改原数组。

const cart = [ { sku: 'N1', price: 10 }, { sku: 'N2', price: 20 }, ]; const item = cart.find(goods => goods.sku === 'N2'); item.price = 99; console.log(cart[1].price); // 99,原数组被改了

这个特性大部分时候是好事,比如购物车里改数量直接改原对象就行。但如果你以为"找到了一个副本"而随意修改,就会在不知不觉中污染原数据。项目中出过类似事故:有人用find()取出配置项,改了字段想临时覆盖展示,结果全局配置被悄悄改掉,排查了很久才定位到这一行。

想要副本,就手动拷贝:const item = { ...cart.find(...) }。如果对象里有嵌套结构,浅拷贝还不够,得用structuredClone或深拷贝工具。这一点在代码评审时值得专门强调。

5. 高频踩坑实录:从 this 指向到遍历副作用

5.1 回调里的 this:为什么传了 thisArg 还是 undefined

find()的第二个参数thisArg可以指定回调里this的指向,但这里有个经典的坑:如果你用箭头函数写回调,thisArg会被直接忽略,因为箭头函数的this在定义时就已经确定,传什么都没用。

看这段代码:

const config = { max: 5, findMatch(list) { return list.find(function (item) { return item.value > this.max; }, this); }, }; config.findMatch([{ value: 3 }, { value: 8 }]); // 返回 { value: 8 }

普通函数配合thisArg,this指向config,能拿到max。但如果改成箭头函数:

const config = { max: 5, findMatch(list) { return list.find(item => item.value > this.max, this); }, };

箭头函数的this是词法作用域里继承的,也就是外层findMatch里的this。在这个例子里它恰好也是config,所以结果一样。但如果你把回调函数抽到外面定义,或者嵌套到别的地方,箭头函数里的this就可能不是你想要的东西。再加上严格模式下普通函数的this默认是undefined,于是经常出现"为什么this突然拿不到值"的报错。

我的建议:现代代码里直接用箭头函数加外层this是主流写法,干净利落;只有当回调需要动态改变this指向时才用function加thisArg。千万别两个混着用,那是在给自己埋雷。

5.2 遍历过程中修改数组:append、splice 与 delete 的行为

find()开始遍历时,数组的length就已经被锁定了。遍历过程中你往数组里 push 新元素,新元素不会被访问到:

const arr = [1, 2, 3]; arr.find((item, index) => { arr.push(item + 10); console.log(index, item); return false; }); // 只输出:0 1 / 1 2 / 2 3 // push 进去的 11、12、13 永远不会被访问

真正容易出问题的是splice()。如果在回调里删除了当前元素,后面的元素会往前补位,find()下一次访问的下标却不会变,于是会跳过一个元素:

const data = [1, 2, 3, 4]; data.find((item, index) => { if (item === 2) { data.splice(index, 1); // 删除当前项 } console.log(index, item); return false; }); // 输出:0 1 / 1 2 / 2 4 // 元素 3 被跳过了

原因是:删除 2 之后,3 从下标 2 挪到了下标 1,而find()下一次访问的是下标 2,拿到的是 4。

如果是delete操作,把下标 1 变成空槽,行为又不一样了。前面在稀疏数组那节说过,find()会照样访问它,回调拿到的值是undefined:

const nums = [1, 2, 3]; delete nums[1]; nums.find((item, index) => { console.log(index, item); return false; }); // 输出:0 1 / 1 undefined / 2 3

结论很明确:不要在find()和findIndex()的回调里修改数组结构。要么把匹配结果收集完再统一处理,要么干脆用普通 for 循环自己控制步进,别让隐式的遍历规则给你添乱。

5.3 一段真实的排查记录:把回调当"退出条件"导致条件反写

有次帮同事排查一个问题。需求很简单:订单列表里找出第一个未过期的订单做展示。他写的是:

const firstValid = orders.find(order => order.expired);

结果页面上展示出来的第一单偏偏是已过期的。他当时的思路是:find()的回调返回 true 表示"继续找",返回 false 表示"停下",所以写了order.expired想让它跳过已过期的。这是典型的 for 循环惯性思维——把回调当成了循环里的"终止条件"。

实际上,find()的回调返回 true 表示"这个就是我要找的那个",返回 false 表示"这个不是,继续看下一个"。所以他那行代码找的是"第一个已过期的订单",和需求完全反了。

修改很简单:

const firstValid = orders.find(order => !order.expired);

排查过程本身没什么技术含量,但这个思维误区很典型。写find()和findIndex()的回调时,想清楚一件事:回调里写的是"什么样的元素算目标",不是"什么时候停下来"。如果写完条件之后发现逻辑读起来像"不满足才返回 true",那多半是惯性思维在作祟,建议回头再读一遍。

6. 选型习惯与一点冷门补充

6.1 按返回值需求选方法:一张速查表

我把数组里几个容易混淆的查找方法整理成一张表,按"你到底想要什么返回值"来选,基本不会选错:

需求推荐方法返回值备注
只判断是否存在some()boolean提前终止
判断原始值是否存在includes()boolean用 SameValueZero 比较,能找到 NaN
找第一个匹配元素find()元素 / undefined提前终止
找第一个匹配下标findIndex()下标 / -1提前终止
取全部匹配项filter()新数组不提前终止
按简单原始值找下标indexOf()下标 / -1严格相等,NaN 会失灵

补充一点:includes()和indexOf()虽然都是"按值找",但includes()用的是SameValueZero比较规则,NaN能被它正确识别:[1, NaN, 3].includes(NaN)返回 true,indexOf(NaN)却是 -1。这也是面试里常考的隐藏差异。

6.2 提前终止特性还能这么用

find()的提前终止特性,除了常规查找,还能写出很简洁的判断逻辑。

比如按优先级取第一条匹配记录:

const matched = list.find(item => item.type === 'VIP') || list.find(item => item.type === 'Normal');

再比如判断一个数组是否存在相邻重复:

const hasDup = values.some((value, index, arr) => value === arr[index + 1]);

这种写法比 for 循环加多个 return 分支更直白,可读性和维护性都更好。层级不深的时候可以放心用,但如果优先级判断超过两三层,还是建议拆成独立函数,别用一堆||链把自己绕晕。

6.3 兼容性提醒与个人命名习惯

find()和findIndex()是 ES6 的方法,IE 全系列不支持。如果项目还要兼容老浏览器,需要引入 polyfill 或者依赖转译工具。现在的主流开发环境基本都用现代浏览器,这个约束已经很少有人提了,但维护老项目的同学还是值得留意一下。顺带说一句,findLast()和findLastIndex()是 ES2023 新增的,从数组末尾往前找,需要的可以关注。

最后分享一个我自己的体会:数组方法本身不难,真正容易出错的地方永远是"这个方法到底返回什么、什么时候停下来、回调里拿到的是什么"。find 和 findIndex 把这些都定义得很干净,前提是你别用 for 循环那套思维去写回调条件。如果你把今天说的这些边界行为在控制台里自己跑一遍,尤其是稀疏数组和遍历中改数组那两个例子,以后再遇到类似问题基本就能秒判。写代码这事,很多时候不是知道多少 API,而是清楚每个 API 的脾气。

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

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

立即咨询