资讯详情

JS 数组元素存在判断:indexOf、includes、some 与 Set

📅 2026/9/30 10:34:11 | 华诺云谱 👁 阅读
JS 数组元素存在判断:indexOf、includes、some 与 Set
上周排查一个列表筛选的问题最后定位到的代码就一行if (arr.indexOf(id))。写这行的人很自然地认为 indexOf 会返回布尔值或者至少找不到的时候是假值。结果恰恰相反——当 id 正好是数组第一个元素时indexOf 返回 00 是假值条件不成立而元素不存在时返回 -1-1 是真值条件反而成立。整个筛选逻辑刚好反了。js 判断数组中是否存在某个元素这件事看起来是本入门级的操作但真到生产代码里翻车的方式远比想象中多NaN 永远找不到、对象数组用值比较查不到、稀疏数组的语义前后不一致、数据量大了每次查询都扫全表。这篇文章把这件事重新拆一遍——四种主流方法各自怎么用、它们在相等这件事上的定义差在哪、什么时候该换 Set、什么时候老老实实写 for 循环反而更好。刚入门的可以当查漏补缺写过几年的人也能顺一遍自己在语义和性能上的直觉。1. 一行if (arr.indexOf(id))引出的四个问题1.1 返回 -1 被当成 true 的那个下午那行 bug 之所以能上线是因为测试数据里 id 恰好都不在第一位、也不在列表里两个错误互相抵消了。真正暴露它的是一个用户把筛选条件设成了列表首项页面开始返回没有匹配结果。这类问题的根子在于indexOf 的参数语义和返回值语义是分离的。它接收的是你要找什么返回的是在哪个下标而不是在不在。当下标是 0 时这个信息本身就是假值这就是为什么正确的写法必须显式比较// 错误依赖隐式布尔转换 if (arr.indexOf(id)) { /* ... */ } // 正确显式判断 if (arr.indexOf(id) ! -1) { /* ... */ }! -1和 0两种写法在语义上都对。我个人更习惯! -1因为它在视觉上和函数返回的哨兵值直接对应review 的时候不容易看漏。 0的问题在于它暗示索引是个有大小意义的量而实际上索引 0 和索引 7 在这里地位是等同的。顺带说一句ESLint 里有no-restricted-syntax或者一些团队自建的规则可以拦住这种隐式转换但更根本的还是先理解返回值是什么。1.2 四种方法其实是四种不同的相等定义大部分人把这四种方法当成同一个东西的不同写法实际不是。它们在底层用的是三套不同的比较算法方法比较算法能否找 NaN返回值空数组结果indexOf严格相等否索引或 -1-1includesSameValueZero是布尔值falsefind回调自定义取决于回调元素或 undefinedundefinedsome回调自定义取决于回调布尔值false和 SameValueZero 的差别只有两点NaN 是否等于自己以及 0 和 -0 是否区分。看起来是边角料但在真实数据里NaN 出现在数组里的概率比你想的高得多——任何一次Number()转换失败、任何一次 JSON 反序列化出的非数字字段都可能给你塞进来一个 NaN。而find和some走的是回调路线比较规则完全由你决定这也意味着它们能做前面两个方法做不到的事按字段查、按范围查、按复合条件查。理解这层差异之后选型就不再是哪个顺手用哪个而是我要的相等是什么。1.3 我给自己定的判断标准我现在的习惯是按三个问题快速决策元素是原始值还是对象原始值走includes对象走some或find。我要的是布尔还是那条数据只要判断存在性就some还需要拿到对象本身就用find。这个数组会被反复查多少次超过一定次数就转Set。这三条基本上覆盖了我日常 90% 的场景。剩下的 10% 是要不要兼容非常老的环境但那部分后面单独说。2. indexOf兼容性最好语义却最容易误读2.1 返回值不是布尔值这是所有坑的源头indexOf是这个家族里资历最老的成员从最早的标准里就存在所以它的兼容性无懈可击。也正因为资历老它带着那个年代特有的设计痕迹返回索引而不是布尔。现在回看这个设计其实说得通——它同时承担了查找和判断两个职责。你要位置它就是位置你要布尔自己比较一下。问题在于自己比较一下这件事很多人偷懒不写。我在 code review 里见过的最常见的两种变形// 变形一把 -1 当成存在的反面但写成了 if (arr.indexOf(x)) // 变形二误以为 indexOf 返回 -1 时是 false const exists !!arr.indexOf(x); // 只要元素存在就一定是 true第二种比第一种更隐蔽因为加了!!看起来像是刻意转成布尔实际上把语义完全理解反了。这种代码在单个元素存在时行为正确在数组为空时返回 false 也看起来正确只有在多种情况下才暴露。我的建议是如果你的目的只是在不在就不要用 indexOf。用它反而多一步心智负担还给后人留下误读空间。2.2 NaN 永远找不到严格相等的硬伤严格相等有个著名的性质NaN NaN为 false。所以const nums [1, NaN, 3]; console.log(nums.indexOf(NaN)); // -1 console.log(nums.includes(NaN)); // true这个差异在数据清洗场景里特别致命。假设你有一个从表格导入的数值数组中间夹杂了几个空单元格解析时被转成了 NaN你想判断这个数组里有没有脏数据if (data.indexOf(NaN) -1) { console.log(数据干净); }这段代码永远输出数据干净。正确的做法是用includes或者更明确一点const hasInvalid data.some(Number.isNaN);Number.isNaN比全局的isNaN更安全因为它不会对字符串做隐式转换。isNaN(abc)是 true而Number.isNaN(abc)是 false——前者把不是数字的字符串也算进去了容易误判。2.3 被冷落的第二个参数 fromIndexindexOf支持第二个参数指定从哪个位置开始找这个参数可以传负数const arr [a, b, c, b, a]; arr.indexOf(b); // 1 arr.indexOf(b, 2); // 3 arr.indexOf(a, -1); // 4从倒数第一个开始 arr.indexOf(a, -2); // 4负数会被换算成length fromIndex如果换算结果还是负的就当 0 处理。这个参数实际用起来比看起来麻烦。比如判断某元素是否在数组的后半段出现你得先算Math.floor(arr.length / 2)代码可读性立刻下降。而且includes也支持这个参数但很多人不知道arr.includes(b, 2); // true我在项目里的做法是如果逻辑涉及负数索引或者分段查找宁可先用slice把区间切出来再对结果做判断。虽然多了一次数组拷贝但代码意图清晰得多而且现代引擎对 slice 的优化很好多数场景下这点开销可以忽略。3. includesES2016 之后应该优先考虑的那一个3.1 SameValueZero 到底修好了什么includes引入的时候规范里给它配了一个新算法叫 SameValueZero。名字听着吓人实际规则很简单除了0和-0视为相等、NaN视为等于自己之外其余行为等同于严格相等。对比一下就清楚了console.log(NaN NaN); // false console.log([NaN].includes(NaN)); // true console.log(0 -0); // true console.log([0].includes(-0)); // true console.log(Object.is(0, -0)); // false也就是说SameValueZero 比只多了NaN 等于自己这一条0和-0的处理和一致。Object.is则是更严格的版本它区分0和-0。所以在需要判断NaN的场景里includes是这四种方法中唯一不写回调就能正确工作的。这也让它成为我现在的默认选择。3.2 稀疏数组的空洞一个反直觉的差异这条是这两者之间比较少被提到的一组不一致。稀疏数组指的是[1, , 3]这种中间有空洞的数组——空洞和值为 undefined是两码事空洞表示这个下标上根本没有属性。const sparse [1, , 3]; console.log(sparse.indexOf(undefined)); // -1跳过了空洞 console.log(sparse.includes(undefined)); // true把空洞当成 undefined const explicit [1, undefined, 3]; console.log(explicit.indexOf(undefined)); // 1 console.log(explicit.includes(undefined)); // true行为差异的原因是indexOf在遍历时会先检查该下标上是否存在属性不存在就跳过而includes直接取值空洞取出来就是 undefined于是命中。我不建议任何生产代码依赖这个差异但知道它有好处如果你在维护老代码看到indexOf(undefined) -1却怎么都想不通为什么明明有空位却找不到答案就在这里。更稳妥的做法是遇到稀疏数组先用Array.from或者扩展运算转成密集数组再做判断const dense [...sparse]; // 空洞变成显式的 undefined3.3 includes 在字符串和类数组上的表现includes不只存在于Array.prototype上String.prototype.includes也早就是标配了TypedArray上也有。这带来一个好处判断逻辑可以统一写成.includes不用在字符串和数组之间来回切换写法。hello world.includes(world); // true new Uint8Array([1, 2, 3]).includes(2); // true但要注意arguments这类类数组对象是没有includes的需要先转换。而String.prototype.includes的第一个参数是字符串如果你传了个数组进去会被隐式转成逗号拼接的字符串这个坑和indexOf的老问题一模一样const str a,b,c; str.includes([a, b]); // true因为 [a,b] 被转成了 a,b str.includes([b, a]); // false顺序不对就不行这种用法基本都属于无心之失代码审查时值得多看两眼。4. find 与 findIndex当判断条件不是一个固定的值4.1 引用相等对象数组里最容易翻车的地方对象数组不能用includes和indexOf来按字段查这是新手最常见的困惑来源const users [{ id: 1, name: A }, { id: 2, name: B }]; users.includes({ id: 1, name: A }); // false原因是这两个方法比的是引用而你在括号里写的那个对象字面量是内存里的新对象和数组里那个根本不是同一个。这是完全正确的行为但非常反直觉。正确做法是用find或者someconst target users.find(u u.id 1); // { id: 1, name: A } const exists users.some(u u.id 1); // true这里有一个我踩过的坑find找到元素后返回的是数组里那个对象的引用不是副本。你对返回值做的任何修改都会直接改到原数组上。有一次我在一个工具函数里顺手把返回结果的某个字段规整了一下结果调用方的数据被悄悄改了排查了很久。如果不希望影响原数组记得用扩展运算或者structuredClone复制一份。4.2 回调返回值被隐式转换的坑find的回调返回值会被转成布尔来判断这就带来了一些看起来能跑但语义混乱的写法// 看起来是判断 id 相等实际上只要 id 为真值就命中 const found users.find(u u.id); // 正确写法 const found users.find(u u.id 1);第一种写法在id从 1 开始的自增主键场景下恰好能用但如果哪天数据里出现了 id 为 0 的记录就会静默失效——而 0 作为 id 在某些系统里完全合法。some有同样的问题。回调的返回值一定要显式是布尔表达式不要依赖隐式转换。我在团队规范里把这条写成了硬性要求因为它的失效方式太隐蔽往往要好几个月才暴露一次。4.3 短路求值带来的副作用find和some都在找到第一个满足条件的元素后立即停止遍历。这个特性拿来做性能优化是好的但如果回调里带了副作用就要小心let callCount 0; [1, 2, 3, 4, 5].some(n { callCount; return n 2; }); console.log(callCount); // 3不是 5回调只执行了 3 次就返回了。如果你在回调里做日志上报、累加统计之类的事情这些操作都不会完整执行。反过来这也给你一个优化思路如果数组是排好序的把命中概率高的元素放前面或者用二分查找自己实现find实际执行的比较次数会明显下降。我在处理几千条的分页数据时用过这一招把最常用的几个筛选条件放在数组头部之后查询耗时降了差不多一半——当然这是数据分布带来的收益不是方法本身的。5. some语义上和存在性判断最匹配的写法5.1 some 和 find 的区别不只是返回值类型很多人以为some就是find加个布尔转换功能上是子集。这个理解漏掉了两点。第一some在找不到时返回falsefind返回undefined。这看起来只是返回值格式的差别但在类型层面影响很大some的结果可以直接塞进iffind的结果如果直接塞进if会把找到的是一个假值元素和没找到混在一起。const arr [0, , null]; arr.find(x x 0); // 0假值 arr.find(x x x); // undefined这两种返回值都是假值用if判断分不出来。而some就没这个问题arr.some(x x 0); // true arr.some(x x x); // false第二语义意图不同。some表达的是存在性find表达的是取回。代码的可读性靠的就是这种语义对齐用一个语义不对的方法做另一件事后来看代码的人就得多想一层。所以我的原则很明确只要不关心具体是哪个元素一律用some。5.2 用 some 做区间、复合条件的判断some真正的价值在于它能表达复杂的判断谓词这是includes做不到的// 判断有没有超期的订单 const hasOverdue orders.some(o o.status pending Date.now() o.deadline); // 判断有没有落在某个价格区间内的商品 const hasInRange products.some(p p.price min p.price max); // 判断有没有重复 id const hasDuplicate list.some((item, index) list.findIndex(t t.id item.id) ! index);最后这个例子要注意它是 O(n²) 的复杂度几千条数据就会明显卡顿。更好的做法是用 Set 一次遍历解决后面章节会说。另外some的回调接收三个参数元素、索引、原数组。用索引参数的时候记得和findIndex区分清楚——some的第二个参数是索引不是fromIndex。这个误用在链式调用里偶尔会出现。5.3 some 在空数组上的行为与短路[].some(fn)返回false[].includes(x)也返回false两者一致符合存在性判断的直觉。但有个细节值得说空数组时回调一次都不会执行。所以如果你在回调里初始化了什么状态空数组输入下这段初始化就跳过了。我遇到过一次线上事故就是类似结构——一个统计函数依赖回调里累加的计数器结果上游传了个空数组计数器没初始化后续计算出 NaN一路传到报表上。加上默认值就能解决let count 0; arr.some(n { count; return n threshold; }); // 空数组时 count 保持 0不会变成 undefined用reduce做类似事情的时候更容易出这种问题因为reduce对空数组不传初始值会直接抛错。相比之下some温和得多。6. 数组长度上了万之后性能账该怎么算6.1 四种方法的时间复杂度其实是同一个先泼一盆冷水这四个方法都是 O(n)。没有哪个能靠换个方法把复杂度降下来。它们之间的性能差异只在常数项上——也就是循环本身的开销、是否创建闭包、是否调用回调函数。大致上的开销排序是这样的indexOf和includes直接在引擎内部循环不涉及 JS 层的函数调用常数项最小。find、findIndex、some每次迭代都要调一次 JS 回调有函数调用开销而且回调里的代码无法被内联优化时开销更明显。这个差距在数组只有几十个元素时完全感知不到到了十万级才会显现出来而且通常也只是几毫秒和十几毫秒的差别。所以不要为了性能把some换成includes——只有在元素是原始值、判断条件是相等比较、且数组很大的时候这个替换才成立。6.2 一次查询 vs 多次查询这是选型的分水岭真正决定性能的不是用哪个方法而是查询次数。同样是十万条数据查 1 次任何方法都是几毫秒随便用。查 1000 次indexOf级别的方案就是 1000 × 十万次比较几十秒起步。查 10000 次必须换数据结构。这就是为什么我前面说数组会被反复查多少次是三个决策问题里最重要的一个。具体到代码上形式是这样的// 慢每次查询都扫一遍 const ids bigList.map(item item.id); queries.forEach(q { if (ids.includes(q)) { /* ... */ } }); // 快一次性建索引 const idSet new Set(bigList.map(item item.id)); queries.forEach(q { if (idSet.has(q)) { /* ... */ } });第二段的建表成本是 O(n)之后每次查询是 O(1)总的复杂度从 O(n×m) 降到 O(nm)。在一万条数据、一万次查询的场景下这个差距是秒级和毫秒级的区别。6.3 我用 Node 做的几组对比我在本机 Node 环境下跑过几组简单对比取数组长度十万、重复查询一千次数据仅供参考不同机器和引擎版本差异很大方案单次查询量级主要开销来源indexOf零点几毫秒内部循环includes零点几毫秒内部循环与 indexOf 同量级some数倍于 includes每次迭代的 JS 回调调用Set.has含建表建表一次之后接近零建表时的哈希计算我特别想强调的一点是这组数据只在数据量大且查询频繁时有意义。在普通业务代码里一个数组往往就几十条some和includes的差距在纳秒级你为了这点差距牺牲可读性是得不偿失的。我见过有人为了优化把所有some都改成了includes结果遇到对象数组时不得不先 map 出一个字段数组反而多了一次遍历和一次内存分配。优化要先量化。没有 profile 数据支撑的替换本质上是在猜。7. 跳出数组本身Set、Map 和对象映射表7.1 Set.prototype.has 的正确打开方式Set的has用的是和includes一样的 SameValueZero 算法所以NaN也能正确命中0和-0不区分const s new Set([1, NaN, a]); s.has(NaN); // true s.has(1); // true s.has(1); // false类型不同用它做存在性判断的标准写法是在数据进入业务逻辑之前就转成 Set而不是在查询时才转。这个在边界处转换的思路很重要它避免了同一个数组被反复转换。// 在数据入口处转换一次 const userIdSet new Set(rawUsers.map(u u.id)); // 后续所有需要判断的地方直接复用 function isKnownUser(id) { return userIdSet.has(id); }这里有个取舍Set不保留索引信息。如果你既需要判断存在性又需要拿到对应元素那就该用Mapconst userMap new Map(rawUsers.map(u [u.id, u])); const u userMap.get(id); // undefined 表示不存在Map.get返回undefined同样存在值本身可能是 undefined的歧义问题此时用userMap.has(id)判断再用get取值两步走最稳妥。7.2 什么时候转换反而更亏Set不是万能药。它的建表成本是实打实的一次遍历、一次哈希计算、一份额外的内存。以下情况我会坚持用数组方法只查一次。建表的开销比一次线性扫描大得多。数组很小。几十个元素的线性扫描在 CPU 缓存友好的情况下极快哈希反而要算一遍。元素是对象且判断条件复杂。Set只能做相等判断做不了some那种任意谓词。内存敏感的场景。比如在移动端处理大数组额外一份 Set 的内存占用可能触发 GC 压力和卡顿。我在一个数据可视化的页面里就吃过亏为了优化一个只调用一两次的判断把几万条数据建成了 Set结果首屏时间反而变长了因为建表和 GC 的成本超过了查询本身。后来改回惰性构建——第一次查询时才建并且用一个标志位缓存结果。7.3 字符串与数字混用时的类型陷阱这一条和前面所有方法都相关但在Set场景下尤其容易出问题哈希表对类型是敏感的而indexOf和includes同样是严格比较两者在这点上一致。const ids [1, 2, 3]; ids.includes(1); // false new Set(ids).has(1); // false // 但从 DOM 或 URL 拿到的值往往是字符串 const fromQuery new URLSearchParams(location.search).get(id); // 1 ids.includes(fromQuery); // false类型不匹配这类问题在实践中极其常见因为数据从表单、URL、localStorage 出来时几乎都是字符串。解决办法只有两个要么在边界处统一转换类型推荐要么在比较时显式转换。我倾向于前者因为把类型统一的工作集中在数据入口比散落在各个比较点要好维护得多。const id Number(fromQuery); if (ids.includes(id)) { /* ... */ }注意Number()是 0Number(abc)是 NaN转换前最好做一次校验不要把一个空字符串悄悄变成 0 然后匹配上了 id 为 0 的记录。8. 项目里我固定下来的几条写法和检查清单8.1 团队里怎么统一写法在我待过的几个团队里最后都收敛到了一份差不多的约定这里直接列出来判断原始值是否存在统一用includes因为能正确处理NaN返回值也是布尔值不需要额外的比较。判断对象数组中是否存在满足条件的元素统一用some。需要拿到对象本身用find返回值接住之后立刻做空值判断不要直接取属性。需要索引用findIndex并且明确处理 -1 的情况。数组长度超过几千且会被多次查询在数据边界处转Set或Map。禁止if (arr.indexOf(x))和!!arr.indexOf(x)这两种写法ESLint 里加规则拦住。这份约定最大的价值不是性能而是让 review 时不用再去想这个人用的是哪种语义。团队里最消耗成本的从来不是单点性能而是理解成本。工具上ESLint 有几个插件能覆盖一部分场景比如unicorn的prefer-array-some和prefer-includes前者会把filter(...).length 0这类写法提示改用some后者会把indexOf(x) ! -1提示改用includes。这两个规则我基本上每次开新项目都会打开尤其是prefer-includes能一次性消灭掉一大批indexOf的比较噪音。8.2 提交前我会自问的几个问题这套清单是我自己从几次事故里总结出来的写在便签上贴在代码编辑器旁边现在基本形成条件反射了这个数组里可能出现NaN吗如果可能就没法用indexOf。元素是对象吗如果是比较的是引用还是字段字段比较必须先解构或者显式写出来。回调的返回值是显式的布尔表达式吗有没有依赖隐式转换这个判断会在循环里被调用很多次吗如果是前面的结构要不要先转成Set。数据来源是字符串还是数字类型对齐了吗空数组输入时行为符合预期吗第 6 条我单独拎出来说因为它是唯一一个代码逻辑本身没错、但业务语义错了的问题。比如判断用户有没有某项权限空权限数组应该返回 false这没问题但如果是判断权限列表是否已加载完成空数组可能意味着加载完了只是没权限也可能意味着还没开始加载靠some根本区分不了必须引入一个加载状态标志。最后分享一个我用了很久的小技巧。当你需要同时判断多个元素是否都存在时别写成一串includes用every更直白const required [read, write]; const hasAll required.every(r permissions.includes(r)); const hasAny required.some(r permissions.includes(r));every和some是对偶的前者要求全部满足后者只要一个满足。两者都是短路求值every遇到第一个不满足的就停some遇到第一个满足的就停。把它们和前面那四种方法放在一起看存在性判断这件事的完整拼图就齐了单个元素的相等判断用includes任意条件的存在判断用some全部满足用every需要取回元素用find。想清楚你要的是哪一个代码自然就不会写歪。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑