JavaScript 迭代器辅助方法实战:用惰性管道解析五万行 CSV 只取前二十条

先说一个经常被忽略的成本:数组方法链看起来只是”写得清爽”,但它每一环都在造一个新数组。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();
}

这段代码真正执行时发生了什么,值得一步步跟一遍:

  1. toArray() 开始向 take 要第一个值。
  2. take 向 filter 要值,filter 向 map 要值,map 向 parsedRows 要值。
  3. parsedRows 从字符串里切出一行,解析完交出去。
  4. 如果这一行被 filter 干掉了,就回到第 2 步,再要一个。
  5. 攒够 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 里那些不规则的行。这大概也是数据处理的常态:真正的复杂度从来在清洗,不在遍历。

JavaScript 迭代器辅助方法实战:用惰性管道解析五万行 CSV 只取前二十条
收藏 (0) 打赏

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

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

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

淘吗网 javascript JavaScript 迭代器辅助方法实战:用惰性管道解析五万行 CSV 只取前二十条 https://www.taomawang.com/web/javascript/2971.html

常见问题

相关文章

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

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