Iterator Helpers 实战:用惰性管道替换 JavaScript 数组链式调用

前阵子帮同事看一个脚本,逻辑很简单:读一份日志文件,挑出 ERROR 级别的记录,取前 50 条统计一下。文件 200MB 左右,跑起来直接把 Node 的内存顶到 4G 然后 OOM。

代码长这样:

const result = raw
  .split('n')
  .filter(line => line.includes('ERROR'))
  .map(parseLine)
  .filter(rec => rec && rec.level === 'ERROR')
  .slice(0, 50);

看上去没问题,直觉上还挺优雅。但每一步都在造一个新数组,而且——这是最要命的地方——slice(0, 50) 明明只想要 50 条,前面几十万条记录该解析的解析了,该分配的对象也全都分配了。计算整个白做,内存也整个白占。

这不是算法问题,是数据结构选错了。数组链式调用天然是”急切的、全量的”,而这里的需求是”惰性的、流式的”。

JavaScript 现在有原生的解决方案:迭代器辅助方法(Iterator Helpers)。

一、环境确认与最小验证

这个提案已经落地到所有主流引擎了,时间线大概是:

  • Chrome / Edge 122+(V8 12.2,2024 年 2 月)
  • Firefox 117+(2023 年 9 月)
  • Safari 18.0+(2024 年 9 月)
  • Node.js 22 及以上

打开控制台跑一行就能确认:

console.log(typeof [].values().map);
// "function" —— 有的话就说明支持

先用最短的例子感受一下写法上的变化:

// 数组链式调用
const a = [1, 2, 3, 4, 5]
  .map(x => x * 2)
  .filter(x => x > 4);
// [6, 8, 10]

// 迭代器辅助方法
const b = [1, 2, 3, 4, 5]
  .values()
  .map(x => x * 2)
  .filter(x => x > 4)
  .toArray();
// [6, 8, 10]

多出来的两个东西:开头要拿到一个迭代器(.values() 或者 Iterator.from()),结尾要显式收口(.toArray())。中间那些 map、filter 的写法一模一样。

差别全在”什么时候执行”上。

const it = [1, 2, 3].values()
  .map(x => { console.log('map 被调用', x); return x * 2; })
  .filter(x => x > 2);

console.log('到这一步为止,什么都没打印');

const out = it.toArray();
// 现在才开始逐个打印 map 被调用 1 / 2 / 3
console.log(out); // [4, 6]

这两段日志的顺序就是全部秘密。迭代器辅助方法不做任何事,直到你终结它。每个 .map()、.filter() 只是包了一层新的迭代器,返回值永远是迭代器本身。

而”逐个”这两个字同样关键:数据是一条一条流过整条管道的,不是每一站都攒一堆再交给下一站。

二、十二个方法,按用途分三类

辅助方法挂在一个共享的 %IteratorPrototype% 上,所以任何迭代器都能直接用——包括生成器对象、Map / Set 的迭代器、Array.prototype.values()。

变换类

it.map(fn)        // 逐个映射
it.filter(fn)     // 逐个筛选
it.flatMap(fn)    // 映射后展开一层
it.drop(n)        // 跳过前 n 项
it.take(n)        // 最多取 n 项,取满就终止上游

消费类(调用后迭代器就废了)

it.toArray()      // 收成数组
it.reduce(fn, i)  // 归约
it.forEach(fn)    // 遍历,返回 undefined
it.some(fn) / it.every(fn) / it.find(fn)

入口

Iterator.from(x)  // 可迭代对象或迭代器 → 迭代器

Iterator.from 有个细节值得知道:如果传进去的已经是迭代器,它会原样返回,不会多包一层。所以写工具函数的时候可以无脑用它做归一化。

另外注意,take 是唯一一个会主动”打断”上游的方法。取够数量之后它就不再调用 next(),上游的生产逻辑自然停在那里。这个特性是后面所有优化的基础。

三、案例:把日志管道改成惰性的

回到开头那个 OOM 的脚本。分两步改。

第一步:先把 split 干掉

很多人改到 .values() 就以为完事了,其实 raw.split('n') 这一步本身就制造了一个几十万元素的数组。要用迭代器,得先有个惰性的按行切分:

function* splitLines(text) {
  let start = 0;
  while (true) {
    const i = text.indexOf('n', start);
    if (i === -1) {
      if (start < text.length) yield text.slice(start);
      return;
    }
    yield text.slice(start, i);
    start = i + 1;
  }
}

逐行切分这件事,用生成器写反而比 split 更自然:找到换行符就 yield,不用管后面还剩多少。

第二步:换成迭代器管道

function parseLine(line) {
  const m = /^(S+)s+(ERROR|WARN|INFO)s+[(w+)]s+(.*)$/.exec(line);
  if (!m) return null;
  return { ts: m[1], level: m[2], mod: m[3], msg: m[4] };
}

const errors = Iterator.from(splitLines(raw))
  .map(parseLine)
  .filter(Boolean)
  .filter(rec => rec.level === 'ERROR')
  .take(50)
  .toArray();

关键在于执行顺序被彻底反转了。take(50) 会在第 50 条 ERROR 被找到的那一刻停止向下游索要数据,于是上面的 map 也停止向 filter 要,splitLines 也停止 yield。

实际跑下来,如果第 3000 行就凑齐了 50 条 ERROR,那 3000 行之后的内容连 indexOf 都不会执行,更不用说正则匹配和对象分配。

想亲眼看到这个效果,加个计数器:

let mapped = 0;

Iterator.from(splitLines(raw))
  .map(line => { mapped++; return parseLine(line); })
  .filter(Boolean)
  .filter(r => r.level === 'ERROR')
  .take(3)
  .toArray();

console.log(mapped); // 假设前三行里就凑齐了 3 条,这里就是 3

把同样的计数器加到数组版本上,你会看到 mapped 等于全部行数。差距就是这么来的。

顺手说一下流式读取

上面这个版本虽然不再制造中间数组,但整个 raw 字符串还在内存里。真要处理超大文件,源头应该换成流:

async function* readLines(readableStream) {
  const decoder = new TextDecoder();
  let buf = '';
  for await (const chunk of readableStream) {
    buf += decoder.decode(chunk, { stream: true });
    let i;
    while ((i = buf.indexOf('n')) >= 0) {
      yield buf.slice(0, i);
      buf = buf.slice(i + 1);
    }
  }
  if (buf) yield buf;
}

但这里有个现实约束:同步的迭代器辅助方法用不到异步迭代器上。for await (const line of readLines(...)) 可以正常遍历,但你不能写 readLines(...).map(...)——异步迭代器的辅助方法还在提案阶段,没有落地到引擎里。

眼下的做法是:用 for await 把异步源转成一个同步数组(或分批处理),再交给迭代器管道。或者干脆手写 async function* 做变换,等标准跟上。

四、案例:数组做不到的无限序列

惰性求值最直观的价值,其实在于那些”根本没有终点”的数据源。

比如取前 10 个质数。用数组写不出来——你没法先造一个包含所有自然数的数组。用迭代器则毫无压力:

function* naturals(start = 2) {
  let n = start;
  while (true) yield n++;
}

function isPrime(n) {
  if (n < 2) return false;
  for (let d = 2; d * d <= n; d++) {
    if (n % d === 0) return false;
  }
  return true;
}

const first10 = naturals()
  .filter(isPrime)
  .take(10)
  .toArray();

console.log(first10);
// [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]

生成器对象本身就是迭代器,所以可以直接 .filter(),不需要 Iterator.from 包一层。

这个模式用处比看起来多。像分页拉取、轮询重试、读不可知长度的输入流、遍历 DOM 的某一层节点,本质上都是”我不知道有多少,但我需要多少就拿多少”。这类场景用数组思维去写,要么写不出来,要么写出个带手写 break 的怪循环。

五、案例:自定义惰性操作符

内置的十二个方法不可能覆盖所有需求。好在这里的扩展方式特别轻——写生成器就行,它们自动兼容整条管道。

比如按某个字段去重,同时保持顺序。如果攒成数组再处理,内存又上去了;用生成器就是个 Set 加一个循环:

function* uniqueBy(source, keyFn) {
  const seen = new Set();
  for (const item of source) {
    const k = keyFn(item);
    if (seen.has(k)) continue;
    seen.add(k);
    yield item;
  }
}

它会自动接在已有的管道上:

const activeUsers = Iterator.from(userStream)
  .filter(u => u.lastActive > WEEK_AGO)
  .pipe(uniqueBy, u => u.id)     // 见下方说明
  .take(100)
  .toArray();

上面的 .pipe() 不在标准里,是我自己加的顺手工具,十几行就够:

if (!Iterator.prototype.pipe) {
  Object.defineProperty(Iterator.prototype, 'pipe', {
    value: function (fn, ...args) {
      return fn(this, ...args);
    },
    writable: true,
    configurable: true,
  });
}

有了它,自己写的生成器操作符和内置方法的写法就统一了,管道读起来是直的一条线。

再举一个:分块。处理批量 API 调用时特别有用,每 50 条发一次请求。

function* chunk(source, size) {
  let buf = [];
  for (const item of source) {
    buf.push(item);
    if (buf.length === size) {
      yield buf;
      buf = [];
    }
  }
  if (buf.length) yield buf;
}
for (const batch of chunk(Iterator.from(recordStream).map(normalize), 50)) {
  await sendBatch(batch);
}

注意这里 chunk 每次 yield 的都是一个新数组,所以调用方可以放心持有。但如果写的是”复用同一个 buffer”的版本,就得留个心眼——下游一旦异步或延迟处理,数组内容会被覆盖。这是我踩过的真实坑,调试了两个小时。

六、六个必须提前知道的坑

1. 一次性消费,用完就没了

const it = [1, 2, 3].values().map(x => x * 2);

console.log(it.toArray()); // [2, 4, 6]
console.log(it.toArray()); // []   ← 不是 [2, 4, 6]

第二次是空数组,而且不会报错。这个行为在”先把结果算出来缓存一下”的场景里特别容易出事。要复用得重新构造一遍迭代器。

2. 忘记收口,代码静默不执行

Iterator.from(data).map(f).filter(g);
// 什么都不会发生,也不报错

这是新手最常见的失误,尤其是从数组链式调用切换过来的时候——那边点了最后一个括号就立刻执行,这边还差一个 .toArray() 或者 .forEach()。

ESLint 有个 no-unused-expressions 规则能捞出一部分,但靠工具不如靠肌肉记忆:看到迭代器管道的末尾没有终结方法,就说明漏了。

3. 回调参数只有值,没有索引

数组的 map 回调签名是 (value, index, array),迭代器的只有 (value)。下面这段从数组版改成迭代器版,会静默出错:

// 数组版:正常
[10, 20, 30].map((v, i) => `${i}:${v}`);
// ["0:10", "1:20", "2:30"]

// 迭代器版:i 永远是 undefined
[10, 20, 30].values().map((v, i) => `${i}:${v}`).toArray();
// ["undefined:10", "undefined:20", "undefined:30"]

不报错,只是结果错了。要索引的话得自己维护计数器,或者先 toArray() 用数组那套。

4. 没有 sort、slice、length

迭代器上不存在 .sort(),因为它需要看到全部数据才能排序——这跟惰性的前提直接冲突。.slice() 和 .length 同理。

遇到需要排序的场景,做法是 toArray() 之后再排。这不是妥协,是问题性质决定的:你要么流式,要么随机访问,很难同时要。

5. 调试信息全丢了

数组管道可以在任意位置 console.log 看中间结果:

const step1 = data.filter(f);
console.log(step1); // 能看到东西
const step2 = step1.map(g);

迭代器管道不行。你 console.log(it) 只会得到一个不透明的 Iterator Helper 对象,展开也没有内容。

调试办法是在变换函数里打点:

.map(x => { console.log('map 输入', x); return f(x); })

或者临时插一个 take(10).toArray() 看前十条长什么样。前几次会比较别扭,习惯之后其实还好——因为管道里的每一步逻辑都很小,问题定位往往比数组版更快。

6. 短管道不一定更快

迭代器辅助方法每次 next() 都要走一遍协议,每个操作符都是一层函数调用。数据量小、又没有提前终止的时候,它比数组版慢。

粗略的适用区间:

  • 少于几千条、整条管道要跑完 → 用数组,别折腾
  • 有几万条以上,或者带 take / find / some 这类提前终止 → 迭代器优势明显
  • 数据源本身是无限的 → 只能用迭代器

另外,如果管道需要多次遍历同一批数据(比如先算总数再算平均值),迭代器的一次性特性会让这个需求变得别扭,老老实实用数组。

七、最后给个选择清单

我自己的判断顺序是这样的,从下往上问:

  1. 数据源是不是无限的、或者不知道有多长?→ 迭代器
  2. 是不是只需要前 N 条?→ 迭代器
  3. 数据量是不是上万了?→ 迭代器
  4. 中间要不要排序?→ 数组
  5. 同一批数据要不要反复用?→ 数组
  6. 以上都不是 → 数组,能少写两个方法调用

大部分业务代码其实落在最后一条上。日常 CRUD 里那种三五十条数据的 .map().filter(),换成迭代器纯属给自己找麻烦,还慢一点。

迭代器辅助方法真正的战场是数据管道:日志处理、ETL 脚本、批量导入导出、爬虫翻页、流式解析。这些地方的共同点是数据量大、往往只需要一部分、并且天然按顺序来。在这种场景下,把 .values() 和 .toArray() 加上,往往比调优任何算法都管用。

Iterator Helpers 实战:用惰性管道替换 JavaScript 数组链式调用
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 javascript Iterator Helpers 实战:用惰性管道替换 JavaScript 数组链式调用 https://www.taomawang.com/web/javascript/2815.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务