es-toolkit 的 `uniqBy` 迭代器函数:按派生键延迟去重实战指南
2026/9/17 1:44:44 网站建设 项目流程

es-toolkit 的uniqBy迭代器函数:按派生键延迟去重实战指南

【免费下载链接】es-toolkitA modern JavaScript utility library that's 2-3 times faster and up to 97% smaller, a major upgrade to lodash.项目地址: https://gitcode.com/GitHub_Trending/es/es-toolkit

本文是 es-toolkit 迭代器(iterator)模块参考文档的深度解读,围绕docs/ja/iterator/reference/uniqBy.md展开。你将掌握如何在流式数据场景中按"派生键"去除重复元素(例如每个用户 ID 只保留第一条事件),理解其 SameValueZero 键比较语义、流式输出的实现原理、对无限迭代器的支持方式,以及如何通过pipe以函数式风格组合使用。阅读后可立即在真实项目中按需引入es-toolkit/iteratores-toolkit/fp/iterator

一、uniqBy是什么:面向迭代器的延迟去重

uniqBy是 es-toolkit 迭代器模块提供的函数,用于延迟地(lazily)生成输入迭代器中"映射键尚未出现过"的元素。与常见的一次性数组去重不同,它面向的是Iterator数据流,逐个元素判断、逐个元素产出。

const unique = uniqBy(source, getKey);

其核心签名如下(见 src/iterator/uniqBy.ts):

export function uniqBy<T, K>(source: Iterator<T>, getKey: (value: T) => K): IteratorObject<T, undefined>;

函数接受两个参数:

  • sourceIterator<T>):需要进行去重的迭代器;
  • getKey(value: T) => K):将每个元素转换为用于检测重复的键的函数。

返回一个IteratorObject<T, undefined>,即移除重复键元素后的延迟求值迭代器。该返回值继承原生Iterator.prototype,自带mapfiltertakedropflatMapreducetoArray等全部原生迭代器辅助方法,可以直接继续链式调用。

在 es-toolkit 的迭代器模块中,uniqBycartesianProductchunkcountdropWhileheaditeratepartitionrangescantakeWhilezip一同从src/iterator/index.ts导出。

二、核心用法:按派生键去重

场景一:对数值流按向下取整结果去重

import { uniqBy } from 'es-toolkit/iterator'; // 每个映射键只保留第一个元素。 uniqBy([1.1, 1.2, 2.3, 2.4].values(), Math.floor).toArray(); // 结果: [1.1, 2.3]

这里getKeyMath.floor1.11.2都映射为键1,因此只保留先出现的1.12.32.4都映射为键2,只保留先出现的2.3

场景二:按派生键对对象去重

const events = [ { userId: 1, type: 'click' }, { userId: 1, type: 'view' }, { userId: 2, type: 'click' }, ]; uniqBy(events.values(), e => e.userId).toArray(); // 结果: [{ userId: 1, type: 'click' }, { userId: 2, type: 'click' }]

这是原文档中给出的经典应用:保留每个用户 ID 的第一条事件。getKey返回e.userId,因此userId: 1的两条记录被去重为一条,userId: 2的记录保留。

行为特征

从原文档与源码实现可以总结出uniqBy的三个关键行为:

  1. 保持首次出现顺序:去重后元素的相对顺序与源迭代器中的首次出现顺序一致;
  2. SameValueZero 键比较语义:与Set的判定一致,因此NaN作为键也能被正确去重(NaN === NaNfalse,但 SameValueZero 认为它们相等),这一点在测试 src/iterator/uniqBy.spec.ts 中有明确验证;
  3. 流式去重:每个元素一旦被判定为唯一就立即输出,因此配合短路(short-circuit)辅助函数可以作用于无限迭代器。

三、源码级原理:Set记忆 + 逐元素拉取

核心实现

uniqBy的完整实现非常简洁,位于 src/iterator/uniqBy.ts:

export function uniqBy<T, K>(source: Iterator<T>, getKey: (value: T) => K): IteratorObject<T, undefined> { const seen = new Set<K>(); return iterator( function () { let result = source.next(); while (!result.done) { const key = getKey(result.value); if (!seen.has(key)) { seen.add(key); return { value: result.value, done: false }; } result = source.next(); } return { value: undefined, done: true }; }, () => void source.return?.() ); }

从源码可以看出其工作原理:

  • Set<K>作为已见键的记忆结构,这正是 SameValueZero 语义的来源;
  • 每次调用next时,从source拉取元素、计算键、查询Set:键未见过则记录并立即产出该元素;键已见过则继续拉取下一个元素;
  • 当源迭代器耗尽(done: true)时,返回终止结果;
  • 第二个回调参数onClose用于在迭代器关闭时调用source.return?.(),从而把关闭信号传播给上游资源。

底层iterator辅助函数

uniqBy返回的迭代器由 _internal/iterator.ts 中的iterator辅助函数构建,它实现了完整的IteratorClose协议:

  • 返回对象的原型是原生Iterator.prototype,因此行为与内建迭代器辅助方法(如array.values().map(...))完全一致:一次性(single-shot)消费、可通过Symbol.iterator迭代(返回自身)、携带全部原生辅助方法;
  • onClose在三种情况下恰好执行一次:消费者提前终止(return(),例如takefor...of中的break)、next抛出异常、next报告done
  • 迭代器关闭后,next不再被调用,后续每一步都直接返回done

值得一提的设计细节:这里刻意用手写的next函数而非生成器(generator)实现,注释说明直接驱动迭代器协议相比yield生成器大约快两倍,而Object.create(Iterator.prototype)相比普通对象字面量没有可测量开销。这是 es-toolkit 追求性能的体现。

测试验证的边界行为

src/iterator/uniqBy.spec.ts 的测试用例完整覆盖了上述行为:

  • 每个映射键保留第一个元素(L17-L19);
  • NaN键按 SameValueZero 去重:uniqBy([NaN, NaN, 1].values(), x => x).toArray()得到[NaN, 1](L21-L23);
  • 按派生键对对象去重(L25-L35);
  • 流式:对无限迭代器uniqBy(infinite, x => x % 3).take(3).toArray()得到[0, 1, 2],即只消费到产出 3 个唯一元素为止(L37-L46);
  • 一次性toArray()消费完成后再次调用返回空数组(L48-L52);
  • 提前关闭:用take(1)提前终止时,上游生成器源的finally块被执行,isClosed()true(L54-L62);
  • 异常传播与关闭getKey抛出异常时,异常向上抛出且上游源被正确关闭(L64-L73)。

四、流式去重与无限迭代器

原文档强调:去重是流式进行的,每个元素一旦确认唯一就立即输出,因此只要用短路辅助函数限定范围,uniqBy就可以用于无限迭代器。

测试中的示例直观展示了这一能力:

let n = 0; const infinite: Iterator<number> = { next: () => ({ value: n++, done: false }) }; uniqBy(infinite, x => x % 3) .take(3) .toArray(); // 结果: [0, 1, 2]

其流程为:uniqBy逐个计算键(0、1、2 各出现一次,均为唯一并输出),当take(3)集齐 3 个元素后触发return(),此时uniqByonClose回调调用source.return?.()关闭上游,整个管道停止——无限迭代器永远不会被完整消费。

五、与pipe组合:函数式风格

柯里化形式

当需要通过pipe组合变换时,应从es-toolkit/fp/iterator导入柯里化形式:它只接收键函数,返回一个接收迭代器的函数。对应实现见 src/fp/iterator/uniqBy.ts:

export function uniqBy<T, K>(getKey: (value: T) => K): (source: Iterator<T>) => IteratorObject<T, undefined> { return function uniqByInIterator(source: Iterator<T>): IteratorObject<T, undefined> { return uniqByIterator(source, getKey); }; }

uniqBy(getKey)返回(source: Iterator<T>) => IteratorObject<T, undefined>,内部委托给上一节讲到的核心实现。

pipe 用法示例

import { pipe } from 'es-toolkit/fp'; import { toArray, uniqBy } from 'es-toolkit/fp/iterator'; pipe([1.1, 1.2, 2.3, 2.4].values(), uniqBy(Math.floor), toArray()); // 结果: [1.1, 2.3]

uniqBy(Math.floor)作为数据在后的操作符接入pipe,数据从左到右依次流过各函数,最终由toArray()收尾。

关于pipe的机制(见 src/fp/pipe.ts):它会把连续的惰性函数(mapfiltertake等)分组融合成一次逐元素的短路遍历,避免每一步都构建中间数组;当末尾存在take(n)这类短路函数时,凑齐n个结果即停止,剩余输入不再被访问。这也解释了为何uniqBy这类惰性操作符在pipe中特别有用——整个管道保持单遍、可早停。

uniqByes-toolkit/fp/iterator中与cartesianProductchunkcountdropdropWhileeveryfilterfindflatMapforEachheadmappartitionreducescansometaketakeWhiletoArrayzip一同导出(见 src/fp/iterator/index.ts)。

六、总结:何时使用uniqBy

需求推荐方式
按某个派生键对流式数据去重,保持首次出现顺序uniqBy(source, getKey)es-toolkit/iterator
对无限迭代器去重,只取前 N 个唯一结果uniqBy(source, getKey).take(n)
在函数式管道中组合去重与其他变换pipe(source, uniqBy(getKey), toArray())es-toolkit/fp/iterator
需要处理NaN等特殊键的去重uniqBy使用 SameValueZero 语义,天然支持

作为现代 lodash 的升级替代方案,es-toolkit 以更小的体积和更快的速度为目标,而uniqBy的迭代器实现通过Set记忆、逐元素拉取和完整的 IteratorClose 协议,在保持语义正确的同时兼顾了流式处理与资源释放。如需深入了解迭代器模块的其他函数,可继续查阅 迭代器模块文档 与对应的 英文参考。

【免费下载链接】es-toolkitA modern JavaScript utility library that's 2-3 times faster and up to 97% smaller, a major upgrade to lodash.项目地址: https://gitcode.com/GitHub_Trending/es/es-toolkit

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询