
遍历这块平时写业务代码摸鱼的时候谁都觉得简单真到线上出问题才意识到坑有多深。这段时间正好在重构一个数据中间层里面充斥着各种 for、forEach、map、reduce还有嵌套好几层的对象拷贝逻辑顺手把 JS 遍历从数组到对象、从同步到异步、从线性到树形结构全部重新吃了一遍。这篇文章就把我踩过的坑和总结出来的套路完整写一遍既讲 API 差异背后的迭代器模型也讲遍历过程中删除元素、异步控制、二叉树层序遍历这些高频场景的工程化写法。不管你是刚学会 forEach 的新手还是已经在业务里天天写遍历的老手应该都能翻到点有用的东西。1. 数组遍历先把迭代器模型讲透1.1 四类数组遍历 API 的取舍JS 里数组遍历的入口实在太多了for 循环、forEach、map、filter、reduce、for...of、every、some还包括新加入的 find。很多人背完 API 就上手写但真正问一句为什么有些场景不能用 forEach 直接 break为什么 map 适合做数据变换而不是纯遍历反而答不上来。先说 for 循环。它的优势是控制力最强可以自由控制下标、可以跳步、可以提前 break也可以在循环体内修改数组的长度和内容。性能上传统 for 循环在绝大多数 V8 优化场景下都是最快的尤其是遍历超长数组时for 和 for...of 的差距能到 10% 左右虽然业务里基本感知不到但如果是敏感的热路径代码还是值得保留 for 循环。forEach 的定位是对每个元素执行副作用。它的语义决定了回调里不能直接 break 或 return 中断想中途退出只能靠抛出异常这种非常别扭的方式。另外 forEach 不返回新数组它返回 undefined所以做数据变换时不要去用它map 是更语义化的选择。需要注意forEach 在遍历开始前会先确定数组的长度你在回调里 push 新元素新元素不会被当前这次遍历访问到反过来如果在回调里删除了后面的元素遍历也会出现跳过的情况这个后面单独展开。map 和 filter 的共同点是返回新数组适合链式调用。map 要求回调返回值是一一对应的新值filter 要求回调返回真值来决定当前元素去留。它们都不应该用来做遍历副作用输出否则代码读起来会非常别扭还会产生无谓的新数组开销。for...of 是 ES6 之后最推荐的数组遍历语法。它不需要管下标支持 break、continue、return配合解构还能拿索引和值比如for (const [index, value] of arr.entries())。底子上它依赖的是可迭代协议也就是对象必须实现Symbol.iterator方法数组天生就有。后面讲异步遍历时你会看到for...of 在异步串行里几乎是唯一优雅的写法。1.2 迭代协议为什么 for...of 能通吃数组有迭代器字符串也有Map、Set 也有甚至连arguments、NodeList这些类数组对象也有。它们的共同点就是实现了Symbol.iterator方法。你可以手动调用迭代器上的next()方法拿到{ value, done }这样的结果对象for...of 本质上就是在循环里反复调用 next直到 done 为 true。有一道很常见的面试题普通对象为什么不能直接 for...of因为普通对象没有实现Symbol.iterator。如果你真想遍历对象的键值可以自己给对象挂一个迭代器比如const obj { name: 张三, age: 18, *[Symbol.iterator]() { yield this.name; yield this.age; } }; for (const value of obj) { console.log(value); // 张三 18 }这个例子只是为了理解迭代器模型实际业务里别这么干直接用 Object.entries 更清晰。但你要明白一件事for...of 是建立在数据结构是否可迭代之上的而不是建立在是不是数组之上。所以数组、Set、Map、生成器、类数组统统可以共用一套遍历语法这是它最大的价值。生成器函数也是可迭代对象它为惰性遍历提供了基础。比如创建一个无限迭代器理论上可以for...of一直取数配合 break 控制退出这种能力用普通 for 循环根本写不出来。2. 对象遍历可枚举性、原型链与遍历顺序2.1 为什么 for...in 会让新手翻车for...in 是用来遍历对象可枚举字符串键的但它有个天然坑会把原型链上可枚举的属性也带出来。只要有人往 Object.prototype 上扩展过方法for...in 就会把这些方法当成普通属性遍历到造成莫名其妙的 bug。Object.prototype.custom prototype prop; const obj { a: 1, b: 2 }; for (const key in obj) { console.log(key); // a b custom }所以业务里遍历对象键时我更推荐 Object.keys 或者 Object.entries它们只返回对象自身的键不会碰原型链。如果非要 for...in自己也必须加一个hasOwnProperty判断但说实话这个习惯在很多团队已经改成禁止了。另一个容易忽略的点是 for...in 的遍历顺序。JS 对象在底层维护了属性顺序整数键按升序排在前面字符串键按插入顺序排在后面Symbol 键也按插入顺序靠后。这个顺序在 for...in、Object.keys 里基本一致但在某些场景下会被打破比如属性被删除再重新添加时整数键会重新排到前面。很多新手想当然认为对象键就是先来后到结果用 JSON 序列化时发现顺序不对找半天找不到原因。2.2 Object.keys / values / entries 的区别在哪这三个方法都是从对象自身可枚举字符串键出发但返回值维度不同。keys 返回键数组values 返回值数组entries 返回[key, value]的二维数组。三种都适合配 for...of配合解构尤其舒服。const user { name: 李四, age: 30, role: admin }; for (const key of Object.keys(user)) { console.log(key); // name age role } for (const [key, value] of Object.entries(user)) { console.log(${key}: ${value}); }如果你需要拿到对象自身的不可枚举属性比如类实例上的方法和属性就得用 Object.getOwnPropertyNames它返回所有自身的字符串键不管可不可枚举。如果要连 Symbol 键一起拿只有 Reflect.ownKeys 能做到。const sym Symbol(secret); const target { a: 1 }; Object.defineProperty(target, hidden, { value: 2, enumerable: false }); target[sym] 3; console.log(Object.keys(target)); // [a] console.log(Object.getOwnPropertyNames(target)); // [a, hidden] console.log(Reflect.ownKeys(target)); // [a, hidden, Symbol(secret)]这里我踩过的一个坑是在线排查对象结构时习惯用 Object.keys 看属性结果某些被 defineProperty 定义成不可枚举的属性死活看不到数据似乎凭空消失了其实就在 getOwnPropertyNames 里。所以做通用遍历工具时优先 Reflect.ownKeys否则你处理的就不是对象自身的完整结构。2.3 Map、Set 的顺序遍历机制Map 和 Set 的出现让对象遍历要自己维护顺序这种痛点基本消失了。Map 天然保持插入顺序遍历时有三种视角keys()、values() 和 entries()for...of 默认遍历的是 entries。const map new Map([ [a, 1], [b, 2] ]); for (const key of map.keys()) { console.log(key); // a b } for (const [key, value] of map) { console.log(key, value); // a 1 / b 2 }Set 的遍历只有 values() 和 entries()其中 entries() 返回的 value 和 key 是同一个值这是为了和 Map 的 API 对齐。Set 遍历同样保持插入顺序。很多人用 Map 做缓存后会想实现一个淘汰最早插入的数据的功能这时遍历顺序就特别重要。你可以通过map.keys().next().value拿到第一个键然后删除它相当于实现一个简易的 LRU 淘汰策略。这就是遍历顺序带来的工程价值。3. 异步遍历循环里塞 async 函数3.1 forEach 根本等不了 async这是一个高频翻车点。很多人写异步任务时会这样const urls [/api/a, /api/b, /api/c]; urls.forEach(async (url) { const data await fetch(url); console.log(data); }); console.log(done);这段代码的行为是先输出 done然后三个请求几乎同时发出等各自返回后再输出数据。forEach 内部的回调是同步调用的它把 async 函数当成普通函数调用后根本不等待返回的 Promise所以 forEach 不会等你的 await 执行完。如果你期望三个请求串行执行一个完成再发下一个上面的写法就是灾难。如果你想三个请求并行执行然后统一拿到结果那也不该用 forEach应该用 Promise.all 配合 map。这里还有个更隐蔽的坑forEach 回调里如果某个异步任务 reject这个错误不会自然被外层 try/catch 捕获因为 try/catch 同步执行区间早就过了。结果就是出现 unhandledrejection定位特别难。3.2 for...of await 实现串行需要串行执行异步任务时正解是 for...ofasync function run() { for (const url of urls) { const data await fetch(url); console.log(data); } }for...of 每轮迭代会等待 await 完成后才进入下一轮天然就是串行。这里要注意被遍历的数组元素不需要显式转成 Promise只需在循环体内部 await 返回 Promise 的表达式就行。如果你需要拿到串行执行过程中的每个结果可以直接 push 到一个数组里或者用 reduce 的异步变体但我觉得可读性不如 for...of。下面这种 reduce 写法虽然简洁但对新手很不友好const results await urls.reduce(async (accPromise, url) { const acc await accPromise; const data await fetch(url); return [...acc, data]; }, Promise.resolve([]));能用 for...of 写清楚的就别炫技代码是给团队看的不是给面试官看的。串行场景对接口限频、按顺序写入日志这类需求特别重要。3.3 Promise.all 并行与限流并发如果想并行执行全部异步任务最简单的姿势是const datas await Promise.all(urls.map((url) fetch(url)));这里 map 返回 Promise 数组Promise.all 等待全部完成。缺点是一口气创建所有任务如果 urls 有几万条副作用是瞬间产生大量并发请求服务端可能直接报警。所以业务中我经常需要手动实现一个并发限流器批量处理任务。一个极简的限流并发实现async function mapLimit(tasks, limit, handler) { const results []; let current 0; async function worker() { while (current tasks.length) { const index current; results[index] await handler(tasks[index]); } } const workers Array.from({ length: Math.min(limit, tasks.length) }, worker); await Promise.all(workers); return results; } // 用法 const datas await mapLimit(urls, 3, async (url) { return fetch(url).then((res) res.json()); });这个实现利用多个 worker 竞争同一个 current 计数器保证同一时刻最多有 limit 个任务在跑。你可以直接抄进项目里注意 handler 抛错时整个 Promise.all 会退出如果业务要求单个失败不阻塞整体要在 worker 内部 catch 并标记失败。4. 遍历时删除元素最常见的下标漂移4.1 正序 splice 为什么会漏项先看这段经典代码const arr [1, 2, 3, 4, 5]; for (let i 0; i arr.length; i) { if (arr[i] % 2 0) { arr.splice(i, 1); } } console.log(arr); // [1, 3, 5] ?你期待的答案是 [1, 3, 5]因为 2 和 4 是偶数应该被删除。但实际输出是 [1, 3, 4, 5]不对真实结果是 [1, 3, 5]我们来推演一下i0arr[0]1不删。i1arr[1]2删掉数组变成 [1,3,4,5]。关键来了for 循环的 i 让 i 变成 2此时数组长度变成 4arr[2]4跳过 3 吗不是arr[2] 是原来 arr[3] 的 4所以 3 被漏掉了。i2arr[2]4删掉数组变成 [1,3,5]。i3但数组长度是 3循环条件 i3 不成立退出。最终结果是 [1,3,5]看起来正确但为什么说漏项实际上用这个数组刚好歪打正着因为偶数恰好隔着。如果数组是 [1,2,3,4,5,6]正序删除偶数的最终结果是 [1,3,5] 吗我们来算删除2后 [1,3,4,5,6]i2 指向 4删除4 - [1,3,5,6]i3 指向 6删除6 - [1,3,5]。结果碰巧也对了。但如果数据是 [2,3,4]删除2后 [3,4]i1 指向4删除4 - [3]最终 [3]看起来也对了但如果要求删除 2 和 3期望 [4]实际i0 删2 - [3,4]i1 指向4不满足不等于3结束 - [3,4]这就是漏项问题来了。再换一个场景删除所有偶数但保留奇数[2,4,6]删除2 - [4,6]i1 指向6删除6 - [4]结束5 不在的话 4 留下来预期 []实际 [4]漏了4其实因为下标漂移导致 4 一直被跳过最后留在数组里。所以永远不要正序删除。正确做法倒序遍历。因为 splice 只影响当前位置及之后元素的下标倒序时已经访问过的元素不受影响。for (let i arr.length - 1; i 0; i--) { if (arr[i] % 2 0) { arr.splice(i, 1); } } console.log(arr); // [1,3,5]也可以用 filter 重建数组但注意 filter 不会修改原数组如果你需要原地修改还得重新赋值。4.2 对象和 Map 遍历时删除的规则对象用 for...in 遍历时删除当前正在遍历的属性并不总是安全的。规范对对象属性遍历的删除行为有明确规定但各引擎实现细节很容易踩坑。我吃过一次亏在 for...in 里删除了一个尚未遍历到的属性结果那个属性的遍历行为不一致有的浏览器跳过它有的浏览器在你删除后又访问了一次。所以对象遍历过程中最好别做删除操作可以先收集要删除的键遍历结束后统一删。Map 的规则就清楚得多Map 的迭代器在遇到被删除的 entry 时会直接跳过所以遍历 Map 时删除当前 entry 是安全的const map new Map([ [a, 1], [b, 2], [c, 3] ]); for (const [key, value] of map) { if (value 2) { map.delete(key); } } console.log(map); // Map { a 1, c 3 }这段代码可以放心跑。但如果删除的是非当前项比如遍历到 b 时删除 a迭代器内部会不会出问题答案是通常不会因为 Map 的键是哈希存储删除不影响迭代顺序但稳妥起见还是建议跟对象一样的策略标记后统一删除可读性也更好。Set 的情况跟 Map 类似遍历时删除当前 value 安全删除其他 value 虽然不会崩但容易造成逻辑混乱。4.3 安全的删除姿势总结一下我在真实项目里对遍历删除定了三条规矩数组原地删除优先倒序遍历 splice不要写正序。数组不必须原地删除用 filter 重建新数组纯函数写法最省心。对象和集合遍历时不直接 delete先收集 key遍历结束后用for (const key of toDelete) obj[key] 或 map.delete(key)统一处理。有一个业务场景很典型表格数据里删除多个选中的行。很多时候我们会用 forEach findIndex splice 的组合结果因为下标变化删除 A 行后 B 行 index 变了最后删不对。我当时改成了先按 id 收集要删除的集合然后用 filter 一次生成新列表代码瞬间简单测试也全绿。5. 链表与二叉树的遍历递归和迭代的博弈5.1 链表遍历的迭代写法链表这种结构在 JS 里没有内置类型但前端做 undo/redo、埋点上报队列、协议栈解析时经常手动实现。链表的遍历非常简单迭代版就是用一个指针不断往后挪class ListNode { constructor(val, next null) { this.val val; this.next next; } } function traverseList(head) { let current head; while (current) { console.log(current.val); current current.next; } }递归版也简单但深度链表会导致调用栈溢出比如几万级节点就不要用递归。算法题里链表反转、合并两个有序链表都是在这个 while 循环框架上加指针操作。我写迭代时习惯先把current和next的关系画清楚不然特别容易在指针交接处写成死循环。5.2 二叉树前中后序递归转迭代的套路二叉树的遍历是 JS 面试和实际图形场景里的常客。递归写法非常好记function preorder(root, res []) { if (!root) return res; res.push(root.val); preorder(root.left, res); preorder(root.right, res); return res; }前序是根左右中序是左根右后序是左右根。只改三行代码就能切换。但要应付超深树递归容易爆栈就要转迭代写法。这里有个核心套路用栈模拟函数调用栈。前序遍历的迭代写法function preorderTraversal(root) { const res []; const stack [root]; while (stack.length) { const node stack.pop(); if (!node) continue; res.push(node.val); stack.push(node.right); stack.push(node.left); } return res; }这里为什么先压 right 再压 left因为栈是 LIFO弹出时先弹 left才能保证 根-左-右 的顺序。中序迭代麻烦一点需要一路向左走到最底再回头function inorderTraversal(root) { const res []; const stack []; let cur root; while (cur || stack.length) { while (cur) { stack.push(cur); cur cur.left; } cur stack.pop(); res.push(cur.val); cur cur.right; } return res; }后序迭代可以玩个巧前序是 根-左-右后序是 左-右-根。那我可以先做根-右-左的遍历再整体反转就得到后序。因为前序模板是现成的只需要把压栈顺序换成先 left 后 rightfunction postorderTraversal(root) { const res []; const stack [root]; while (stack.length) { const node stack.pop(); if (!node) continue; res.push(node.val); stack.push(node.left); stack.push(node.right); } return res.reverse(); }这套思路理解后遇到再难的变体都能很快写出来。5.3 层序遍历队列实现 BFS层序遍历就是 BFS一层层从左到右扫描。关键是用队列并且每次循环开始时要先记录当前队列长度不然你无法知道这一层到底有多少节点function levelOrder(root) { if (!root) return []; const queue [root]; const res []; while (queue.length) { const levelSize queue.length; const levelNodes []; for (let i 0; i levelSize; i) { const node queue.shift(); levelNodes.push(node.val); if (node.left) queue.push(node.left); if (node.right) queue.push(node.right); } res.push(levelNodes); } return res; }那个levelSize如果不提前存下来直接用 queue.length 作为循环条件queue 会不断变长你就分不出层了。这是层序遍历最容易错的地方我见过不止一次有人在工程代码里这样写输出的数组全混在一起。层序和前序的区别其实特别好记层序用队列天然适合按层处理前序用栈天然适合深度优先。如果你能把层序遍历的模板背下来像每层的最大值二叉树右视图填充每个节点的下一个右侧指针这些题基本就是换汤不换药。5.4 前序层序的分工与关系热词里反复出现层序遍历和前序遍历我觉得这两者的对比是理解遍历模型的好切入点。前序是深度优先走到底再回头层序是广度优先一层层平推。同一个算法问题选择哪种遍历方式往往影响解题复杂度。比如判断二叉树是否对称用层序遍历逐层检查镜像最直观。而序列化二叉树前序遍历配合 null 标记最省解析成本。工作中处理类树形结构的天花板目录时层序遍历能方便地统计每层目录的节点数前序遍历方便生成全路径清单。代码实现层面也可以把前序遍历改成模拟 DFS与层序的 BFS 做对比。前端常见的一个场景是递归组件树渲染权限树你往往需要同时知道子节点数量和当前节点深度层序遍历天然带着层号信息处理起来比 DFS 顺手得多。6. 遍历性能与惰性求值不要只看 API 名6.1 常见遍历方式的性能差异我在本地用一百万长度的数组做过粗略基准测试不同机器差异很大只看相对趋势for 循环和 for...of 差距在 5%-15%forEach 和 for...of 接近map 和 filter 因为要创建新数组内存分配开销更明显reduce 在拼接新数组时如果频繁展开数组性能会显著下降。但性能从来不是选遍历 API 的唯一标准。业务代码里纯遍历场景的瓶颈通常在回调里的业务逻辑而不是循环本身。如果你遇到十万级数组要频繁遍历我建议先做一次 profiling再考虑要不要把 forEach 换成 for。盲目把 map 改成 for 来优化代码可读性下降收益却几乎为零。有一类优化值得做链式遍历时减少中间数组。比如arr.filter(...).map(...).reduce(...)会创建两个中间数组大数据量时触发多次 GC。可以用单次 reduce 把过滤和映射合并牺牲一点代码清晰度换取内存稳定。6.2 生成器与惰性遍历生成器函数让遍历可以做到按需计算而不是一次性把所有数据算完。假设你要生成一个斐波那契数列并取前十个偶数项用普通数组存全量数据会越算越大用生成器就可以边遍历边控制终止function* fibonacci() { let a 0, b 1; while (true) { yield a; [a, b] [b, a b]; } } const result []; for (const value of fibonacci()) { if (value 100) break; if (value % 2 0) result.push(value); } console.log(result); // [0, 2, 8, 34]这种惰性遍历在分页拉取接口、大文件逐行读取、无限滚动列表加载等场景特别有用。你不需要创建完整的中间数组只需要一个迭代器。配合 for...of 的 break 能力可以在任意位置干净地停止遍历而不会像 forEach 那样无法中途退出。一个更贴近业务的例子读取一个超大日志文件用 readline 的 async iterator 逐行遍历每处理一行就释放一行内存占用保持极低。如果用一次性 readFile 读成字符串再 split 成数组再 for 循环几十 MB 文件就能把内存吃满。6.3 业务里怎么选遍历器我整理了一份选择参考不一定绝对准确但可以作为团队代码评审时的判断依据需要中间退出for、for...of、some、every选哪个看语义。需要做数据变换返回新数组map。需要过滤filter。需要把数组收敛成单个值reduce。只需要访问每个元素做副作用forEach 或 for...of。需要串行异步for...of await。需要并行异步map Promise.all 或并发限流器。需要处理树形/链表递归或栈/队列迭代。另外some 和 every 有短路机制遍历到满足条件就停适合做是否存在某元素是否全部满足这类判断比 forEach flag 变量优雅太多。find 和 findIndex 则在需要拿到第一个匹配元素或下标时使用语义明确。业务里还有一个常见的坑对可迭代对象但不是数组的数据结构直接调用数组方法比如 NodeList。NodeList 没有 map 方法直接document.querySelectorAll(div).map(...)会报错。正确做法是const nodes Array.from(document.querySelectorAll(div)); // 或者 const nodes [...document.querySelectorAll(div)];Array.from 还可以传第二个参数做 map 逻辑一步到位产出新数组。我个人在实际项目里的体会是遍历这个问题看似基础却最容易在不经意间引入线上 bug。尤其是遍历时修改集合结构和异步回调不等待这两类问题几乎每个团队都会踩一遍。如果你时间有限先把 for...of 的语义吃透把它当成默认遍历手段再结合需求在 map、filter、reduce 里做数据变换至少能把一半的隐患消灭在源头。后面真正碰到性能瓶颈时再回头优化 for 循环也不迟前提是记得加注释告诉后来人你为什么不用更语义化的写法。