排查线上问题的时候我遇到过不止一次因为数组里到底有没有这个元素判断写错导致的 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.has100命中中间约 1约 1约 4约 0.3含构建10000命中中间约 6约 6约 20约 110000不存在约 12约 12约 38约 1100000不存在约 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)); // 1includes和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所以对象数组的存在性判断正确工具是someconsole.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)是0Number(undefined)是NaN如果入参可能为空得先判空再转。5. 问题排查速查表与调试手法5.1 高频问题速查表把上面这些坑整理成一张表出问题的时候直接对着查现象可能原因解决方向对象数组判断恒为 false用 includes/indexOf 比引用改用 some 字段比较第一个元素判断失败indexOf 返回值当布尔值用显式写! -1NaN 找不到indexOf 不支持 NaN改用 includes 或 some空位元素判断不一致回调类方法跳过空位先用 Array.from 规范化明明是 2 却匹配不上字符串和数字混用入口处统一类型大数据量判断卡顿线性查找 高频调用转 Set 缓存后 hasfind 返回的假值元素被误判用if (item)判空改判! undefinedsome 回调不执行数组是空位或长度为 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渲染走数组。代码多写了几行同步逻辑但把每次渲染里的线性查找全干掉了列表项上百个的看板上帧率改善很明显。什么时候值得这么做我的判断标准是单帧内对同一个集合的判断次数乘上集合长度超过一万就值得考虑。