ARTICLE DETAIL

资讯详情

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

手写JavaScript数组三大方法:forEach、filter、every的底层实现与陷阱

手写JavaScript数组三大方法:forEach、filter、every的底层实现与陷阱 有阵子没写这种“手写实现”系列了。今天聊三个看起来特别简单、但细挖起来全是细节的数组方法forEach、filter、every。大多数人在项目里天天用但真要让你当场手写一个或者解释为什么forEach不能用return跳出循环、filter会不会改写原始数组、every对空数组会返回什么很多人会卡壳。这篇文章就把这三个方法的底层行为、手写实现和隐藏坑全翻出来不管你是准备面试还是想加深对JavaScript数组的理解都能找到想要的东西。1. 内容整体设计与思路拆解1.1 这三个方法到底有多高频——从业务场景看需求在日常业务代码里数组操作几乎绕不开forEach、filter、every。比如拿到后端返回的订单列表要遍历渲染成表格最顺手的就是forEach要按条件筛选出已支付的订单filter一行搞定要判断一组表单是否全部通过校验every是最直观的答案。它们对应着三种最基本的集合运算遍历、筛选、全真判断。你去看现在的前端代码只要业务稍微复杂一点这三个方法几乎会同时出现。举个例子一个购物车页面需要计算选中商品的总价你可能会先用filter把isSelected为true的商品筛出来再用forEach累加价格如果还要判断是否所有商品都有库存every又派上用场。可以说不掌握这三个方法写数组逻辑会处处碰壁。而且这三个方法不仅仅是工具函数它们背后蕴含着函数式编程的核心思想不直接写循环、不手动管理索引、用回调描述“做什么”而不是“怎么做”。这也是为什么很多人前端写了几年一改用原生循环就浑身不自在因为高阶函数带来的抽象能力确实让代码更清晰、更不容易写错。1.2 为什么要学习底层实现——面试、踩坑和函数式编程很多人觉得这些方法浏览器已经实现了直接调不就行了但正因为它们太底层我们才要理解它的内部逻辑。一方面大厂面试常考手写这些方法考察你对回调函数、this指向、循环边界的掌握。我曾经面试过一个候选人他能很流利地背出这三个方法的用法但让他手写一个filter他却直接写出了for循环加push却忘了处理稀疏数组也忘了回调中thisArg的绑定。这不是背不背得出来的问题而是对语言规范的理解深度不够。另一方面真实项目中经常出现隐性bug根源就是对实现机制不清楚。最常见的例子在forEach里用return试图终止循环结果发现后面的元素照样被处理或者用filter筛选却没有接收返回值导致筛选结果丢失再或者对一个空数组调用every得到true让一些不熟悉规范的人大吃一惊。这些坑如果你读过它们的实现基本不会踩。熟悉底层实现还有一个实际好处你可以在无法使用ES5的环境里自己写一个polyfill兼容老浏览器。虽然现在ES5以上环境已经非常普及但在某些特殊的运行环境——比如老版本的安卓WebView、某些嵌入式JavaScript引擎中原生方法可能缺失或行为不一致。这时候手写实现就是救命稻草。1.3 一个共同的技术底座回调函数如何驱动这三个方法本质都是遍历数组元素依次调用你传入的回调函数。区别在于forEach只负责遍历回调的返回值被丢弃filter根据回调返回的布尔值决定是否保留元素收集到一个新数组every则是只要回调返回一个假值立刻终止遍历并返回false。如果你掌握了这个共同底座手写起来就会非常顺畅。比如forEach和filter的循环骨架几乎一样唯一区别是filter多了一个result.push(value)的条件分支every则多了一个提前return false的出口。所以千万别把这三个方法当成独立的API去死记硬背把它们看成一棵树上的三个果实会容易得多。另外它们还有一些共同的细节需要留意回调中会收到三个参数(当前值, 当前索引, 原数组)第二个可选参数thisArg用于指定回调中的this指向遍历过程中如果数组被修改行为可能会很微妙。这些细节在后面的实现代码里会一一展开。2. 核心细节解析forEach的完整实现2.1 forEach的API规范和行为特征forEach的官方定义是对数组的每个元素执行一次给定的函数。它的API签名是arr.forEach(callback(currentValue [, index [, array]]) [, thisArg]);这里有个关键点forEach没有返回值或者说返回undefined。很多人误以为它返回一个新数组实际上它只做遍历。如果你想拿到处理后的结果应该用map或者在外部变量中累积结果。另一个关键行为是forEach不会遍历数组中的空位稀疏数组。什么叫空位比如const arr [1, , 3];中间那个位置没有值访问arr[1]会得到undefined但它其实是一个“空位”而不是值为undefined的元素。forEach会跳过这个空位不调用回调。这一细节在做性能优化或兼容时特别重要。还需要注意的是forEach没有跳过早退机制不像for循环可以用break或return中断。如果需要在满足条件时停止遍历forEach不是合适的选择可以考虑for...of或some/every它们在某些场景下会提前退出。2.2 手写forEach从零开始逐行拆解直接给出一个符合ES5规范的手写实现然后逐行解释Array.prototype.myForEach function(callback, thisArg) { // 1. 检查this是否为null或undefined if (this null) { throw new TypeError(Array.prototype.myForEach called on null or undefined); } // 2. 检查callback是否为函数 if (typeof callback ! function) { throw new TypeError(callback is not a function); } // 3. 将this对象化并取出数组长度 const arr Object(this); const len arr.length 0; // 4. 从索引0开始遍历 let k 0; while (k len) { // 5. 只处理真实存在的元素跳过稀疏数组的空位 if (k in arr) { // 6. 调用回调指定thisArg并传入三个参数 callback.call(thisArg, arr[k], k, arr); } k; } };让我逐条解释为什么每个步骤都不能少。第一步为什么用this null而不是!this因为this可能被显式设置为null或undefined也可能是0、空字符串等假值。规范要求只有null和undefined会被拒绝如果传入0应该正常执行当然0上没有length属性实际不会有元素。用是为了同时判断null和undefined这里用宽松相等是刻意为之严格相等 null则无法覆盖undefined。第三步的Object(this)是为了兼容基本类型。如果调用[].myForEach.call(abc, fn)字符串会被包装成String对象拥有length和索引属性所以forEach也能遍历字符串。len arr.length 0是一种将任意值转换成非负整数的标准技巧。 0会把负数变成很大的正数把小数截成整数这看起来有点反直觉但能确保后续循环不会越界。第五步的if (k in arr)就是处理稀疏数组的关键。in运算符会检查属性是否存在包括原型链对于空位属性不存在所以跳过。对于值为undefined的元素比如const arr [undefined, undefined]空位与显式undefined的区别就能体现出来显式undefined元素会正常执行回调。第六步里callback.call(thisArg, arr[k], k, arr)如果调用时没有传入thisArg在非严格模式下thisArg会是undefined而回调中的this会被自动绑定到全局对象在严格模式下则保持undefined。这个行为与原生的forEach一致。如果你想避免这种混淆可以在调用时强制thisArg thisArg但规范实际上就是这么处理的。这里有个小技巧如果你不想修改原生原型其实不用挂在Array.prototype上可以直接写一个普通函数function myForEach(arr, callback, thisArg) { ... }但挂在原型上更贴近原生API的调用方式也方便在面试中演示。注意在生产环境里尽量不要去修改内置对象的原型避免污染全局。2.3 forEach的陷阱稀疏数组、break失效、修改原数组先说稀疏数组。我们用上面的myForEach测试一下const arr [1, , 3]; let sum 0; arr.myForEach((v) { sum v; }); console.log(sum); // 4空位被跳过所以只有1和3参与累加。如果你用for循环直接遍历那么arr[1]是undefined就会变成sum undefined得到NaN。所以当你需要保持“跳过空位”的语义时直接用原生forEach或手写实现会更安全。再说break失效。很多人误以为回调里的return能终止forEach其实不能。这是因为forEach每次遍历都会调用回调退货值只是被忽略循环不受影响。如果你真的需要提前退出可以用for...of、some或every。比如const arr [1, 2, 3, 4]; let found false; arr.some((v) { if (v 3) { found true; return true; // 提前终止 } });但要注意用some做“提前退出”有点滥用其语义更好的方式是for...of加break。不过面试中问“如何在forEach里实现break”通常会考你try...catch抛异常的方式但那并不优雅且会破坏控制流不建议在生产中使用。最后是修改原数组的问题。forEach规范里有一个非常隐蔽的行为遍历过程中如果增加了数组元素新增元素可能会被访问到如果删除了某个元素该位置可能被跳过如果改变了元素值实际被回调读取到的可能是修改后的值。这导致结果可能不符合直觉。官方建议不要指望遍历过程中数组不被修改也不要在回调里修改数组除了“修改当前元素”以外的部分。举个例子const arr [1, 2, 3, 4]; arr.forEach((v, i) { console.log(v); if (i 1) { arr.push(5); // 新增元素原生的forEach会继续访问arr[4] } }); // 输出 1 2 3 4 5而如果你在索引为1时执行arr.shift()则后面的索引会前移导致一些元素被跳过。这类问题非常隐蔽一般只有在处理动态列表时才可能遇到。我的建议是如果需要在遍历时删除或添加元素优先使用filter、map等返回新数组的方法或者使用for循环从后往前遍历。3. filter的实现筛选逻辑背后的机理3.1 filter的语义与典型应用filter的核心语义是“留下满足条件的元素”并且返回一个新数组不修改原数组。它的API签名是arr.filter(callback(element [, index [, array]]) [, thisArg]);和forEach相比filter有两个显著区别第一它返回一个新数组包含所有回调返回true或真值的元素第二空位也会被跳过不参与判断。典型应用是数据筛选。比如从一个用户列表中筛选出所有成年人const adults users.filter((user) user.age 18);再比如从一组商品中筛选出有库存且价格低于100元的const affordable products.filter((p) p.inStock p.price 100);它非常契合函数式编程的“声明式”风格你只关心“筛选条件”不需要关心循环怎么走。这也是为什么我写业务代码时能不用for循环就不用因为filter的表达力更强bug更少。3.2 手写filter保留满足条件的元素直接给出实现Array.prototype.myFilter function(callback, thisArg) { if (this null) { throw new TypeError(Array.prototype.myFilter called on null or undefined); } if (typeof callback ! function) { throw new TypeError(callback is not a function); } const arr Object(this); const len arr.length 0; const result []; let k 0; while (k len) { if (k in arr) { const value arr[k]; if (callback.call(thisArg, value, k, arr)) { result.push(value); } } k; } return result; };这个实现和myForEach非常像比较关键的差异在返回值。filter必须创建并维护一个result数组当回调返回真值时把当前元素value压入结果。这里有几个容易忽略的细节一是回调的判断条件if (callback.call(...))它并不要求必须返回布尔值只要是真值就会被保留。这符合JS的“truthy/falsy”规则比如{}、[]、1、a都会保留元素0、、null、undefined、NaN、false都会丢弃。如果你想严格要求布尔值可以在回调里用Boolean()包裹但原生filter不做这个转换。二是拿到value存成局部变量是有原因的。因为callback.call执行时可能会改变arr[k]的值而filter保留的是执行回调前已经获取的值也就是value。如果用arr[k]去push可能push进去的是修改后的新值。规范的意图是保留原数组里对应位置的值在进入回调那一刻的值所以先取出来更安全。三是if (k in arr)仍然不能省略。如果不跳过空位filter会错误地处理稀疏数组。例如[1, , 3].filter(v v undefined)原生返回[]因为空位被跳过没有一个元素被判断为undefined而如果你的实现不检查k in arr那么arr[1]会被当作undefined参与判断结果就会错误地保留一个undefined。3.3 filter与forEach的组合如何实现筛选后再处理实际开发中经常需要先筛选再遍历。比如筛选出所有金额大于100的订单然后累加总额const total orders .filter((order) order.amount 100) .reduce((sum, order) sum order.amount, 0);但有时候你不想用reduce就想用forEach。这时候可以这样写let total 0; orders .filter((order) order.amount 100) .forEach((order) { total order.amount; });甚至可以直接手动实现一个“筛选后执行”的组合函数function filterAndForEach(arr, predicate, action, thisArg) { arr.myFilter(predicate, thisArg).forEach(action, thisArg); }不过如果数组很大这种链式调用会创建中间数组增加内存开销。性能敏感的场景可以考虑一次遍历完成筛选和处理let total 0; for (const order of orders) { if (order.amount 100) { total order.amount; } }在数据量几百上千时这点性能差异可以忽略不计但如果是几十万条数据中间数组的分配和GC会成为瓶颈。我的建议是优先写清晰的链式代码只有在真实性能分析确认有问题时再去优化成单次循环。这里也引出一个与热词相关的小插曲很多人一听到filter会联想到音视频领域的卷积滤镜convolution filter、图形图像处理里的卷积核或者Windows事件跟踪里的事件过滤器比如event filter with query。实际上不同领域的filter都是“筛选、过滤”的意思。在JavaScript里filter只是数组的高阶函数用来筛选元素跟那些底层滤镜没有任何关系。写代码时千万别混淆。4. every的实现全真判断的严谨性4.1 every的语义与短路的秘密every用于测试数组中的所有元素是否都通过了回调的测试。它返回一个布尔值。API签名如下arr.every(callback(element [, index [, array]]) [, thisArg]);它的一个极其重要的特性是短路只要回调有一个返回假值every立刻停止遍历直接返回false。这跟逻辑运算符很像一假则假后面的不用管了。正因为有短路行为every在处理大数组时效率可能高于forEach因为它不需要完整遍历。另一个容易被忽略的规范是对空数组调用every永远返回true。这其实符合数学上的“全称命题”逻辑对空集的任何断言都是真的因为不存在反例。你可以这么理解如果数组里没有任何元素那自然也就没有“不满足条件的元素”所以通过了测试。这点和some正好相反some对空数组返回false因为空集中不存在一个满足条件的元素。4.2 手写every如何准确判断空数组实现如下Array.prototype.myEvery function(callback, thisArg) { if (this null) { throw new TypeError(Array.prototype.myEvery called on null or undefined); } if (typeof callback ! function) { throw new TypeError(callback is not a function); } const arr Object(this); const len arr.length 0; let k 0; while (k len) { // 跳过稀疏数组空位 if (k in arr) { // 只要有一个假值立即返回false实现短路 if (!callback.call(thisArg, arr[k], k, arr)) { return false; } } k; } // 全部通过包括空数组返回true return true; };重点在最后一行如果len为0while循环一次都不会执行自然走到return true。这和原生的行为一致。另外if (k in arr)中如果遇到空位我们需要跳过空位继续检查后面的元素。这是不是意味着空位会被“无视”是的原生every会跳过空位不调用回调也不把它当作“不满足条件的元素”。所以[1, , 3].every((v) v ! undefined); // true因为空位被跳过剩下的1和3以及回调判断v ! undefined两个元素都满足所以结果true。如果你手动用for循环遍历时会访问到空位的undefined结果就会变成false。在实现中保留k in arr判断非常重要。手写实现还有一个细节回调执行次数。因为存在短路every中回调可能不会被执行完。比如[1, 2, 3].myEvery((v) { console.log(v); // 只会打印1因为1是假值不这里v1是真值所以会继续。如果条件是v 0则全真。 });如果你写v v 3那么对于[1,2,3]会先看1返回true再看2返回true最后看3返回false立即终止不会再看后面的元素。这个“只执行必要次数”的特性能用来做条件判断的优化。4.3 some与every的对照以及综合应用案例要理解every最好和some一起说。some判断数组中是否至少有一个元素满足条件它同样有短路行为只要一个为真立即返回true空数组返回false。可以这样对照记忆方法语义空数组返回值短路条件every所有元素都满足true遇到第一个假值返回falsesome至少一个元素满足false遇到第一个真值返回true业务中最常见的综合应用是“多条件校验”。比如一个注册表单页需要验证所有必填字段都不能为空const fields [username, email, password]; const isValid fields.every((field) { const value form[field]; return value ! undefined value ! ; });再比如权限系统中判断用户是否拥有所有指定权限const requiredPermissions [read, write, delete]; const hasAll requiredPermissions.every((perm) user.permissions.includes(perm));还有一个小技巧如果every的回调会产生副作用比如往外部数组里push内容要警惕短路导致副作用不完整。举个极端例子let count 0; const result [1, 2, 3].every((v) { count; return v 2; }); console.log(result); // false console.log(count); // 2因为第二个元素就返回false第三个元素并没有被执行所以如果你依赖回调“必须对所有元素执行”来收集数据就不要用every这种情况应该用forEach。5. 基于这些实现我们还能做什么扩展5.1 自定义高阶函数map、reduce、find的衍生有了forEach、filter、every的手写经验你可以很轻松地扩展出其他高阶函数。比如map它的实现和filter很像只是把“满足条件才push”改成“无条件push回调的返回值”Array.prototype.myMap function(callback, thisArg) { if (this null) throw new TypeError(...); if (typeof callback ! function) throw new TypeError(...); const arr Object(this); const len arr.length 0; const result new Array(len); let k 0; while (k len) { if (k in arr) { result[k] callback.call(thisArg, arr[k], k, arr); } k; } return result; };注意map会保持原数组的稀疏结构空位在新数组里仍然是空位而不是undefined。这一点很有趣但很多人不知道。再比如find它需要返回第一个满足条件的元素值没有则返回undefined。它的实现也可以用every的短路思路不过终止条件是“找到真值”Array.prototype.myFind function(callback, thisArg) { if (this null) throw new TypeError(...); if (typeof callback ! function) throw new TypeError(...); const arr Object(this); const len arr.length 0; let k 0; while (k len) { const value arr[k]; if (k in arr callback.call(thisArg, value, k, arr)) { return value; } k; } return undefined; };这些实现共同的核心模式是“遍历数组 回调驱动 边界处理”。一旦建立起这个模式你甚至可以去实现reduce、flatMap、groupBy等更复杂的方法。5.2 函数式编程与链式调用的性能考量当你可以手写这些方法后你对链式调用的理解会发生质变。比如这样一段代码const result users .filter(user user.isActive) .map(user user.score) .filter(score score 60) .reduce((sum, score) sum score, 0);看起来非常优雅但它的执行过程实际上是filter生成一个新数组map生成一个新数组第二个filter又生成一个新数组最后reduce累加。每一步都会分配内存、遍历数组。在小数组上这不是问题但在处理数万条数据时中间数组的创建会带来明显的内存压力。如果你追求极致性能可以手写一个“流水线”函数让数据只遍历一次const result users.reduce((acc, user) { if (!user.isActive) return acc; const score user.score; if (score 60) return acc score; return acc; }, 0);这个版本只遍历一次不产生中间数组。代价是代码的可读性稍微下降。在实际项目中我的原则是可读性优先先写出意图清晰的代码再用性能工具如Chrome Performance面板找出真正的热点不要凭空优化。另外如果你经常写链式调用可以封装一些可复用的小函数比如pipeconst pipe (...fns) (input) fns.reduce((acc, fn) fn(acc), input); const process pipe( arr arr.filter(user user.isActive), arr arr.map(user user.score), arr arr.filter(score score 60), arr arr.reduce((sum, score) sum score, 0) );这样写的好处是逻辑分块清晰维护起来也方便。5.3 并发控制、异步场景中的这些方法很多人会问forEach可以配合async/await吗可以但要小心。如果你的回调是异步函数forEach不会等待异步操作完成它会直接遍历下一个元素然后返回。来看一个例子async function processItems(items) { items.forEach(async (item) { await someAsyncTask(item); }); console.log(done); // 可能先于异步任务执行完打印 }这是因为forEach不会等待回调返回的Promise。如果你需要串行执行异步任务用for...of配合awaitasync function processItems(items) { for (const item of items) { await someAsyncTask(item); } console.log(done); // 等所有任务执行完后打印 }如果你想并发执行可以用Promise.all配合mapawait Promise.all(items.map((item) someAsyncTask(item)));但要注意map在这里仍然会同步遍历所有元素立即发起所有异步任务然后再等待全部完成。如果并发数过大可能会压垮服务器一般需要做并发控制比如分批处理。filter和every在异步场景中同样有自己的局限。filter的回调如果是异步函数它返回的是Promise对象而Promise对象永远是truthy所以filter(async () true)会把所有元素保留而不是按异步结果筛选。正确做法是const results await Promise.all( items.map(async (item) { const keep await check(item); return keep ? item : null; }) ); const filtered results.filter(Boolean);或者使用现代写法const filtered []; for (const item of items) { const keep await check(item); if (keep) filtered.push(item); }every在异步环境下也不能直接用async回调因为回调返回的Promise永远被当作truthy结果会恒为true。如果你需要异步全真判断也得手动for...of遍历。这些都是在实际开发中容易踩的坑尤其在后端Node.js处理数据时经常遇到。6. 常见问题与排查技巧实录6.1 为什么不能用return跳出forEach前面已经提到过forEach忽略了回调的返回值它不像every或some那样会检查返回值。如果你在回调里写return false只会结束当前这次回调的执行不会终止整体遍历。排查类似问题时先确认你是不是在forEach里写了“提前退出”逻辑。如果是建议改成for...of加break或者改用some/every根据条件语义选择。还有一种特殊情况你希望“跳过当前元素但继续后续元素”这本来就不需要continue只要在回调里用if包住逻辑即可。注意forEach没有continue和break只有return是合法的“跳过当前回调”方式但语义上相当于continue而不是break。如果你硬要在forEach中模拟break用try...catch抛出自定义异常是常见的“面试解法”但生产环境不推荐。它会让控制流变得难以追踪还会吞掉其他异常除非万不得已别这么做。6.2 filter通常不会修改原数组但回调里的副作用要注意filter本身不会修改原数组它返回一个新数组。但如果你在回调里间接修改原数组元素、增删数组项就会产生不可预测的结果。比如const arr [1, 2, 3, 4]; const result arr.filter((v, i) { if (i 1) { arr[2] 30; // 修改后续元素 } return v 2; }); console.log(result); // [3, 30]因为遍历到第0个元素时v是1不满足条件遍历到第1个元素时v是2不满足但修改了arr[2]为30遍历到第2个元素时v已经变成30满足条件被保留。这导致你看到的结果可能和预期完全不一致。排查思路不要在filter、forEach、every的回调里修改数组的结构使用push、pop、splice、shift、unshift等。如果确实需要考虑先复制一份数组或者用“从后往前”的循环方式。此外如果你看到filter调用了但原数组长度变了那肯定是回调里有副作用逐一检查回调内部逻辑即可。顺带提一下热词里有一个“dumpcs filter”很多人第一眼以为是什么高级的筛选函数其实它可能指某个工具或脚本里的filter模块。这种命名上的混淆在搜索时经常出现。JavaScript的filter只是数组方法别被其他领域的同名术语带偏。6.3 every在空数组上的行为以及如何测试空数组调用every返回true这是规范行为不是bug。如果你希望“空数组时校验不通过”需要在调用every前手动判断数组长度const canSubmit fields.length 0 ? false : fields.every(isValid);或者封装一个函数isEmpty判断先行。测试的时候也要把空数组列入测试用例。我见过同事因为不知道这个行为在表单校验场景中被空数组导致“所有必填字段为空也能通过”最后排查了半天才找到原因。写单元测试时的经验是最好把边界情况列成表格一次性校验清楚。比如针对myEvery输入数组测试条件期望结果[]v v 0true[1, 2]v v 0true[1, 2]v v 1false[1, , 3]v v 0true最后一行说明空位被跳过所以两个真实元素都满足结果true。这个表格能帮你快速验证实现的正确性。如果你在写手写实现也可以用这些用例来测试你的myEvery。同理myFilter和myForEach也需要类似的边界用例包含空位的数组、非数字索引、类数组对象、字符串作为调用者等等。最后再分享一个我在实际项目中用过的小技巧如果你经常需要“校验数组非空且全部满足条件”可以这样写const isValid arr.length 0 arr.every(check);这样既避免了空数组的陷阱又保持了代码简洁。而且由于的短路特性当arr.length 0时根本不会调用every性能也不会浪费。说实话这三个方法我写了无数次手写实现但每次都能发现新的细节。尤其是稀疏数组、回调副作用、空数组返回值这几块几乎就是面试官最爱挖的坑。你可以把我上面的代码拷贝到自己项目里跑几个测试用例亲手感受一下这些边界情况。等你真正理解了它们的行为不仅面试能对答如流日常写代码也能少掉很多坑。
返回列表