JavaScript遍历全解:从数组循环到二叉树遍历
如果你问我前端日常写代码最绕不开的东西是什么我的答案一定是遍历。数组要循环、对象要取键、链表要一路 next 下去、二叉树要一层一层走、DOM 要递归找节点甚至深拷贝、数据扁平化、树形组件渲染本质上都是在做遍历。JS 里的遍历写法又多又杂for、while、forEach、map、filter、reduce、for...in、for...of、迭代器、生成器很多人学了多年还是分不清该用哪个。这篇东西我打算一次性讲透从最基础的数组循环一路讲到迭代器、二叉树遍历和 DOM 实战配合每个方案背后的“为什么选它”和“坑在哪”看完之后你再遇到任何遍历需求基本都能直接写对、选对、用对。这篇内容不挑基础刚入门的能从头看懂写了两三年的也能从进阶部分拿点东西走。我尽量用我在项目里实际用过的代码和踩过的坑来讲少一点教科书味道多一些真实场景。开始吧。1. 先从最熟悉的地方说起数组遍历的五个层次数组大概是 JS 里最常用的数据结构没有之一。遍历数组的写法五花八门但我习惯把它们分成五个层次传统循环、函数式遍历、链式处理、迭代器协议、生成器。后面两层是进阶前三种是日常工作主力。1.1 基础循环for 与 while 的适用场景先说最常见的 for 循环。它的结构很清楚初始化条件、循环条件、每次迭代后的更新表达式。语法层面没什么好讲的关键是搞懂什么场景必须用 for 而不是别的。一种是我需要同时拿到索引和值并且要经常修改原数组。举个我在实际项目里踩过坑的例子要把一个数组每隔一个元素删除一个。用 forEach 写出来效果是错的因为删除元素会让索引漂移。正确的做法是用 for 倒序遍历从最后一个元素往前删这样可以保证前面的索引不受影响。// 每隔一个删除一个元素 let arr [1, 2, 3, 4, 5, 6]; for (let i arr.length - 1; i 0; i - 2) { arr.splice(i, 1); } console.log(arr); // [1, 3, 5]这个场景很典型它同时体现了 for 的灵活性和索引控制能力。while 用得相对少但有一些场景非常合适比如不知道循环次数、靠某个条件判断退出的情况。链表遍历就是典型例子我们不知道链表有多长只能用一个指针往下走走到 next 是 null 就停。这种“不固定次数、只看条件”的场景用 for 写会很别扭用 if 加 while 才自然。1.2 forEach 的两面性方便背后的三个坑forEach 我很多年前就很喜欢用因为写起来快、语义清楚。但它有三个坑新手常常在第三个上翻车。第一个坑是 break 和 return 都退不出循环。JS 没有“提前终止 forEach”的官方手段只能在遍历过程中抛出异常来实现 break非常不优雅。如果遍历中需要中途退出比如找到目标元素就停老老实实用 for 循环或者用 some、every 这些天生支持短路的方法。第二个坑是 this 绑定问题。传给 forEach 的回调函数如果用了普通 function里面的 this 不指向数组除非传入第二个参数指定 thisArg。现在有了箭头函数这个坑基本被填补了但我在接手老代码时还看到过因为 this 写错导致的 bug。第三个坑是稀疏数组会被跳过。forEach 回调不会执行稀疏位。如果数组有空洞比如通过 new Array(5) 创建用 for 循环访问这些位置会得到 undefined用 forEach 则直接跳过回调。这两个行为差异会让你的逻辑在某些边界场景表现不一致。let arr new Array(3); // 稀疏数组 arr[1] a; arr.forEach((item, index) console.log(index, item)); // 只输出 1 a1.3 map、filter、reduce从循环到函数的思维转变map、filter、reduce 这三个我放在一起说因为它们代表着一种思维转变从“怎么遍历”到“怎么处理数据”。传统 for 循环强调的是过程你要具体描述“从第几个开始、怎么递增、什么时候停”函数式方法强调的则是结果你只关心“把 A 变成 B 的规则是什么”遍历的细节被隐藏了。map 表示“逐个转换”返回新数组长度不变。过滤用的是 filter返回满足条件的元素。reduce 最灵活它可以把整个数组合并成一个值也可以用来做扁平化、分组、计数。const numbers [1, 2, 3, 4, 5, 6]; const doubled numbers.map(n n * 2); const evens numbers.filter(n n % 2 0); const sum numbers.reduce((total, n) total n, 0); const flat [[1, 2], [3, 4]].reduce((acc, cur) acc.concat(cur), []);reduce 的思维很重要它本质上是在维护一个“累积器”。我第一次真正理解 reduce 是从数组转成对象开始——要把一个物品列表转换成按 id 索引的 Map 结构传统写法需要先创建空对象再 for 循环赋值reduce 一行搞定可读性甚至更好。1.4 性能实测与选型建议有人问过我 for 比 forEach 快多少这事要分情况。在小数组几千条以内场景两者性能差距在毫秒以下完全可以忽略。但数据量到十万、百万级别正统 for 循环会比 forEach 快一些因为 forEach 每次迭代有函数调用的开销还有回调和作用域的处理。我曾经在一个表格组件里处理过几十万行数据做过简单对比for 循环约 8msforEach约 12msmap还需要收集回来约 14ms当然这只是我本机测试的参考值环境不同数据不同结果会有差异。但结论很明确大数组、高频遍历场景用传统 for 更稳日常业务、小数据量优先选择可读性好的 map、forEach。如果既想要 for 的速度又想要函数式的可读性可以考虑用 for...of 加解构——这是后面会细讲的写法。2. 对象与字符串遍历常常被忽略的两个战场数组遍历写多了很容易把对象和字符串这两个“战场”忽略掉。它们在业务里出现频率极高但写法坑多很多人都是背下来的没搞懂原理遇到边界情况就炸。2.1 对象遍历for...in、Object.keys、Reflect.ownKeys三者的差异对象遍历第一关就是选方法。for...in 会遍历自身和继承对象上的可枚举属性这既是它的亮点也是它的坑点。如果对象继承自某个原型链for...in 会把原型上的属性也捞出来需要配合 hasOwnProperty 过滤。const parent { inheritedProp: 来自原型链 }; const child Object.create(parent); child.ownProp 自身属性; for (let key in child) { console.log(key); // 输出 ownProp 和 inheritedProp } Object.keys(child).forEach(key { console.log(key); // 只有 ownProp });Object.keys 只返回自身的、可枚举的字符串键。这是大多数场景最稳妥的选择。但如果对象的键是 SymbolObject.keys 就无能为力了——Reflect.ownKeys 才是真正的“全家桶”它会返回包括不可枚举的、Symbol 在内的所有自身键。我自己在开发一个配置合并模块时踩过一次坑配置对象的某个属性是用 Symbol 当作 key 藏的排查半天最后才想到 Object.keys 根本看不到 Symbol 键换个 Reflect.ownKeys 就拿到了。所以做工具库要谨慎选择业务开发图省心可以用 Object.keys Symbol 额外处理的方式。2.2 对象的遍历顺序问题说到对象遍历很多人不知道属性遍历是有顺序规则的吗——整数键会按数字升序排在最前面其他字符串键按插入顺序排列Symbol 键最后。这个规则在不同方法里表现不同但在现代 JavaScript 引擎里大致一致。const obj { b: 1, 3: three, a: 2, 1: one }; for (let key in obj) { console.log(key); // 输出顺序1, 3, b, a }这个顺序在某些场景很重要。比如你用对象维护一个“省市区”列表数字开头的区域编码可能被提前遍历导致渲染顺序和你预想的不一样。遇到这种问题不要硬捋直接用 Map 或者数组结构可能更合适。2.3 字符串遍历与“是否包含”的常见需求热词里有“js判断字符串是否包含”这其实是字符串遍历中最常见的需求但不同场景要选不同 API。最简单的是 includes它区分大小写ES6 以后的标准写法直接返回布尔值const str Hello JavaScript World; str.includes(javascript) ; // false大小写敏感 str.toLowerCase().includes(javascript.toLowerCase()); // true如果要做忽略大小写的判断最实用的是把两边都转成小写再比较这种方法简单、可控、兼容性好。也可以用更灵活的正则const pattern /javascript/i; pattern.test(str); // true如果还需要知道位置用 indexOf如果需要判断开头结尾用 startsWith 和 endsWith。字符串遍历本身我推荐用 for...of 而不是普通 for 循环。原因是普通 for 循环按 UTF-16 码元来分割遇到 emoji、生僻字这类字符会出错。for...of 遍历的是完整字符处理更友好。const emojiStr AB; for (let i 0; i emojiStr.length; i) { console.log(emojiStr[i]); // A, , , B } for (let char of emojiStr) { console.log(char); // A, , B }3. 进阶核心迭代器、生成器与 for...of 的底层逻辑到了进阶部分先说一个很多人写了几年代码都没真正理解的东西为什么 for...of 既能遍历数组又能遍历 Set、Map甚至自定义对象也让它工作答案是迭代器协议。3.1 可迭代协议解密 for...of 的万能钥匙JavaScript 里有一个约定如果一个对象拥有 Symbol.iterator 方法并且这个方法返回一个迭代器对象那么这个对象就是“可迭代的”。for...of 本质上不是在直接遍历数组而是在调用数组的 Symbol.iterator 方法拿到一个迭代器然后反复调用迭代器的 next() 方法直到返回的 done 为 true。数组、字符串、Set、Map、arguments、NodeList 这些原生对象都内置了迭代器。所以 for...of 可以通吃它们。这也是为什么面试里总问“for...of 和 for...in 有什么区别”——for...in 遍历的是键名字符串for...of 遍历的是值通过迭代器协议拿到的值。const set new Set([a, b, c]); for (let item of set) { console.log(item); // 输出 a b c不需要索引也不需要 next() }理解了这个底层逻辑很多问题就豁然开朗了。比如你写了一个不可迭代的自定义类想在 for...of 里遍历它发现报错。不是 JS 不让你遍历而是你没给它提供迭代方式——你只需要手动实现 Symbol.iterator。3.2 手写迭代器把机制彻底搞懂与其死记“迭代器是啥”不如亲手写一个。下面这个例子是让一个带有 start 和 end 属性的范围对象变成可迭代的const range { start: 1, end: 5, [Symbol.iterator]() { let current this.start; const end this.end; return { next() { if (current end) { return { value: current, done: false }; } return { value: undefined, done: true }; } }; } }; for (let num of range) { console.log(num); // 1 2 3 4 5 }这样写了之后 for...of 就能用了。原理看清楚迭代器就是带 next() 方法的对象next() 每次返回一个对象包含 value当前值和 done是否结束。当 done 为 true 时for...of 自动停止。3.3 生成器让遍历“暂停”的魔法手写迭代器还是很啰嗦的。如果内部状态复杂手写 next() 简直是灾难。ES6 提供了生成器Generator这个语法糖用 function* 声明配合 yield 关键字“吐出一个值并暂停”。function* generateRange(start, end) { for (let i start; i end; i) { yield i; } } for (let num of generateRange(1, 5)) { console.log(num); // 1 2 3 4 5 }生成器的执行过程像是“懒流式”的它不会一次性算出所有结果而是每次调用 next() 时才执行到下一个 yield 并暂停。这个特性在需要处理无限序列、大数据分页、懒加载时非常有用。举例来说在实现大文件逐行读取时生成器就很合适读到一行 yield 一行调用方处理完这一行再读下一行内存占用很低。如果觉得生成器不太常用请记住一点它是很多现代写法的基础设施。async/await 本质上就是基于生成器实现的只是引擎帮你封装了。理解生成器的“暂停/恢复”机制能帮你理解很多异步流程的底层逻辑。3.4 利用迭代器实现自定义对象遍历实际项目中自定义可迭代对象最经典的使用场景是链表或者树形结构。举个例子我们实现一个单向链表并让它可以直接用 for...of 遍历代码就会非常优雅class LinkedList { constructor() { this.head null; } add(value) { const node { value, next: null }; if (!this.head) { this.head node; } else { let curr this.head; while (curr.next) curr curr.next; curr.next node; } } [Symbol.iterator]() { let current this.head; return { next() { if (current) { const value current.value; current current.next; return { value, done: false }; } return { value: undefined, done: true }; } }; } } const list new LinkedList(); list.add(a); list.add(b); for (let val of list) { console.log(val); // a b }这一步做出来后链表的“遍历能力”就和原生数组一致了。工具函数如展开运算符、Array.from 也能直接作用在这个链表上。理解迭代器协议的收益就在于此——让自定义数据结构无缝接入 JS 的生态。4. 数据结构遍历链表与二叉树的全面攻克很多前端觉得算法是后端和面试题的事情但一旦开始做组件库、数据可视化、依赖图分析链表的遍历、二叉树的遍历就避不开了。而且树形结构在前端太常见了菜单树、组织架构树、目录树、AST 语法树到处是树。4.1 链表遍历迭代与递归的取舍先看单链表的遍历。最朴素的写法是迭代用 while 循环顺着 next 指针移动直到 null。写起来也不难function traverseLinkedList(head) { let current head; while (current) { console.log(current.value); current current.next; } }对于链表的其他高频操作——反转链表——同样有迭代和递归两种写法。递归写法看起来很秀但理解成本高而且链表很长时递归层数太深可能导致调用栈溢出迭代写法用三个指针 prev、current、next 反转每一步都很清晰function reverseList(head) { let prev null; let current head; while (current) { const next current.next; current.next prev; prev current; current next; } return prev; }我的建议是链表这类线性结构优先用迭代。递归的优雅不等于高效真正需要递归的是下面的树形结构。这也是为什么我把链表和二叉树放在一起做对比——两者的遍历方式和思维模式很不一样链表是一维线性移动二叉树有分叉和回溯。4.2 二叉树遍历前序、中序、后序、层序二叉树遍历在这两年面试题里几乎成标配了。所谓前序、中序、后序区别只在根节点的访问顺序前序是“根-左-右”中序是“左-根-右”后序是“左-右-根”。递归写法很简单关键在调用顺序function preorder(node) { if (!node) return; console.log(node.val); preorder(node.left); preorder(node.right); } function inorder(node) { if (!node) return; inorder(node.left); console.log(node.val); inorder(node.right); } function postorder(node) { if (!node) return; postorder(node.left); postorder(node.right); console.log(node.val); }递归虽然好写但无法应对特别深的树同样的调用栈溢出问题所以面试常要求用迭代方式重写。前序遍历的迭代写法最好理解用显式栈模拟递归的调用栈先把根节点压栈每次弹出一个节点再把右、左子节点依次压入先右后左是为了保证先访问左function preorderTraversal(root) { if (!root) return []; const result []; const stack [root]; while (stack.length) { const node stack.pop(); result.push(node.val); if (node.right) stack.push(node.right); if (node.left) stack.push(node.left); } return result; }中序迭代的写法稍微绕一点要从左子节点一路压栈直到左子树为空然后弹出一个节点并转向右子树。这中间每一步都要想清楚“上一层的折返点在哪里”但理解后对栈的应用会有更深体会。4.3 层序遍历从上到下逐层扫描层序遍历对应的是“广度优先”和前面三个“深度优先”不一样。层序是按层从左到右输出需要借助队列来实现先进先出的逐层推进。每轮循环开始时当前队列里存放的就是当前层的全部节点把这些节点依次出队并把它们的孩子入队就能完成一整层的处理function levelOrder(root) { if (!root) return []; const result []; const queue [root]; while (queue.length) { const levelSize queue.length; const currentLevel []; for (let i 0; i levelSize; i) { const node queue.shift(); currentLevel.push(node.val); if (node.left) queue.push(node.left); if (node.right) queue.push(node.right); } result.push(currentLevel); } return result; }层序遍历的前端应用极其广泛。树形组件的逐层渲染、目录树的层级展开、多级菜单的深度计算本质都是层序遍历。某个热词里也有“按层遍历”看来做前端的人确实经常碰到这类需求。4.4 实战应用深拷贝、序列化、DOM树遍历二叉树遍历并不是孤立的算法技巧它在真实的 JS 编程里到处可见。比如深拷贝要复制一个任意结构的对象本质就是递归遍历它的所有键值遇到对象或数组就再往里走一层直到所有节点都复制完。这就是深度优先的思路。function deepClone(obj, map new WeakMap()) { if (obj null || typeof obj ! object) return obj; if (map.has(obj)) return map.get(obj); const result Array.isArray(obj) ? [] : {}; map.set(obj, result); for (const key of Reflect.ownKeys(obj)) { result[key] deepClone(obj[key], map); } return result; }这段还能处理循环引用的对象用 WeakMap 记录已访问对象避免死循环遍历。再看看 DOM 遍历DOM 本身就是一棵树parentNode、childNodes、nextSibling 构成了遍历语义。用递归遍历所有节点配合判断条件就能实现“搜索并高亮指定节点”“统计页面标签使用次数”之类的功能。这些看似高深的操作和二叉树遍历共用同一套思维模型。5. 深度优先与广度优先遍历思维的前端化从数组到树遍历方法已经不止一种了。面对同一个数据结构你可以在深度优先和广度优先两条路线里选。搞懂两种思维的差别对解决实际问题异常有帮助。5.1 两种思维的差异与选择依据深度优先DFS是一条路走到底走不通再退回来类似挖隧道一直往前挖到底然后折返。广度优先BFS则是一层一层向外推进类似水波扩散。放在树结构里DFS 通常配合栈递归调用栈BFS 配合队列。怎么选如果目标是“找节点”并且节点大概率出现在靠近根部的位置BFS 能更快找到尤其适用于层数很深、叶子很多的情况。如果目标是“分层处理”比如按目录层级构建缩进或者“深度判断”比如计算树的最大深度DFS 更直观。日常业务里页面组件的递归渲染往往是 DFS数据报表的逐层汇总则是 BFS。5.2 用遍历搞定树形数据的扁平化与还原前端处理接口数据时常碰到树形结构要转成扁平列表或扁平列表要还原成树。树转扁平就是递归遍历收集所有节点扁平转树则需要借助父 id 建立映射关系本质上也是一次遍历加一次归组function flatTree(tree) { const result []; function walk(nodes) { nodes.forEach(node { result.push({ id: node.id, name: node.name }); if (node.children) walk(node.children); }); } walk(tree); return result; } function buildTree(flatList) { const map {}; const tree []; flatList.forEach(item { map[item.id] { ...item, children: [] }; }); flatList.forEach(item { const node map[item.id]; if (item.parentId) { map[item.parentId].children.push(node); } else { tree.push(node); } }); return tree; }这个过程在权限菜单配置、组织架构编辑、多级分类管理中用到次数非常多。理解遍历顺序这里是先序遍历先父后子代码写出来几乎不会错。5.3 遍历在业务组件中的综合应用拿一个多级菜单组件举例。菜单数据是一棵无限的树组件渲染需要递归遍历统计菜单深度需要遍历默认展开到某一层也需要遍历。再比如目录树组件需要支持搜素时在树中定位做法就是把树遍历一遍找到匹配节点再记录从根到该节点的路径。一个完整的前端项目里树的遍历永远不会缺席。6. 遍历中的性能、细节与常见问题排查最后这部分很重要。上面讲了这么多遍历方法最怕的就是你知道方法但用错场景。我挑几个我真实踩过的坑整理成容易查的清单。6.1 遍历时删除元素索引漂移问题这是我在维护老项目时遇到最多的 bug。用 for 循环正序遍历数组在循环体内调用 splice 删除当前元素删除后后面的元素会前移一位但 i 已经加一了所以跳过了原本的下一个元素。解决方案有两种一是倒序遍历从末尾往前删二是使用 while 加手动控制索引。前面已经给过倒序遍历的例子这里不再重复但我想强调这类问题很隐蔽比看起来的复杂。6.2 for...in 遍历原型链意外的属性污染for...in 会把原型链上的枚举属性都带出来。如果你的对象继承自某个类而某个版本的库又往原型上挂了一个自定义方法用 for...in 时会拿到这个不属于对象自身的东西。这类 bug 极易在组合式开发中爆发。建议是遍历对象老老实实用 Object.keys除非你有明确需求要包含原型链。6.3 大数组遍历的异步问题把任务拆开如果一个数组有上万条数据遍历体内又做了复杂的计算或 DOM 操作页面会卡到掉帧。原因不是遍历本身慢而是所有任务都在一个宏任务里同步执行浏览器没法响应渲染。解决办法是分批处理把大任务切分成多个宏任务用 setTimeout 或 requestAnimationFrame 控制节奏。甚至可以使用 Web Worker 把遍历计算放到后台线程计算完再回传主线程。下面是一个简单的分片处理示意function processLargeArray(arr, handler, chunkSize 100) { let index 0; return new Promise(resolve { function nextChunk() { const end Math.min(index chunkSize, arr.length); for (; index end; index) { handler(arr[index], index); } if (index arr.length) { setTimeout(nextChunk, 0); } else { resolve(); } } nextChunk(); }); }6.4 遍历中的正确调试姿势最后说下调试。给循环体打断点的时候光看当前变量不够建议把每次迭代的索引和状态变化打印到控制台。大循环不想断点太密可以设置条件断点比如 i % 1000 0 时才停下这个技巧在面对几十万数据排查时极其好用。最后再分享一点个人的体会写了这么多年 JS我最大的感受是遍历不是一个语法点而是一种思维。入门时你以为是在学 for 怎么用、forEach 怎么用等真正深入后你会发现自己学会了观察数据结构、规划访问路径、控制执行时机——这些能力在树形组件、状态管理、复杂数据处理里全都用得上。而且一旦掌握迭代器协议和递归思维你会发现很多新特性像可迭代对象、生成器等都能顺手接起来。如果你现在还在背遍历方法的 API不用焦虑那很正常。但建议你挑一个自己业务里曾经写得不顺的遍历场景重新用迭代器、递归、BFS/DFS 的思路各试一遍写通一次后面的路就顺很多。遍历这件事确实是“想看山是山”最后又要“看山不是山”的典型过程——但走进去之后收益是长期的。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →