有一类代码,写的时候觉得挺自然,回头看一眼总觉得不对劲。比如从接口拉一批用户,筛出活跃的,取前 50 个名字:
async function topActiveNames(baseUrl) {
const all = [];
let cursor = null;
do {
const url = new URL(baseUrl);
if (cursor) url.searchParams.set('cursor', cursor);
const page = await (await fetch(url)).json();
all.push(...page.items);
cursor = page.nextCursor;
} while (cursor);
return all.filter(u => u.active).map(u => u.name).slice(0, 50);
}
能跑,但有两个可以较真的地方。第一,只要 50 个名字,却把所有分页全拉完了——如果这个接口有一万条数据,那 99.5% 的网络请求和内存都是白花的。第二,「先收集、再过滤、再切片」这三步各自都产生了一整个中间数组。
要修的话,传统写法是手动维护一个计数器,在循环里判断、提前 return。逻辑能对,但循环体里塞进了三件不相干的事:分页、筛选、截断。揉在一起之后,想改「不要名字要邮箱」都得重新读一遍循环。
迭代器助手(Iterator Helpers)给了另一条路。它把数组那套 map / filter / slice 搬到了迭代器上,而且是惰性的。迭代器在语言里存在很久了,但一直只能靠 for...of 或者展开语法来消费,中间加工全靠手写循环。现在这条链子终于补齐了。
一、先把三个词分清楚:可迭代对象、迭代器、生成器
这三个词经常被混着说,但要讲清楚迭代器助手,边界必须先划出来。
可迭代对象(iterable)的特征是有一个 Symbol.iterator 方法。数组、字符串、Map、Set、NodeList 都是。
迭代器(iterator)是 Symbol.iterator 返回的那个东西,它有一个 next() 方法,每次调用返回 { value, done }。迭代器本身也应该带 Symbol.iterator 并且返回自己,这样它才能被 for...of 直接吃。
生成器(generator)是用 function* 写的函数,调用它得到一个生成器对象,这个对象既是迭代器也是可迭代对象。
迭代器助手挂在的是 Iterator.prototype 上。也就是说,只有迭代器能用这些方法,数组不能用。数组有自己那套 Array.prototype.map,两者是不同的东西,行为也不一样。
const arr = [1, 2, 3];
arr.map(x => x * 2); // 数组的 map,立刻求值,返回新数组
const it = [1, 2, 3].values();
it.map(x => x * 2); // 迭代器的 map,惰性,返回迭代器
而生成器对象天然带这些方法,因为它就在 Iterator.prototype 的原型链上。这是后面所有例子的基础。
二、惰性到底值不值钱
先从最直观的对比开始。假设有一个百万长度的数组,要取前 5 个偶数的平方:
const bigArray = Array.from({ length: 1_000_000 }, (_, i) => i);
// 数组写法:三次完整遍历,两个中间数组
const viaArray = bigArray
.filter(n => n % 2 === 0) // 走一百万次,产出 50 万个
.map(n => n * n) // 走五十万次,产出 50 万个
.slice(0, 5); // 从 50 万里切 5 个
// 迭代器写法:一次遍历,零中间数组
const viaIterator = Iterator.from(bigArray)
.filter(n => n % 2 === 0)
.map(n => n * n)
.take(5)
.toArray();
结果是同一个 [0, 4, 16, 36, 64]。但工作量差得远。
数组那版:filter 老老实实走完一百万个元素,map 老实走完五十万个,slice 再从五十万的结果里取五个。中间两个数组都是完整的、真实分配的。
迭代器那版:take(5) 拿到第 5 个结果之后就告诉上游「够了」,整个链条停止。filter 和 map 加起来只跑了十来个元素。而 filter 和 map 本身没有分配任何数组——它们只是把你的函数包起来,等着被拉取。
这就是「惰性求值」和「及早求值」的区别。数组的方法必须返回一个完整的数组,所以它必须把活干完。迭代器的助手返回的是一个「还什么都没做」的迭代器,真正的计算在你消费它的时候,一个元素一个元素地被拉出来。
这里有个非常容易搞混的点,也是我第一次用的时候踩到的。
function* source() {
for (let i = 0; i < 5; i++) {
console.log('produce', i);
yield i;
}
}
const pipeline = source()
.map(n => n * 10)
.filter(n => n > 10);
console.log('pipeline built');
// 此时只打印了 'pipeline built',一行 'produce' 都没有
const result = pipeline.toArray();
// 到这里才一次性打印 produce 0 .. produce 4
构造管道的语句不执行任何东西。所有副作用——包括生成器函数体的执行、日志打印、网络请求——都推迟到消费的那一刻。这意味着你没法靠「打断点看哪一行没执行」来判断管道对不对,得把它消费掉。
三、案例一:无限序列
惰性的直接好处是,你可以写出「长度无限」的迭代器而不会炸。数组永远做不到这一点,因为数组必须被完整创建出来。
function* naturals() {
let n = 0;
while (true) yield n++;
}
const firstTenSquares = naturals()
.filter(n => n % 3 !== 0)
.map(n => n * n)
.take(10)
.toArray();
// [1, 4, 16, 25, 49, 64, 100, 121, 169, 196]
naturals() 里是个 while (true),看起来挺吓人,但 take(10) 拿到 10 个之后就不再往下拉了,生成器停在 yield 那一行,从此不再前进。它不会被回收,但也绝不会继续跑。
实际项目里更有用的例子是一个不重复的 ID 生成器:
function* sessionIdGenerator(prefix) {
let seq = 0;
while (true) {
yield `${prefix}-${(++seq).toString(36).padStart(4, '0')}`;
}
}
const gen = sessionIdGenerator('s');
gen.next().value; // 's-0001'
gen.next().value; // 's-0002'
这样 ID 是按需生成的,不需要预先算好一批放在数组里。如果程序提前结束,剩下的 ID 从来没被「生产」过——虽然它们本来也不占资源,但思路上的区别是重要的:你写的是规则,而不是一组数据。
四、案例二:一条链子跑完的异步分页
回到开头那个问题。迭代器助手在异步这一侧也有一整套对应的方法,挂在 AsyncIterator.prototype 上:map、filter、take、drop、flatMap、reduce、forEach、toArray、some、every、find。
先把分页逻辑单独写成一个异步生成器:
async function* fetchUsers(baseUrl) {
let cursor = null;
while (true) {
const url = new URL(baseUrl);
if (cursor) url.searchParams.set('cursor', cursor);
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status} ${url}`);
const page = await res.json();
yield* page.items; // 展开这一页的元素
if (!page.nextCursor) return;
cursor = page.nextCursor;
}
}
几个写法上的注意点。第一,yield* 在异步生成器里可以直接展开数组或者同步可迭代对象,你不需要写 for (const item of page.items) yield item。第二,那个 return 结束的是整个生成器,不是当前这一页。第三,异常直接 throw 出去,会传播到消费这一侧的 try/catch,不用在生成器里处理。
然后,整条业务逻辑变成一行链子:
const names = await fetchUsers('/api/users')
.filter(u => u.active)
.map(u => u.name)
.take(50)
.toArray();
和开头那段手写的循环放在一起看,改动其实就两点。
语义变清楚了。「按页拉用户」「只要活跃的」「要名字」「五十个」。四件事各占一行,谁也不用读循环体去猜。
网络请求变少了。这条链子只会拉到凑够 50 个名字为止的分页数。take(50) 一满足,整个异步管道就不再向上游索取,fetchUsers 生成器里的 while 循环不会再进入下一次迭代,fetch 也就不会发出。这跟数组那版「全拉完再筛」相比,可能省掉几十次 HTTP 往返。
还有一点经常被忽略:提前终止会触发清理。当 take 提前结束,或者你在 for await...of 里 break 出来,迭代器的 return() 会被调用,生成器函数里 finally 块会执行:
async function* watchQueue(client) {
const sub = await client.subscribe('jobs');
try {
for await (const msg of sub) yield msg;
} finally {
await sub.unsubscribe(); // 提前 break 也会走到这里
}
}
// 用的时候不必手动清理
for await (const job of watchQueue(client)) {
if (job.priority === 'high') break;
// break 之后 unsubscribe 已经被调用了
}
这个「结构化清理」的保证,是手动维护游标或者用回调风格的 API 很难拿到的。写自定义迭代器的时候,把资源释放放进 finally,可以省掉调用方一大块样板代码。
五、案例三:把 ReadableStream 和迭代器接起来
Web 平台上还有一个接口能让这两套东西自然对接:ReadableStream.prototype.values()。它返回一个异步迭代器,于是 fetch 的响应体、文件读取流、SSE 通道,全都可以直接用异步迭代器助手处理。
下面是从一个流式接口里读 NDJSON(每行一个 JSON 对象),筛出耗时超过 1 秒的请求:
async function* readNdjson(stream) {
const decoder = new TextDecoder();
let buffer = '';
for await (const chunk of stream) {
buffer += decoder.decode(chunk, { stream: true });
let idx;
while ((idx = buffer.indexOf('n')) !== -1) {
const line = buffer.slice(0, idx).trim();
buffer = buffer.slice(idx + 1);
if (line) yield JSON.parse(line);
}
}
const tail = (buffer + decoder.decode()).trim();
if (tail) yield JSON.parse(tail);
}
const slow = await fetch('/api/logs/stream')
.then(res => res.body)
.then(body => readNdjson(body))
.then(records => records
.filter(r => r.durationMs > 1000)
.map(r => ({ path: r.path, ms: r.durationMs }))
.take(20)
.toArray());
这段代码里有几个值得停下来看的地方。
那个 buffer 是必须的。流给你的 chunk 是任意切分的,一个 JSON 对象可能被劈成两半,也可能一个 chunk 里装了三个完整的对象加半个。所以要在缓冲区里找换行符,找到一行就发一行,剩下的留着。用 decoder.decode(chunk, { stream: true }) 而不是直接 decode(chunk),是为了让编码器把跨 chunk 的多字节字符(比如中文)拼回来,否则会出现「一个汉字被劈开变成两个乱码」。这个坑我在处理中文日志的时候连着踩过两次。
循环内部的 while 不是多余的。一个 chunk 里可能有多行,所以找到一行发一行之后,还得继续在这段缓冲区里找。如果只 if 一次,就会丢掉同 chunk 里的其他行,表现为「日志少了三分之二」。
take(20) 之后流会被取消。这一点很妙。凑够 20 条之后,toArray 停止拉取,异步迭代器被终止,readNdjson 里的 for await 被 return() 打断,而它迭代的是 ReadableStream 的迭代器——这时底层响应体会被 cancel,TCP 连接释放。不需要 AbortController,不需要手动 reader.cancel()。惰性在这里不只是省 CPU,还省了网络和连接。
六、几个一定会撞上的坑
1. 迭代器只能消费一次
这是最要命的一条。数组可以随便遍历,迭代器不行。
const it = [1, 2, 3].values();
it.map(x => x * 2).toArray(); // [2, 4, 6]
it.map(x => x * 2).toArray(); // [] —— 空的!
第二次是空的,因为迭代器已经走到头了。done 一旦为 true,就永远为 true,没有 rewind,也没有 reset。数组那种「同一个数据源反复遍历」的直觉在这里不成立。
要复用,就在源头重新创建:
const makeSource = () => [1, 2, 3].values();
makeSource().map(x => x * 2).toArray();
makeSource().map(x => x * 2).toArray();
或者干脆一开始就用数组存着,需要遍历的时候各取各的迭代器。如果一段代码需要「先遍历一遍统计,再遍历一遍处理」,那就别用迭代器,老老实实 Array.from 存下来。
2. 惰性意味着不执行,也意味着异常延后
const pipeline = Iterator.from(data)
.map(parseItem) // parseItem 可能抛异常
.filter(isValid);
// 这里不会抛,因为什么都还没跑
console.log('built ok');
// 异常会在这里抛出来
const result = pipeline.toArray();
用 try/catch 的时候要把 消费语句 包进去,包在构造管道那一行是没用的。反过来,如果你写的是「先 try 构造、再 try 消费」,那就白包一层了。
3. 同步和异步不能混着链
async function* asyncSource() { /* ... */ }
const result = asyncSource()
.map(x => x * 2) // 这是 AsyncIterator.prototype.map,返回 Promise 链
.filter(x => x > 10) // OK,也是异步版
.toArray(); // 返回 Promise
// 必须 await
const items = await result;
异步迭代器上的 map 返回的也是异步迭代器,回调可以返回 Promise(会被自动 await),也可以返回普通值。但如果反过来——把同步迭代器塞进异步链里——是不行的。要转换,得包一层:
async function* toAsync(syncIterable) {
yield* syncIterable;
}
反过来把异步迭代器转成同步的,没有任何办法。异步就是异步的,不可能在不阻塞线程的前提下变成同步。
4. 别在 toArray 之前偷看结果
经常看到这种写法:
const pipeline = Iterator.from(data)
.filter(isValid)
.map(transform);
console.log(pipeline.length); // undefined,而且不会有任何输出
迭代器没有 length,也不知道自己还剩多少个元素。它是「一个一个拉」的模型,没有「总数」这个概念。naturals().take(10) 里的 10 是截断条件,不是长度——真正的元素个数只有拉完才知道。想要数组,就只能拉完,也就是 toArray()。
5. 大数组的场景下不一定划算
如果数据本来就是个百万长度的数组,那么「先 Iterator.from 再用助手」和「直接数组方法」的区别只在中间数组上。数组那版多分配两个数组,但如果你的最终结果也是完整数组(没有 take),那总量其实差不多。
真正拉开差距的是两种情况:一是有提前终止(take、find、some),二是数据源本身就是「按需产生」的(生成器、流、分页接口)。在这两种之外,为了用而用,收益很有限。
6. Iterator.from 是做什么的
简单说,它把「任何可迭代的东西」变成一个带助手方法的迭代器。因为字符串、Map、Set 的 Symbol.iterator 返回的迭代器本来也带这些方法,所以它最常见的用途是处理那些自定义的可迭代对象,以及——一个挺实用的场景——临时把一个可迭代对象包装起来。
const m = new Map([['a', 1], ['b', 2]]);
const pairs = Iterator.from(m)
.map(([k, v]) => `${k}=${v}`)
.toArray();
// ['a=1', 'b=2']
不用 Iterator.from 也可以写 m.entries().map(...),效果一样。它的价值在于统一:如果一段代码接受的输入可能是数组、可能是 Set、可能是生成器,用 Iterator.from(input) 包一层,后面就只认一种东西了,不用再写分支判断。
七、什么时候别用它
需要随机访问的时候。迭代器只能顺序拉,it[5] 这种是不存在的。要索引、要排序、要反复回看,直接用数组。
需要多次遍历的时候。前面说过了。二次遍历的成本比一开始就存数组高。
数据量很小的时候。十个元素,两种写法的性能差异完全在噪声范围内,那就选可读性更好的那个。通常数组方法的写法更眼熟,团队里所有人都能一眼看懂。
需要和其他库互操作的时候。很多库的 API 标注是 Array 而不是 Iterable。虽然大部分时候传迭代器进去也能用(因为很多库内部会展开它),但不保证。遇到接口不匹配,还得 Array.from 转一下,那偷懒的意义就不大了。
至于环境支持,同步的迭代器助手在 ES2025 已经定稿,Node 22 及以上、以及主流浏览器的新版本都已经可以直接用,不需要 polyfill。异步迭代器助手进入实现的时间稍早一些。稳妥起见,动手之前在你的目标环境里跑一句 typeof [].values().map 确认一下,比翻文档快。
八、写在最后
迭代器这套东西在 JS 里存在很久了,但一直处于「语言有、工具没有」的尴尬状态。for...of 让你能遍历它,展开语法让你能把它变成数组,但中间那一步——筛选、变换、截断——只能手写循环。所以现实里大家绕一圈还是回到数组上:Array.from(something) 然后再用数组方法。绕了一圈,惰性丢了,提前终止也丢了。
助手补上之后,写法上最大的变化是:你可以先描述「要什么」,再决定「要多少」。分页那一段尤其明显。过去要把「拉多少页」和「要多少条」揉在同一个循环里,现在它们各自独立地写在自己的位置上,中间靠 take(50) 连起来。多出来的不只是网络请求,还有改需求时的从容——把 50 改成 200,改一个数字就行。
如果手上正好有一段「边循环边筛边计数」的胶水代码,可以拿它当第一个试点。先剥出数据源那一层,把它改成一个生成器;再在上面接助手方法,把循环体里揉着的那几件事一行行摊开。改完对着看一遍,通常会比原来短,而且更清楚哪一行在做什么。

