先说一个经常被忽略的成本:数组方法链看起来只是”写得清爽”,但它每一环都在造一个新数组。rows.filter(...).map(...).slice(0, 20),哪怕你最后只要二十条,filter 也会把五万行整个过一遍,产出一个几万条的新数组,map 再把那个数组整个映射一遍。数据量小的时候无所谓,上了万级就开始肉疼。
迭代器辅助方法(Iterator Helpers)把这条链换了个底座:它不在数组上做,在迭代器上做,每一步都是惰性的——没有下一个值被需要,就不会有多余的计算。这篇文章拿一个真实需求走一遍,顺便把几个用下去才会碰到的问题说清楚。
一、需求:导入 CSV 后给用户看 20 条预览
用户上传一份订单 CSV,页面要立刻展示”前 20 条有效记录”的预览。文件内容已经以字符串形式拿到(FileReader 读出来的),可能有五万行。
要求有几条,都很现实:
- 跳过表头
- 跳过空行,跳过字段数不对的残行
- 金额字段要转成数字,转不了的整行丢掉
- 只取 20 条,剩下的不要解析
传统写法大概是:text.split('n') 先造一个五万元素的数组,然后一路 slice、filter、map。光建数组这一步就吃掉不少内存,而且后面对每一行做 split(',') 的开销全都付了——即使你只要 20 条。
二、第一步:生成器逐行产出,不建中间数组
用生成器把”逐行切分”这件事变成一次只产出一行:
function* lines(text, separator = 'n') {
let start = 0;
while (start <= text.length) {
const end = text.indexOf(separator, start);
if (end === -1) {
yield text.slice(start);
return;
}
yield text.slice(start, end);
start = end + separator.length;
}
}
function* parsedRows(text) {
let isFirst = true;
for (const line of lines(text)) {
const trimmed = line.trim();
if (!trimmed) continue;
const cells = trimmed.split(',');
if (isFirst) {
isFirst = false;
continue; // 跳过表头
}
if (cells.length !== 4) continue;
yield cells;
}
}
注意 parsedRows 里的 for...of:它在消费 lines() 这个生成器,而 parsedRows 自己也是生成器。整个过程的推进靠的是外层的”要下一个值”,内层配合着往下走。没有任何一个时刻,五万行会同时存在于内存里。
这一步已经能做不少事了。但真要让”转数字、过滤、取前 20″这几件事组合起来,手工循环写起来会越来越啰嗦。这时候就该迭代器辅助方法上场。
三、把 for 换成链式调用
迭代器辅助方法和数组方法的用法几乎一样,区别在于你得先拿到一个迭代器。三种常见入口:
// 1. 已经是迭代器:Set、Map 的 values(),或者生成器调用结果
const it1 = new Set([1, 2, 3]).values();
it1.map((n) => n * 2).toArray(); // [2, 4, 6]
// 2. 数组:.values() 拿到数组迭代器
const it2 = [1, 2, 3].values();
it2.filter((n) => n > 1).toArray(); // [2, 3]
// 3. 任意可迭代对象:Iterator.from() 包一层
const it3 = Iterator.from([1, 2, 3]);
关键在于返回值的类型。这些方法返回的还是迭代器,不是数组。map 之后还能接 filter、take、drop、flatMap,直到你调 toArray()、reduce()、forEach() 或者进 for...of,计算才开始真的发生。
把 CSV 那段接上:
const PREVIEW_LIMIT = 20;
function buildPreview(text) {
return Iterator.from(parsedRows(text))
.map(([id, name, amount, status]) => ({
id,
name,
amount: Number(amount),
status,
}))
.filter((row) => Number.isFinite(row.amount) && row.amount > 0)
.take(PREVIEW_LIMIT)
.toArray();
}
这段代码真正执行时发生了什么,值得一步步跟一遍:
toArray()开始向take要第一个值。take向filter要值,filter向map要值,map向parsedRows要值。parsedRows从字符串里切出一行,解析完交出去。- 如果这一行被
filter干掉了,就回到第 2 步,再要一个。 - 攒够 20 个,
take停下,立刻结束。
也就是说,如果前 200 行里就凑出了 20 条合法记录,后面四万九千八百行从头到尾没有被触碰过——没有切分,没有 split,连字符都没读。这是惰性管道最实在的收益,不是”代码看起来优雅”,是实打实少干活。
四、take 停下来之后,生成器的 finally 会不会跑
这个问题的答案直接决定你能不能在生成器里写清理逻辑。take 取够数量后,会对底层迭代器做一次关闭(IteratorClose,等同于调用它的 return())。效果和 for...of 里 break 是一样的:生成器里的 finally 块会执行。
function* withCleanup(source) {
try {
for (const item of source) {
yield item;
}
} finally {
console.log('清理:这里会执行');
}
}
Iterator.from(withCleanup([1, 2, 3, 4, 5]))
.take(2)
.toArray();
// 输出两次 yield,然后打印 "清理:这里会执行"
这就意味着你可以放心地在生成器里开数据库游标、开文件句柄、加锁,用 finally 负责释放。take 提前收工不会把资源漏掉。
但反过来有个容易忽略的点:如果你自己手写 for...of 加 break,同样会触发 return();可如果你手动调 it.next() 然后就不管了,什么都不会发生。 用辅助方法时这点不用操心,但混着写代码就要注意。
五、坑一:迭代器是一次性的
迭代器用完就空了。下面这段代码第二次 toArray() 拿到的是空数组,不是报错,是静默返回 []:
const it = [1, 2, 3].values().map((n) => n * 10);
console.log(it.toArray()); // [10, 20, 30]
console.log(it.toArray()); // [] —— 不会报错,就是空的
这个行为和”数组方法每次返回新数组”的直觉差别很大。在调试或写组件时,如果发现第二次渲染列表空了,先回来看这个 it 是不是在首次渲染时就被耗尽了。
需要重复消费的场景,把迭代器的创建包进函数里,每次调用重新生成:
const makePipeline = () =>
Iterator.from(parsedRows(rawText))
.map(toRow)
.filter(isValid);
// 想读几遍就读几遍,每次都是新的迭代器
const first = makePipeline().take(20).toArray();
const all = makePipeline().toArray();
顺带一提,生成器函数(function*)天然就是这种”调用一次产出一个新迭代器”的形状,所以把逻辑封成生成器,比把迭代器存成变量安全。
六、坑二:链子只有单向的
迭代器辅助方法提供的是:map、filter、flatMap、take、drop、takeWhile、dropWhile、reduce、forEach、some、every、find、toArray。注意缺了什么:
- 没有
reverse、sort、slice、at、length。迭代器不知道总共有多少元素,也不知道末尾在哪,这些操作本质上需要全量数据或随机访问。 - 没有索引参数。
filter((x, i) => ...)里的第二个参数用不了。map的回调只收到当前值。 - 没有
reduceRight,同理。
需要索引的话,在链子上游用 zip 自己造一个,或者干脆用 Iterator.range 配 map——后者是比较新的静态方法,用之前查一下目标环境是否已支持。保守做法是拿一个手写的计数生成器:
function* withIndex(iterable) {
let i = 0;
for (const value of iterable) {
yield [i++, value];
}
}
Iterator.from(withIndex(['a', 'b', 'c']))
.filter(([i]) => i % 2 === 0)
.map(([, v]) => v)
.toArray();
// ['a', 'c']
多写了六行,但它把一个”看起来本该内置”的能力补了回来,而且是通用的。
七、坑三:别把异步的东西硬塞进同步管道
同步迭代器辅助方法处理不了异步迭代器。下面这段不会报语法错误,但 for await 之外拿到的是一堆 Promise:
// 错误的期待:以为 map 里的 async 会被等待
async function* fetchPages() { /* ... */ }
Iterator.from(fetchPages()) // fetchPages() 返回的是 AsyncGenerator
.map(async (x) => x); // 这里 Iterator.from 拿到的不是你想要的
正确做法是 for await 手写循环,或者用异步版本的辅助方法(AsyncIterator.prototype 上的那套,推进比同步版本慢,上线前确认支持范围)。在异步版本不能用的环境里,最稳的仍然是经典写法:
async function collectFirst(source, limit) {
const out = [];
for await (const item of source) {
if (isValid(item)) out.push(item);
if (out.length >= limit) break;
}
return out;
}
这个循环并不丑,而且 break 会正确地关闭异步生成器。同步管道解决同步数据,异步流程交给 for await,别为了”统一风格”硬造一座桥。
八、顺手用上 Set 的新方法:权限判空
同一批新特性里,Set 也长出了集合运算方法,和迭代器辅助方法配在一起很顺。典型场景:判断用户权限是否满足某个操作的最低要求。
const userPerms = new Set(['order:read', 'order:export', 'user:read']);
const required = new Set(['order:read', 'order:export']);
required.isSubsetOf(userPerms); // true —— 两个都有
userPerms.isDisjointFrom(required); // false —— 有交集
userPerms.intersection(required).size; // 2
userPerms.difference(required); // Set {'user:read'}
注意 isSubsetOf 是”全部包含”,isDisjointFrom 是”完全不交”。判断”有没有任何一个权限”,用后者取反比用 intersection(...).size > 0 更能表达意图——而且前者不需要真的构造出一个新集合。
跟迭代器接起来也很自然,因为 Set 本身就是可迭代的:
// 找出用户有、但不在白名单里的权限,取前三個报给审计
const missing = Iterator.from(userPerms)
.filter((p) => !allowList.has(p))
.take(3)
.toArray();
九、降级:不支持的浏览器怎么办
迭代器辅助方法在 Chromium 122 之后、Node 22 起可用,其他引擎也在陆续跟上,但 Safari 的推进节奏一向慢一些。上线前先查一遍目标环境的支持矩阵,不要凭印象。
降级方案有两个方向,选哪个取决于项目结构:
方案一:加 polyfill。 直接用 core-js 一类的库补上 Iterator.prototype 的方法,业务代码一行不改。代价是引入体积,而且这类 polyfill 对生成器 return() 的时序未必和原生完全一致,涉及资源释放的关键路径要单独测。
方案二:把管道收敛到一处。 这是个更可控的做法——管道逻辑集中在一个工具函数里,降级时改一个文件:
function* takeWhile(iterable, predicate) {
for (const value of iterable) {
if (!predicate(value)) return;
yield value;
}
}
function* mapIter(iterable, fn) {
for (const value of iterable) yield fn(value);
}
function* filterIter(iterable, predicate) {
for (const value of iterable) {
if (predicate(value)) yield value;
}
}
每个函数不超过五行,写法也直白。在内部实现用原生方法、外部接口保持不变的前提下,将来砍掉 polyfill 或者换实现都很轻松。我倾向后者:生成器是语言级别的能力,支持面比新方法宽得多,用它来承接降级几乎不会有坑。
十、什么时候别用这套
惰性不是万能的,几种情况用上去反而更麻烦:
- 数据本来就在数组里,而且只有几百条。
arr.filter().map()可读性更好,调试时能直接console.log中间结果,为了几十微秒上迭代器纯属自找。 - 需要多次遍历同一批数据。 迭代器一次性,每次都要重新构造,代码里会多出一堆
makePipeline()调用。数组反而是更合适的容器。 - 要在中间打断点看状态。 惰性链在
toArray()之前是”没发生”的,调试器里看不到中间数据。排查 bug 时先把toArray()插进链子中间,比盯着计划层猜要快得多。 - 团队里大部分人对生成器不熟。 这个理由听着技术含量低,但很真实。一条
Iterator.from(...)的链子在代码评审里,往往会换来一堆”这里能不能改成 for 循环”的提问。先在小范围用,等大家读顺了再铺开。
我的判断标准一直很朴素:当你有”只需要前若干条”或者”数据源大得不想全量建数组”这两个动机之一时,用惰性管道;其余时候,老老实实写数组方法和 for 循环。 前面那个 CSV 预览的例子之所以适合,正是因为它两个都占了——五万行的源,二十条的产出。
把文章里的代码串起来跑一遍,你会发现最花时间的不是写管道,而是处理 CSV 里那些不规则的行。这大概也是数据处理的常态:真正的复杂度从来在清洗,不在遍历。

