
先说个真实经历。前阵子团队招人面试一个自称“精通JavaScript”的候选人我问了他一个问题“如果让你手写一个 forEach你会怎么写”他愣了几秒然后开始背MDN上的API说明什么callback、thisArg、返回undefined背得头头是道。但我让他真的写出来他却迟迟下不了笔。这个场景太典型了。日常开发里forEach、filter、every这三个方法几乎是所有前端写业务代码的标配大家用起来比谁都顺手。可真要问一句“它们的内部是怎么实现的”能答清楚的人其实不多。你可以说这不影响写业务确实大部分场景下你只需要“会用”。但一旦你遇到那些诡异的问题——比如forEach遍历时删除元素导致索引错乱、稀疏数组里filter“凭空消失”的元素、every对空数组返回true让人百思不得其解——如果你不懂底层实现排查起来就会像无头苍蝇一样乱撞。这篇文章我就带你把这三个方法彻底“拆开揉碎”。不只是给一份能跑的代码更重要的是把每一步设计背后的语义、规范和隐藏的坑全部讲清楚。适合刚接触JavaScript数组方法的初学者也适合想深入理解原理、准备面试或者被某些诡异行为折磨过的开发者。1. 为什么非要手写一遍原生方法在动手写代码之前先聊聊为什么这件事值得做。市面上讲数组方法的文章多如牛毛但大都是在讲“怎么用”很少有讲“怎么实现”。而恰恰是“怎么实现”这个视角能帮你建立起对数组方法的完整认知模型。1.1 一眼看懂三个方法的执行模型很多人在初学阶段是把这三个方法当成“三个独立的功能点”去背的。但其实它们背后统一的核心机制只有一个遍历数组对每个元素执行回调函数。你可以把整个机制想象成一条流水线forEach是一个“搬运工”它挨个把数组里的元素取出来递给你你处理完它也不回收结果处理完就完事。filter是一个“质检员”它同样挨个取出元素但会递给你一张“合格吗”的问卷你回答true就留下回答false就丢掉最后把所有合格品装进一个新箱子。every是一个“门卫”它挨个检查元素只要发现一个“不合格”的立刻关门下班返回false如果所有元素都检查完都没发现问题就放行返回true。这个类比听起来很直白但它精确对应了底层实现里的三个关键差异是否收集结果、是否短路、返回什么类型。1.2 手写实现能带来什么实际收益第一个收益是“出了怪事不慌”。比如你在forEach的回调里push了新元素进原数组结果发现新元素也被遍历了。如果你知道内部实现是“先取length再按索引递增遍历”你就不会觉得奇怪而是立刻明白这是因为length在循环过程中被动态改变了。第二个收益是“面试不虚”。手写实现几乎是前端面试的高频考点它考察的不是背代码的能力而是你是否真的理解数组方法的规范语义。你能够写出边界处理完善的版本本身就是在证明你是“科班思维”而不是“野路子”。第三个收益也是容易被忽略的当原生方法满足不了你时你能自己造一个趁手的轮子。比如你希望有一个“能提前终止的forEach”或者一个“从尾部开始遍历的filter”理解了内部机制之后你只需要改动几行就能实现自己的版本。2. forEach手写几行代码背后的完整语义forEach似乎是三个方法里最容易实现的一个。网上很多人写出的精简版是这样的Array.prototype.myForEach function (callback) { for (let i 0; i this.length; i) { callback(this[i], i, this); } };这个版本能用吗能但它非常脆弱。它至少忽略了三个关键点传入的数组可能是null或undefined、回调函数本身可能不是函数、稀疏数组中的空槽位应该被跳过。2.1 对照规范语义的完整实现根据ECMAScript规范forEach的实际行为要复杂一些。我们一步步还原一个更接近原生行为的版本function myForEach(array, callback, thisArg) { // 1. 处理空值 if (array null || array undefined) { throw new TypeError(Cannot read property of null or undefined); } // 2. 回调必须是函数 if (typeof callback ! function) { throw new TypeError(callback is not a function); } // 3. 统一转成对象支持类数组 const O Object(array); // 4. 取长度并转成无符号32位整数 const len O.length 0; for (let k 0; k len; k) { // 5. 只在索引存在时调用回调 if (k in O) { callback.call(thisArg, O[k], k, O); } } }这段代码每一行都有它的道理逐个拆开讲。第一点为什么要用Object(array)包一层因为规范里定义的遍历目标是一个“对象”而不是严格意义上的“数组”。这意味着类数组对象——比如arguments、NodeList、{ 0: a, 1: b, length: 2 }——也能被forEach处理。Object()会把原始值包成对象、把已有的引用原样返回这样各种输入都能统一处理。第二点length 0是什么操作无符号右移0位看起来什么都没做但它有两个重要作用一是把任何非数字值比如undefined、字符串、负数强行转成无符号32位整数二是保证结果一定是个非负整数。如果你传的length是3.7 0会把它变成3如果是-1会变成4294967295——这能有效防止遍历时出现不可预料的索引。这是规范里的一个经典技巧在很多原生方法的polyfill里都能看到。第三点也是最容易被忽视的if (k in O)。这就是处理稀疏数组的关键。2.2 稀疏数组为什么你用for循环怎么也复现不出“跳过空槽”JavaScript的数组可以存在“空洞”。比如你写const arr [1, , 3]中间的逗号之间什么都没有这就是一个稀疏数组。虽然你打印它时看到的是[1, empty, 3]但那个位置并没有一个值为undefined的元素——它压根就不存在。用原始for循环遍历时const arr [1, , 3]; // for循环空槽位会被访问到值为undefined for (let i 0; i arr.length; i) { console.log(i, arr[i]); // 0 1 / 1 undefined / 2 3 } // forEach空槽位被跳过 arr.forEach((item, i) { console.log(i, item); // 0 1 / 2 3 });这个差异非常容易造成Bug。特别是当你从接口拿到一件“半成品数据”里面有些字段没有赋值导致数组变成了稀疏结构这时你用forEach统计或用map映射结果都会和预期不一致。k in O这个判断的语义就是“检查这个索引是否真实存在于对象上”。如果数组在某索引处是空的in操作符会返回false循环体就不会执行天然实现了“跳过空槽”。经验补充如果某些业务场景里你不希望跳过空槽而是希望把空槽当成undefined处理可以考虑先用Array.from(arr)把稀疏数组转换成密集数组再遍历。Array.from会为每个空槽补上undefined。2.3 遍历期间修改原数组一个容易翻车的操作另一个和实现相关的经典坑是在forEach的回调里修改原数组时遍历行为会变得“不可捉摸”。我们来分析一下。看这个例子const arr [1, 2, 3, 4, 5]; arr.forEach((item, index) { console.log(index, item); if (item 2) { arr.splice(index, 1); // 删除当前元素 } }); // 实际输出0 1 / 1 2 / 2 4 / 3 5注意元素3被跳过了。原因在实现代码里已经暴露了forEach在开始遍历前会先拿到length本例为5然后按k 0, 1, 2, 3, 4递增访问。当你删除索引为1的元素后原本索引2的3平移到了索引1但循环已经从k 1继续走到k 2了——于是3就再也访问不到了。如果你在回调里往数组末尾push新元素新元素反而会被遍历到因为length一开始就定为5但原始数组的length已经被你改大了而规范里forEach实际上每次循环都会读取当前的O[k]只要索引还在范围内且元素存在。等等这里需要精确定义一下规范实现里len是在循环前固定下来的所以如果原数组长度是5你push到长度6k最大到4新元素索引5不会被访问到。但有些现代引擎的实现细节略有差异不过按规范逻辑走len是固定的push进去的新元素不会遍历到。如果你需要在遍历时删除元素最稳妥的方式是使用filter来“筛选保留”或者从后往前遍历。这也是为什么我在团队Code Review里看到有人在forEach里splice时都会提醒他改用filter。3. filter手写返回新数组与保留空槽的策略filter比forEach多了一层“收集结果”的逻辑。它的核心行为可以概括为遍历原数组对每个存在的元素执行回调回调返回真值就收集该元素最终返回一个由这些元素组成的新数组。3.1 最简实现到完整实现你能搜到的很多“精简版filter”长这样Array.prototype.myFilter function (callback) { const result []; for (let i 0; i this.length; i) { if (callback(this[i], i, this)) { result.push(this[i]); } } return result; };这个版本看起来没毛病但和forEach一样它在边界处理上是不合格的。完整版本应该在结构上和forEach保持高度一致唯一的区别是增加了收集逻辑function myFilter(array, callback, thisArg) { if (array null || array undefined) { throw new TypeError(Cannot read property of null or undefined); } if (typeof callback ! function) { throw new TypeError(callback is not a function); } const O Object(array); const len O.length 0; const result []; for (let k 0; k len; k) { if (k in O) { const value O[k]; if (callback.call(thisArg, value, k, O)) { result.push(value); } } } return result; }注意这里用const value O[k]先把当前元素存起来然后再传给回调。这看起来是小事但实际上有两个作用一是避免多次读取属性带来的潜在性能损耗二是防止一种极端情况——如果在回调执行前原数组被其他代码修改了比如在同一个执行栈里有一个proxy拦截了get你读取到的值可能不一致。虽然在单线程JavaScript里这种情况极其罕见但作为严谨的实现先存值再使用是最稳妥的写法。3.2 filter的结果数组永远是“密集”的这是很多人没意识到的一个细节。filter对稀疏数组的处理是“跳过空槽”而且结果数组里不会保留空槽。来看个例子const arr [1, , 3, , 5]; // 原生 filter const result1 arr.filter(x x 2); console.log(result1); // [3, 5] —— 中间没有空位 // 普通 for 循环push const result2 []; for (let i 0; i arr.length; i) { if (arr[i] 2) { result2.push(arr[i]); } } console.log(result2); // [3, 5] —— 效果一致因为我们是push进去的所以天然是密集数组。这也提醒我们filter不仅能筛选元素还能顺手把稀疏数组“填平”。如果你需要先筛掉一些无效项、又要消除稀疏结构filter一个方法就够了。但反过来如果你只是想把稀疏数组变成密集数组而不做筛选Array.from(arr)更直接它会把每个空槽位都补上undefined。3.3 回调里改原数组filter同样招架不住和forEach类似filter遍历期间如果修改了原数组结果也会“很随缘”。不过这里有个相对好一些的特性因为filter返回的是一个全新数组所以即使你在回调里把原数组改了已经收集进result的元素不会受到影响。但如果你的修改导致后续索引位置变了仍然会出现“漏选”或“错选”。比如const arr [1, 2, 3, 4, 5]; const filtered arr.filter((item, index) { if (item 2) { arr.shift(); // 删除头部元素 } console.log(index, item); return item % 2 0; }); console.log(filtered);这种写法几乎无法准确预测结果因为shift会把所有元素索引往前挪一位而循环的k还在按原计划递增。所以在使用filter时同样要遵循一条铁律不要在遍历过程中修改原数组的长度。经验补充如果你确实需要“筛选 修改原数组”二合一的操作建议分两步走——先filter出保留项再对原数组做splice或重新赋值。这样逻辑清晰也不容易埋坑。4. every手写短路逻辑与“空数组返回true”的设计哲学every是这三个方法里行为最“反直觉”的一个尤其是它对空数组的处理几乎每次聊到这个点时都会有人提出疑问。4.1 完整实现every的实现逻辑其实很朴素遍历数组只要有一个元素让回调返回false就立刻返回false所有元素都通过才返回true。代码如下function myEvery(array, callback, thisArg) { if (array null || array undefined) { throw new TypeError(Cannot read property of null or undefined); } if (typeof callback ! function) { throw new TypeError(callback is not a function); } const O Object(array); const len O.length 0; for (let k 0; k len; k) { if (k in O) { if (!callback.call(thisArg, O[k], k, O)) { return false; } } } return true; }核心就是那个if (!callback.call(...)) { return false; }——这行代码实现了短路。一旦某个元素让回调返回假值函数会立即终止循环后面的元素连看都不会看。4.2 空数组返回true不是bug是数学约定很多初学者第一次发现[].every(() false)竟然返回true时都觉得“这一定是JS的bug”。但这不是bug而是一个经过深思熟虑的设计数学家把这叫做“vacuous truth”空真。用大白话解释every表达的是“对于所有元素条件都成立”。当集合为空时你找不到任何一个反例所以命题成立。这就像说“我们班所有身高超过两米五的同学都是男生”——如果班里根本没有身高超过两米五的人这句话算真还是假按数学逻辑它是真的因为你找不出反例。这个约定在编程里的意义是避免特判。假如空数组返回false那你在使用every时就必须额外写一行if (arr.length 0)去处理特殊情况平白增加逻辑负担。而返回true之后很多算法可以自然套用比如“权限列表every检查通过才放行”空列表时直接放行这才符合直觉。4.3 稀疏数组里every的行为空槽不参与判断every同样遵循“跳过空槽”的规则。如果稀疏数组中的空槽位本应是一个非法值那么every也会“视而不见”const arr [2, , 4]; function isEven(num) { console.log(检查, num); return num % 2 0; } console.log(arr.every(isEven)); // 只会检查 2 和 4输出 true这在实际开发中是个隐患。比如你从一个表单里拿到的数组是稀疏的中间有一个空槽恰好你要用every做合法性校验结果告诉你“全部合法”而实际上那个空位数据是缺失的。避坑建议在做数据校验时先调用Array.from(arr)把稀疏数组变成密集数组再every或者先判断arr.includes(undefined)把空槽和undefined值一并拦截。4.4 如果我想实现一个“空数组返回false的every”怎么办面试里有时会碰到这种变体题或者你在业务里确实有这种需求。实现也很简单function myEveryStrict(array, callback, thisArg) { if (array null || array undefined) { throw new TypeError(Cannot read property of null or undefined); } if (typeof callback ! function) { throw new TypeError(callback is not a function); } const O Object(array); const len O.length 0; if (len 0) { return false; // 空数组直接返回false } for (let k 0; k len; k) { // 注意这里可以考虑跳过空槽也可以选择把空槽视为undefined传给回调 if (k in O !callback.call(thisArg, O[k], k, O)) { return false; } } return true; }不过这种自定义方法会带来额外的理解成本。你在团队里写代码时最好在注释里说明“这里和原生every行为不同”。一般来说保持和原生一致不太需要特殊发明除非业务逻辑确实要求空数组不通过。5. 三个方法同台对比什么时候用哪个讲完三个方法的实现我们对它们的内部机制已经有了完整认知。现在把它们放在一起从多个维度做一次系统性对比。5.1 核心差异一览表维度forEachfilterevery返回值undefined新数组密集数组布尔值是否修改原数组否但回调内可以否但回调内可以否但回调内可以是否支持短路不支持不支持支持遇false立即返回空槽位处理跳过不调用回调跳过且结果数组不保留空槽跳过视为“通过检查”空数组返回值不执行回调返回undefined返回空数组返回true主要用途执行副作用操作如日志、请求上报筛选符合条件的元素判断是否所有元素满足条件这张表基本涵盖了你在做技术选型时需要的所有信息量。日常开发中很多人是用“习惯”来决定用哪个方法的而不是用“目的”。比如想在循环里统计某个值顺手就写了arr.forEach结果还得在外部声明一个变量。这种写法当然没错但如果把目标换成“筛选出所有偶数”那filter的语义就比forEach更明确、更少出错。5.2 用“目的”选择方法而不是“习惯”做出选择的标准其实非常简单我一般会问自己三个问题我要的是新数组吗是——用filter或map看你需要变换还是筛选。我要的是布尔值吗是——用every全部满足或some存在满足。我只是想“遍历一遍做点事情”吗是——用forEach或者干脆用for...of。特别要提醒的是不要在forEach里手动模拟filter。这个我见过太多次了// 反面典型 const result []; arr.forEach(item { if (item 2) { result.push(item); } }); // 正确写法 const result arr.filter(item item 2);第二种写法不仅代码量更少可读性也更高。filter的存在就是为了解决这件事绕开它去用forEach反而违背了代码的表达意图。5.3 性能问题需要担心吗很多人在选择for循环还是forEach时会纠结性能。在数组长度只有几百上千的业务场景里这种差异完全可以忽略不计。真正决定性能的不是遍历方式而是你遍历时要执行的回调复杂度。不过有一点值得注意every因为有短路机制在判断“是否所有元素满足条件”时通常比forEach计数器写法更快。比如判断一个数组是否全为正数用every遇到第一个负数就停了而forEach风格必须遍历完整个数组才能得出结论。function allPositive(arr) { return arr.every(num num 0); }如果数组的第一个元素就是负数every只执行一次回调就返回了。这个优势在数据量特别大时时能明显感知到。6. 从热搜词里翻出来的真实事故同名概念与误用场景在整理这篇文章时我翻了翻几个平台上的相关热搜词发现一个很有意思的现象搜索“forEach具体实现”的人有很大一部分并不是在搜JavaScript。有人是在查PowerShell的命令有人是在查MyBatis动态SQL里的foreach标签还有人单纯想知道怎么用filter过滤文件。这提示了我们一个容易踩坑的现实“同名概念”在不同技术栈里的含义可能天差地别。6.1 PowerShell的ForEach、MyBatis的foreach和JavaScript的forEach拿热搜里出现的这条来说Get-AppxPackage *WindowsStore* -AllUsers | ForEach { Add-AppxPackage -DisableDevelopmentMode -Register $($_.InstallLocation)\AppxManifest.xml }这是PowerShell里的ForEach命令它的作用是“对管道中的每个对象执行一段操作”和JavaScript数组的forEach方法在概念上有相似之处但语法和语义完全不同。用JavaScript的思路去套PowerShell的ForEach大概率会写出“看起来很对但就是报错”的代码。再看MyBatis里的foreach标签foreach collectionlist itemitem separator, open( close) #{item} /foreach这是用来拼接SQL语句的本质上是模板字符串的循环展开和数组遍历的关系就更远了。如果你在写MyBatis时遇到动态SQL问题去搜“foreach实现原理”可能搜到的全是JavaScript文章这显然会浪费时间。所以遇到“forEach报错”时我的建议是先确认你在写的是什么技术栈里的forEach。JavaScript的、PowerShell的、MyBatis的、Java的它们只是同名底层完全是两个世界。6.2 “filter失败”最常见的三类原因热搜里还有一组词是“filter failed”“javascript filter函数”。结合起来看很多人搜索“filter”是遇到了报错或结果不对。总结下来最常见的“filter失败”基本逃不出这三类第一类点错了方法名。JavaScript里数组有filter字符串有replace和match对象没有filter。如果你对一个普通对象调用filter会直接抛TypeError: obj.filter is not a function。这通常是因为把对象误当成了数组或者忘了先Object.values(obj)取数组。第二类回调函数忘记返回值。filter依赖回调函数的返回值来决定是否保留元素。如果你在回调里写了大括号却没有return回调返回的是undefined于是每一个元素都会被过滤掉。这是最典型的“filter结果为空”的原因。// 错误箭头函数用了大括号却忘了return const result [1, 2, 3].filter(item { item 1; }); console.log(result); // [] // 正确用表达式体或显式return const result [1, 2, 3].filter(item item 1); console.log(result); // [2, 3]第三类误以为filter会修改原数组。很多从其他语言转过来的开发者会对“不可变性”不习惯以为filter执行完后原数组会被“删掉不合格项”。但JavaScript里filter返回新数组、原数组不变。如果你后续还在用原数组发现数据没变就以为“filter失败了”。实际上它成功执行了只是你获取结果的方式不对。6.3 关于“every 5.0s: nvidia-smi”这类命令的重复失败热搜里还有一条非常典型的every 5.0s: nvidia-smi star: Sat Sep 12 08:30:02 2026 failed to initialize N...这条一看就是用某种监控工具比如watch命令或者Supervisor每隔5秒执行一次nvidia-smi结果每次执行都失败。注意这里的every和JavaScript的every半毛钱关系都没有它是监控工具的定时语法。但这条热搜折射出一个通用的排错思路当你看到“周期性任务失败”时不要光把焦点放在周期上而是要先关注单次执行本身。如果nvidia-smi单次执行就会failed to initialize那问题出在驱动、环境变量或权限而不是周期配置。很多人折腾了半天every 5.0s的语法最后发现手写一条nvidia-smi都跑不通。这个思路放在JavaScript里也一样。你写的forEach回调如果单独拿出来执行都会报错那不管用哪种遍历方式都白搭。先保证回调本身正确再考虑遍历逻辑是排查一切数组方法问题的基本顺序。7. 写在最后花十分钟在控制台里做一次实测文章写到这里三个方法的实现和背后的语义已经讲得比较透了。但我还是想补一句老生常谈的建议一定要亲手在浏览器控制台里把这几个实现跑一遍。我见过太多人看完源码分析的教程觉得“懂了”结果真到写代码时还是会栽在稀疏数组、空数组返回值这种细节上。你可以准备一个包含空槽的数组比如const arr [1, , 3, , 5]然后把原生方法和自己手写的实现分别跑一遍对比输出。尤其是filter和every对空槽的处理——只有你亲眼看到结果才可能真正记住这些边界行为。最后分享一个我个人的调试小技巧如果你在排查filter或every的行为异常可以在回调里临时加一行console.log(item, index)观察循环是否访问到了你预期中的元素。很多时候问题不在方法本身而是数组里藏着你看不见的空槽或多余元素。等一切都对上了再把调试代码删掉就行。数组方法的实现并不难但细节真的不少。希望这篇拆解能帮你省下一些黑暗中摸索的时间。