- 文档
- 技术博客
- 教程
【免费下载链接】weekly
前端精读周刊。帮你理解最前沿、实用的技术。
本文以《设计模式 - Iterator 迭代器模式》精读为核心,讲解迭代器模式的行为型设计意图、JS 中[Symbol.iterator]协议的具体形态、generator / 数组 / Map 三类典型应用、TypeScript 手写实现与实现时的健壮性考量,并联动仓库内《精读《迭代器 Iterable》》一文,帮助你从"设计模式"与"语言内置协议"两个视角吃透迭代器,掌握用同一套 API 遍历任意聚合对象而不暴露其内部结构的能力。
为什么需要迭代器模式:从遍历方式的割裂说起
迭代器模式(Iterator)属于行为型模式,其经典意图是:
提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象的内部表示。
这个意图要解决的根本问题是:聚合的种类非常多——对象、链表、数组,甚至自定义结构——而不同结构的遍历方式各不相同,所以我们必须了解每种结构的内部定义才能遍历它:
- 数组可以利用
length+for循环遍历; - 对象可以使用
Object.keys遍历; - 链表比较麻烦,需要内部暴露出元素的
next指针,由调用方手动沿着next走到下一个元素。
如果业务代码里到处散落着针对不同结构写死的遍历逻辑,那么一旦结构发生变化或引入新的聚合类型,所有调用点都要跟着改。迭代器模式的价值在于:用同一种 API 遍历任意类型的聚合对象,且调用方完全不用关心聚合对象内部是怎么存储的。
在 JS 里,这种"统一 API"并不是抽象概念,而是语言层面的真实协议。它和Array.from有点像,但真正的迭代器在 JS 中是obj[Symbol.iterator]()——一个对象只要实现了[Symbol.iterator],就被认为是可遍历的。这一点在仓库姊妹篇 精读《迭代器 Iterable》 中有更完整的展开:JS 引擎之所以知道字符串该如何逐字符遍历、数组[1, 2, 3]与Set每个 Key 如何对应,背后的原理就是迭代器协议;Array、Set都是可迭代的,所以它们都能被for...of遍历,也能相互转换。
三个工作里触手可及的例子
设计模式需要在日常工作里用起来。下面三个例子能让你直观体会:什么场景下会用到迭代器,以及统一遍历 API 到底意味着什么。
例子一:generator 天生就是迭代器 API
generator天生为迭代器而生:
function* func () { yield 'a'; yield 'b'; return 'c'; } var run = func(); run.next() // {value: "a", done: false} run.next() // {value: "b", done: false} run.next() // {value: "c", done: true}调用方无需关心 generator 内部是何种存储结构,只需要调用.next(),并根据返回的done字段判断是否遍历完即可。注意在 generator 的场景中,迭代器不仅用来遍历聚合,还用于执行代码——next()每次调用都会让函数体推进到下一个yield处,这比单纯遍历更进了一步。
例子二:用迭代器遍历数组
数组同样可以用迭代器的方式遍历:
const arr = [1, 2, 3] const run = arr[Symbol.iterator]() run.next() // {value: 1, done: false} run.next() // {value: 2, done: false} run.next() // {value: 3, done: false} run.next() // {value: undefined, done: true}说明:
next()返回的{ value, done }二元结构是整个迭代协议的契约——done: false表示迭代尚未结束、value是当前元素;done: true表示遍历完毕。遍历到末尾后再调用next(),会返回{ value: undefined, done: true }。
可能有人觉得这是画蛇添足——遍历数组用for循环显然更方便。但这就是设计模式思维与非设计模式思维的区别:重要的不是用熟悉简单的 API 快速满足眼前需求,而是关注如何统一、抽象、低耦合地编码。
例子三:Map 迭代器
Map 对象也可以用迭代器方式遍历:
const citys = new Map([['北京', 1], ['上海', 2], ['杭州', 3]]) const run = citys.entries() run.next() // {value: ['北京', 1], done: false} run.next() // {value: ['上海', 2], done: false} run.next() // {value: ['杭州', 3], done: false} run.next() // {value: undefined, done: true}这里用的是Map.prototype.entries()——它返回的迭代器以[key, value]二元组为元素,依然是同一个{ value, done }契约。事实上 精读《迭代器 Iterable》 指出,String、Array、TypedArray、Map、Set等内置类型全部支持迭代,它们都能被for...of统一遍历,这就是迭代器协议在语言层面的统一性。
意图解释:为什么迭代器是唯一不需要关心聚合类型的方案
把上面所有聚合类型放到一起看,结论就很清晰了:虽然用迭代器遍历数组看上去比for循环麻烦一点,但在所有聚合类型的遍历方案中,只有迭代器的 API 是最统一的,是唯一一个不需要关心聚合类型就可以完成遍历的方案。
再回到意图:
提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象的内部表示。
此时就非常好理解了:我们无需关心数组、generator、Map 内部是如何存储的,就可以进行遍历。实际上,深究 generator 内部的存储结构也没有意义;如果我们不用迭代器进行遍历,那么对复杂结构的遍历成本会非常高。
这种"黑盒遍历"的思想,在 精读《迭代器 Iterable》 中被概括为一句话:一个对象黑盒,不论内部怎么实现,如果我们能按照顺序数出内部结构,那么这个对象就是可迭代的。这正是[Symbol.iterator]协议要解决的问题,也正是 Iterator 设计模式在语言层面落地的形态。
结构图与两个核心角色
迭代器模式的类图结构由两个核心角色组成:
Aggregate(聚合):定义创建迭代器的接口。在前端规范里对应[Symbol.iterator](),在经典类图里对应CreateIterator()。Iterator(迭代器):定义访问与遍历的 API。
在 JS 中,这两个角色的边界由迭代协议天然划清:聚合对象实现[Symbol.iterator]()方法并返回一个迭代器对象;迭代器对象实现next()方法,返回{ value, done }。参考 精读《迭代器 Iterable》,在 JS 里自定义迭代器通常有两种写法:
写法一:独立定义迭代器
const obj = {} obj[Symbol.iterator] = function() { return { someValue: 1, next() { // 可通过 this.someValue 访问与修改该值,可定义任意数量的变量作为迭代过程中的辅助变量 if (/* 未遍历完 */) { return { done: false, value: this.current++ } // 迭代还没完,当前值为 value } return { done: true } // 迭代完毕 } } }之所以规范采用[Symbol.iterator]这个符号属性,是为了防止普通的字面量 Key 与对象自身的 OwnProperties 冲突。
写法二:合并在对象里定义(迭代器即自身)
const range = { from: 1, to: 5, [Symbol.iterator]() { this.current = this.from return this }, next() { if (this.current <= this.to) { return { done: false, value: this.current++ } } else { return { done: true } } } }这种写法的缺点是:并行迭代同一个对象时可能触发 BUG,因为每次迭代共享了同一份状态变量(current)。这也是"实现时要考虑的因素"之一——迭代器的状态管理需要谨慎设计。
实现时需要考虑的三个因素
迭代器的定义很简单,实现时要考虑的因素可不少:
- 健壮性:迭代过程中增加、删除元素后,还能正常遍历;遍历空聚合时也要能正常工作(即空聚合也应立刻返回
{ done: true },而不是报错或死循环)。 - 外部控制迭代还是内部控制:类似 KOA 由插件调用
next()控制迭代,还是由外层统一控制迭代。这决定了迭代器的推进权归属——JS 中for...of属于外层自动控制,而手动调用iterator.next()则把控制权完全交给你,可以自由控制next()的触发时机与频率,甚至提前终止(详见 精读《迭代器 Iterable》 中"手动控制迭代"一节)。 - 如何定义遍历算法:即便对于对象这种简单场景,也存在深度优先和广度优先、冒泡与捕获这几种遍历顺序。迭代器可以提供选择或者扩展的方式,自定义遍历算法,从而把"怎么走"的算法细节封装在迭代器内部,对调用方透明。
TypeScript 手写实现:从接口到具体迭代器
下面是用 TypeScript 实现的一个最小可运行的迭代器模式版本(对应原文档代码,此处补全了边界处理与使用示例):
// 定义聚合接口:聚合需要提供创建迭代器的方法 interface Aggregate { getIterator: () => Iterator } // 定义迭代器接口:定义访问与遍历的 API interface Iterator { // 指向下一个,返回 { value, done } next: () => { value?: any; done: boolean } } // 定义一个聚合:List 内部用数组存储元素,并维护一个游标 class List implements Aggregate { // 存储元素 public values: string[] // 游标 public index: number constructor(values: string[]) { this.values = values this.index = 0 } getIterator() { return new ConcreteIterator(this) } } // List 的迭代器 class ConcreteIterator implements Iterator { private list: List constructor(list: List) { this.list = list } next() { // 边界情况:游标越过末尾时返回 done: true,避免越界访问 if (this.list.index >= this.list.values.length) { return { value: undefined, done: true } } const value = this.list.values[this.list.index] this.list.index++ return { value, done: false } } } // 使用:调用方完全不关心 List 内部如何存储,只依赖统一 API 遍历 const list = new List(['a', 'b', 'c']) const iterator = list.getIterator() iterator.next() // { value: 'a', done: false } iterator.next() // { value: 'b', done: false } iterator.next() // { value: 'c', done: false } iterator.next() // { value: undefined, done: true }从代码可以看出迭代器模式在 OO 世界的标准形态:Aggregate(聚合)负责生产Iterator(迭代器),Iterator内部持有对聚合的引用并维护游标状态。原文档中的示例代码刻意省略了边界处理(注释说明"注意边界情况,这里就不展开"),本文在实现中补全了游标越界时返回done: true的健壮性逻辑——这正是上文"实现时需要考虑的因素"中健壮性要求的具体落地。
值得一提的是,上面这套{ value, done }的返回契约,与 JS 内置迭代协议完全一致。也就是说,如果你让上面的ConcreteIterator同时暴露[Symbol.iterator]()(返回this),它就能直接支持for...of、Array.from、展开运算符等语言级能力。在 精读《迭代器 Iterable》 中可以看到更多可迭代对象适用的内置 API:Map()、WeakMap()、Set()、WeakSet()、Promise.all()、Promise.allSettled()、Promise.race()、Promise.any()、Array.from(),以及依赖迭代器实现的解构与展开语法:
// Array.from 可以将可迭代对象变成真正的数组 Array.from(new Set(["1", "2", "3"])) // ['1', '2', '3'] // 解构与展开本质也是用迭代器进行运算 [...range] // [1, 2, 3, 4, 5](range 为上面实现了 [Symbol.iterator] 的对象)弊端与适用场景:不要为了模式而模式
迭代器模式并非万能。如果你只是遍历数组,直接用for循环会比迭代器方便很多,没必要为了用设计模式而用设计模式。迭代器仅在以下情况可以考虑用于数组:
- 这个数组比较特殊,是 N 维数组,需要一次性遍历完,那么可以用迭代器(把"维度展开"的算法封装进迭代器,调用方无需递归)。
- 同时遍历数组和其他类型的聚合:不论数组还是其他聚合,都用相同的迭代器模式遍历最好——此时统一 API 的收益最大,正如本文意图解释部分所强调的"唯一统一方案"。
换句话说,迭代器模式的价值在"异构聚合的统一遍历"场景下才会真正体现;对单一结构、简单场景,直接用语言原生的for循环反而更直白、更高效。
与其他设计模式的关系
迭代器模式比较好理解,这里补充几个密切相关的设计模式:
- 与组合模式配合:组合模式(Composite)将对象组合成树形结构以表示"部分 - 整体"的层次结构,详见 精读《设计模式 - Composite 组合模式》。迭代器可以在组合结构内进行递归,这样一个迭代器就能遍历完整个组合树的所有节点,把"树的遍历算法"从调用方手中解放出来。
- 与工厂模式 + 多态模式配合:可以用工厂模式 + 多态模式,实例化不同的迭代器的实例——聚合对象通过工厂方法按需创建不同策略的迭代器(例如深度优先遍历器、广度优先遍历器),再通过多态统一使用。
- 与备忘录模式配合:当我们要还原迭代器状态时,适合在迭代器内部使用备忘录模式(Memento)进行状态存储。备忘录模式的意图是"在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,以便之后恢复",详见 精读《设计模式 - Memoto 备忘录模式》。把迭代游标等状态快照存入备忘录,就可以随时将迭代器恢复到之前的遍历位置。
总结
迭代器模式的核心思想可以用一句话概括:把"遍历"这件事从"被遍历的结构"中解耦出来——聚合对象不暴露内部表示,只负责交出迭代器;迭代器封装遍历算法与游标状态,对外只暴露统一的next()API。在 JS 中,这一思想已经内化为语言协议:实现[Symbol.iterator]即可获得for...of、Array.from、展开、解构等一整套语言级能力的支持(详见 精读《迭代器 Iterable》)。
在仓库的 设计模式 模块中,本文属于行为型模式系列的一环,其姊妹文章 精读《设计模式 - Composite 组合模式》、精读《设计模式 - Memoto 备忘录模式》 分别从树形结构与状态恢复两个角度,与迭代器模式形成了完整的协同关系;readme.md 汇总了整套精读周刊的全部模块索引,可作为继续深入学习的导航。
- 文档
- 技术博客
- 教程
【免费下载链接】weekly
前端精读周刊。帮你理解最前沿、实用的技术。
相关推荐
scrcpy安卓投屏:3个命令把手机屏幕搬进电脑
scrcpy安卓投屏:3个命令把手机屏幕搬进电脑 如果你想在电脑上直接看到手机屏幕,再用鼠标键盘去操作它,scrcpy就是干这件事的工具。它通过USB或WiFi
音视频fe-interview前端迭代器模式:集合遍历访问
fe interview前端迭代器模式:集合遍历访问 引言:为什么需要迭代器模式? 在日常前端开发中,我们经常需要处理各种数据集合的遍历操作。从简单的数组遍历到
前端知识库教程别再手动点了:青龙面板 API 批量运维全流程
别再手动点了:青龙面板 API 批量运维全流程 凌晨两点,一批定时任务要在整点前停掉,你只能打开青龙面板的网页一个个点;想改脚本里的账号配置,又要翻到环境变量页
任务调度后端前端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考