ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ES6迭代器与生成器:从协议机制到大数据量场景的工程实践

ES6迭代器与生成器:从协议机制到大数据量场景的工程实践 ES6里我最开始最忽略的两个东西一个是迭代器Iterator一个是生成器Generator。不是看不懂语法而是想不明白“学了能干嘛”。数组有forEach、有map、有filter为什么非要再造一套for...of还要搞一个Symbol.iterator藏在对象里面这个疑问一直到我做一版批量数据处理功能时才解开数据量大到一定程度一次性把所有结果算完放进数组内存根本扛不住这时候你需要的不是一个“转换函数”而是一个“下次要什么才算什么”的取数器。这篇文章就围绕迭代器与生成器的协议机制、运行原理、常见使用场景和我实际踩过的坑展开适合刚学完 ES6 基础语法、但不知道往哪用的前端也适合写脚本处理大批量数据的同学参考。1. for...of 背后不是循环而是一套协议1.1 传统遍历方式的“隐性前提”很多人觉得for...of就是for循环的语法糖这可能是最大的误解。for循环能跑起来其实隐含了两个前提数据有length你能用下标访问。比如数组arr.length、arr[i]但“长度”和“下标”并不是所有集合类型都具备的属性。字符串虽然可以用索引但Set和Map这种结构就没有set[0]的访问方式DOM 里的NodeList虽然有length但你不能直接nodeList[0]其实可以但如果不是类数组结构处理起来就非常别扭。更麻烦的是有些数据结构根本不关心“第几个元素”这个概念比如Set只关心“是否存在”Map只关心“键值对”。以前为了遍历Set要么先转成数组要么手动维护一个数组记录。ES6 的迭代器协议给了统一答案任何数据结构只要你说清楚“怎么依次拿元素”for...of就能遍历。你不用关心它内部是数组、链表、树还是流式数据。1.2 可迭代协议与迭代器协议迭代器不是某一个对象而是一组规则的集合一共两层协议可迭代协议对象实现了Symbol.iterator方法这个方法返回一个迭代器对象。迭代器协议迭代器对象必须有next()方法next()返回{ value, done }其中done为true表示遍历结束。可以把迭代器理解成一个“发牌员”你喊一次next()他才发一张牌你不喊他永远不发下一张。举例来说数组默认就是可迭代的const arr [10, 20, 30]; const it arr[Symbol.iterator](); console.log(it.next()); // { value: 10, done: false } console.log(it.next()); // { value: 20, done: false } console.log(it.next()); // { value: 30, done: false } console.log(it.next()); // { value: undefined, done: true }for...of做的事情本质就是反复调用next()直到done变成true。所以数组能for...of并不是语法规定而是数组实现了Symbol.iterator。字符串、Set、Map、arguments、NodeList等也都实现了这个协议。1.3 Symbol.iterator协议挂在哪个位置为什么偏偏用Symbol.iterator而不是普通方法名iterator因为Symbol是 ES6 新增的基本类型能够保证属性名不会和业务字段冲突。你想想如果协议规定叫iterator那你的对象里正好有一个名为iterator的业务字段整个协议就废了。用Symbol.iterator就不会有这样的问题。自定义一个可迭代对象也不复杂。比如我想让一个普通对象能被for...of遍历它内部的数据const obj { data: [1, 2, 3], [Symbol.iterator]() { let index 0; const data this.data; return { next() { if (index data.length) { return { value: data[index], done: false }; } return { value: undefined, done: true }; } }; } }; for (const item of obj) { console.log(item); // 1 2 3 }这个例子里obj本身不是数组但通过实现Symbol.iterator它就成了可迭代对象。这就是协议的价值它不规定你是哪种结构只规定你如何“吐出”元素。2. 生成器用函数来“画出”迭代器2.1 生成器函数不会立即执行手写迭代器对象实在太啰嗦了所以 ES6 又提供了生成器函数用来“批量生产”迭代器。生成器函数的写法是在function后面加一个星号function* count() { console.log(开始); yield 1; console.log(中间); yield 2; console.log(结束); }调用count()时函数体不会立即执行而是返回一个生成器对象。只有调用next()函数体才会运行到下一个yield处暂停const g count(); console.log(还没执行); // 这行会先打印 g.next(); // 打印“开始”返回 { value: 1, done: false } g.next(); // 打印“中间”返回 { value: 2, done: false } g.next(); // 打印“结束”返回 { value: undefined, done: true }我习惯把yield看成“代码里的暂停标记”。每次next()走一段遇到yield就停下来把yield后面的值交给外部。等到下一次next()再从暂停的地方继续往下走。生成器对象本身既是可迭代对象也是迭代器所以它能直接被for...of消费。2.2 next() 传参外部向生成器内部注入数据yield不只是向外抛出值还可以接收外部传入的值。这个机制第一次看很容易晕但理清一次就通透了。看例子function* interview() { const name yield 请介绍自己; const age yield 你好 ${name}年龄是; return ${name} ${age}岁; } const g interview(); console.log(g.next().value); // 请介绍自己 console.log(g.next(张三).value); // 你好 张三年龄是 console.log(g.next(28)); // { value: 张三 28岁, done: true }这里的运行逻辑是第一个yield的返回值请介绍自己被抛给外部但整个yield表达式的结果还没确定第二次调用next(张三)时张三会成为上一个yield表达式的结果所以赋值给name。然后函数继续执行到第二个yield把你好 张三年龄是抛出去第三次next(28)再让28成为第二个yield的结果赋值给age最后return出去。一个经常考的细节是第一次next()传参是无效的因为当时还没有正在暂停的yield在等参数。如果你需要在生成器启动时就注入初始值建议在生成器外部憋一个状态变量或者多写一个yield作为“启动信号”。2.3 yield*把执行权委托给另一个迭代器yield*后面跟一个可迭代对象生成器会转而遍历这个对象并把遍历到的值逐个yield出去。组合两个生成器时尤其好用function* inner() { yield a; yield b; } function* outer() { yield 0; yield* inner(); yield 1; } console.log([...outer()]); // [0, a, b, 1]如果没有yield*而是直接写yield inner()那只会把生成器对象作为一个值抛出去。有了yield*你可以把多个小生成器组合成一个大生成器在遍历树、分页加载、组合多个数据源时非常顺手。3. 从分批加载到深拷贝四个值得落地的场景3.1 无限序列与惰性计算“无限序列”听起来很玄但生成器做起来非常自然。比如斐波那契数列用数组你根本没法直接构造一个无限长的数组但在生成器里一个while (true)就搞定了function* fibonacci() { let a 0; let b 1; while (true) { yield a; [a, b] [b, a b]; } }你可能会担心死循环。其实不会因为yield会暂停执行。没有next()这个生成器就一直停在原地。取前 10 个数const fib fibonacci(); for (let i 0; i 10; i) { console.log(fib.next().value); }这就是惰性求值的意义不是一次性算完所有结果而是“要一个算一个”。面对真正无限或超大规模的数据源这是数组无法替代的。3.2 大数据量分批读取与转换我实际遇到的一个场景是接口一次性返回上万条记录前端拿到后还要做字段映射和过滤直接mapfilter会产生多个中间数组内存占用瞬间涨上去。后来改成用生成器分批读取处理完一批再拿下一批function* paginate(loadRows, pageSize 500) { let page 1; while (true) { const rows loadRows(page, pageSize); if (rows.length 0) return; yield rows; page 1; } }调用方可以这样消费let total 0; for (const batch of paginate(fetchPage)) { for (const row of batch) { total process(row); } }paginate内部不会把几百页数据全部装进数组每次只保留一页数据。如果配合后面要讲的take、map、filter生成器还能做到“边取边转边筛”整个过程不产生完整中间数组。3.3 树形结构遍历遍历树形结构最常见的是递归函数。递归本身没问题但如果你希望调用方能够“随时跳出遍历”生成器会灵活很多。比如做一个深度优先遍历function* walkTree(node) { if (!node) return; yield node; if (Array.isArray(node.children)) { for (const child of node.children) { yield* walkTree(child); } } }使用方只需要for (const node of walkTree(rootNode)) { if (node.type target) break; // 找到目标后直接跳出 }在这个模型里遍历逻辑是生成器负责的外部控制权却保留在使用方。你不需要设计一个shouldStop回调也不需要维护一个显式的栈yield*天然帮你做了递归展开。3.4 深拷贝里被忽视的迭代协议说到“深拷贝”很多人第一反应是JSON.parse(JSON.stringify(obj))但遇到Date、Map、Set、循环引用就全废了。手写深拷贝时数组、Set、Map通常是三个独立分支遍历方式各不相同。其实它们都实现了Symbol.iterator可以用for...of统一处理“元素怎么取”的问题数组和Set迭代出来的是单个值Map迭代出来的是[key, value]。基于这个特性深拷贝可以写成下面这样。我加了一点循环引用的处理用WeakMap做缓存function deepCopy(value, seen new WeakMap()) { if (value null || typeof value ! object) return value; if (seen.has(value)) return seen.get(value); let copy; if (Array.isArray(value)) { copy []; } else if (value instanceof Map) { copy new Map(); } else if (value instanceof Set) { copy new Set(); } else if (value instanceof Date) { copy new Date(value); } else { copy Object.create(Object.getPrototypeOf(value)); } seen.set(value, copy); if (value instanceof Map) { for (const [key, val] of value) { copy.set(deepCopy(key, seen), deepCopy(val, seen)); } } else if (value instanceof Set) { for (const item of value) { copy.add(deepCopy(item, seen)); } } else if (Array.isArray(value)) { for (const item of value) { copy.push(deepCopy(item, seen)); } } else { for (const key of Object.keys(value)) { copy[key] deepCopy(value[key], seen); } } return copy; }这里最让我觉得顺手的地方是数组和Set在遍历思路上共用了for...of代码看起来非常一致。深拷贝是一个经典场景但它也印证了迭代协议的核心价值统一不同数据结构的遍历方式。4. 封装一套自己的迭代工具函数4.1 用生成器实现 take / map / filter数组自带map、filter但它们是立即执行的会创建完整的新数组。如果我要处理的是无限序列或超大数据流就必须用惰性版本。这里我封装三个最常用的生成器工具function* take(iterable, limit) { let count 0; for (const value of iterable) { if (count limit) return; yield value; count 1; } } function* map(iterable, fn) { for (const value of iterable) { yield fn(value); } } function* filter(iterable, fn) { for (const value of iterable) { if (fn(value)) yield value; } }组合起来就能对无限斐波那契数列做“取前 5 个满足条件的结果”const result [ ...take( filter( map(fibonacci(), (n) n ** 2), (n) n % 3 0 ), 5 ) ];这里最关键的思维是顺序take必须在最外层兜底它负责限制最终产出的数量。如果先展开无限序列再filter浏览器直接卡死如果把take放在filter后面又能少计算不少无效值。这种“管道式”的写法正是生成器的用途——延迟计算、按需取用。4.2 给普通对象加上可迭代能力有些业务对象你希望它看起来像一个“可以遍历的数据源”。与其在外部写一堆for (let i 0; i obj.length; i)不如直接给它挂一个Symbol.iterator。比如做一个简单的rangefunction range(start, end, step 1) { return { [Symbol.iterator]: function* () { let current start; while (current end) { yield current; current step; } } }; } console.log([...range(1, 5)]); // [1, 2, 3, 4, 5]这里的range返回的是一个普通对象但内部用生成器函数实现了迭代协议。调用方完全不需要关心start、end怎么组合直接展开或for...of就行。这种封装特别适合给配置项、路由表、状态列表这类静态数据使用。4.3 Array.from 和展开运算符怎么选[...iterable]和Array.from(iterable)都能把可迭代对象转成数组但它们不是完全等价[...iterable]只能处理可迭代对象不能处理类数组Array.from同时支持可迭代对象和类数组Array.from还能接收第二个参数作为mapFn边遍历边转换少产生一个中间数组。const arrLike { 0: a, 1: b, length: 2 }; console.log(Array.from(arrLike)); // [a, b] // console.log([...arrLike]); // TypeError: arrLike is not iterable const items new Set([1, 2, 3]); console.log(Array.from(items, (x) x * 10)); // [10, 20, 30]另外要非常注意这两者都会把整个可迭代对象“取完”。如果遇到无限生成器比如[...fibonacci()]或Array.from(fibonacci())程序会直接卡死。正确的做法是先takeconsole.log(Array.from(take(fibonacci(), 10))); // 安全4.4 用生成器做一个简单状态机生成器天生适合表示“按顺序流转”的状态机尤其适合订单、审批这类线性流程。我写过一个小例子function* orderWorkflow() { yield 待支付; const confirmed yield 已支付等待确认; yield confirmed ? 已确认 : 未确认; yield 完成; } const wf orderWorkflow(); console.log(wf.next().value); // 待支付 console.log(wf.next().value); // 已支付等待确认 console.log(wf.next(true).value); // 已确认 console.log(wf.next().value); // 完成每次next()推进一个状态外部通过传参决定下一步跳转方向。这其实就是一种协程函数可以暂停也可以从外部接收数据。虽然复杂状态机还是要靠专门的状态机库但用它做简单流程控制结构非常清晰。5. 这些迭代器相关的坑我至少碰到过三次5.1 迭代器是一次性的重复消费才明白的事迭代器是有状态的一旦被消费完就不会重新回到起点。这个坑我最早在写数据转换时踩过const arr [1, 2, 3]; const it arr[Symbol.iterator](); console.log([...it]); // [1, 2, 3] console.log([...it]); // []arr本身可以反复遍历是因为每次arr[Symbol.iterator]()都会返回一个新的迭代器。但同一个迭代器对象只能从头走到尾一次。如果你在代码里把迭代器存下来期望下次还能用结果必然是拿到空数组。需要重复消费数据时不要缓存迭代器要缓存原始的可迭代对象或者每次重新调用生成器函数。5.2 对生成器用 for...of 也会“耗尽”它很多人以为for...of只是读了一遍读完还能继续next()其实不是function* twoValues() { yield a; yield b; } const g twoValues(); for (const value of g) { console.log(value); // a b } console.log(g.next()); // { value: undefined, done: true }for...of会一直消费迭代器直到done为true所以循环结束后生成器对象也“耗尽”了。如果你想把同一个生成器跑两遍唯一的办法是重新调用生成器函数创建一个新的实例。5.3 提前终止时finally 会执行在for...of里用break退出很多人以为不会触发什么。实际上如果生成器内部有try/finallyfinally一定会执行。这个机制可以用来做资源清理function* resource() { try { yield 1; yield 2; } finally { console.log(清理连接); } } for (const value of resource()) { if (value 1) break; } // 输出清理连接如果你正在用生成器管理文件流、数据库连接或定时器记得把清理逻辑放在finally里。这样无论正常结束还是被外部break、return、throw都能保证释放资源。5.4 无限迭代器直接展开等于浏览器卡死这是一个非常容易手滑的操作。一旦写了[...forever()]或Array.from(forever())程序就会永远循环下去。别在浏览器控制台试页面会直接失去响应function* forever() { while (true) yield 1; } // [...forever()]; // 千万别试 // Array.from(forever()); // 千万别试正确做法是先take限制数量console.log([...take(forever(), 3)]); // [1, 1, 1]如果你的数据来自不确定的分页接口也建议在消费端加一个最大页数限制免得某个接口异常时生成器一直读下去。5.5 return 值与 for...of 的“分道扬镳”生成器函数里的return值不会出现在for...of的结果里但可以通过手动next()拿到function* finalValue() { yield 1; return 99; } const g finalValue(); console.log(g.next().value); // 1 console.log(g.next().value); // 99此时 done: true console.log([...finalValue()]); // [1]所以如果你希望生成器在处理完数据后返回一个统计结果比如总条数、汇总值用for...of是拿不到的。这时候可以手动循环next()或者改用yield*的委托返回值function* inner() { yield 1; return inner result; } function* outer() { const result yield* inner(); yield result; } console.log([...outer()]); // [1, inner result]我自己在项目里用生成器处理过几十万行日志也用它写过分批同步任务。有一点体会特别深以前习惯把所有数据先放进数组再链式调用map、filter看起来高端但代价是中间数组一个接一个。迭代器和生成器给了一条完全不同的路数据不是“算完堆在那里”而是“用一点、取一点”。如果你手上正好有一段处理大列表的代码先别急着把map和filter链式写满一行不妨问一句这个中间结果真的需要全部存下来吗迭代器和生成器给出的答案是不一定需要。这个思维转变比记住next()的返回值更重要。
返回列表