如果你问我,前端日常写代码最绕不开的东西是什么,我的答案一定是:遍历。数组要循环、对象要取键、链表要一路 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 a"1.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 循环:约 8ms
- forEach:约 12ms
- map(还需要收集回来):约 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 只返回自身的、可枚举的字符串键。这是大多数场景最稳妥的选择。但如果对象的键是 Symbol,Object.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 = 'A😀B'; 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 的思路各试一遍,写通一次,后面的路就顺很多。遍历这件事,确实是“想看山是山”最后又要“看山不是山”的典型过程——但走进去之后,收益是长期的。