es-toolkit/fp 中的 differenceWith:用自定义相等函数在 pipe 管道中实现差集过滤
【免费下载链接】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 函数式编程入口es-toolkit/fp提供的differenceWith。它是一个数据后置(data-last)的管道算子:先用secondArray与areItemsEqual完成配置,返回一个等待数组输入的函数,再与pipe组合,在自左向右的数据流中按自定义相等规则剔除匹配项。读完本文,你将掌握differenceWith的参数契约、与普通版differenceWith的选用准则、它在pipe内触发惰性求值(lazy evaluation)与提前终止的底层原理,以及如何通过测试用例验证其行为。
概览:为 pipe 而生的差集算子
differenceWith创建一个函数,该函数使用自定义相等函数从管道传入的数组中排除与配置值相等的元素。它专用于函数式编程风格,典型用法是与pipe串联:
const result = pipe(array, differenceWith(secondArray, areItemsEqual));在es-toolkit/fp的定位中,每个 fp 函数都是"先传配置、后传数据"的形态:differenceWith(secondArray, areItemsEqual)先完成配置并返回(array) => T[],pipe再把数据逐个喂给这些算子,见 fp 入口文档。
选用建议:在不使用
pipe拼接管道的普通代码中,推荐直接使用 es-toolkit 的普通版differenceWith。只有当你要通过pipe串联多个变换时,才使用这里的 fp 版本。
用法与语义
differenceWith仅当areItemsEqual对secondArray中的每一个值都返回false时,才保留被管道传入数组中的对应值。换句话说,它返回的是"与比较数组没有任何元素相等"的子集。
import { differenceWith, pipe } from 'es-toolkit/fp'; pipe( [{ id: 1 }, { id: 2 }], differenceWith([{ id: 2 }], (a, b) => a.id === b.id) ); // => [{ id: 1 }]上例中,{ id: 2 }与比较数组中的{ id: 2 }按id相等,因此被剔除,最终结果只剩[{ id: 1 }]。
参数(Parameters)
| 参数 | 类型 | 说明 |
|---|---|---|
secondArray | readonly U[] | 用于与管道输入逐项比较、从而决定排除哪些值的数组 |
areItemsEqual | (item: T, other: U) => boolean | 判定两个值是否相等的自定义比较函数 |
返回值(Returns)
(array: readonly T[] => T[]):一个将readonly T[]映射为"未被比较函数匹配到的值"组成的新数组的函数。
从普通版到 fp 版:实现与复用
fp 版differenceWith并没有重新发明算法,而是复用了 es-toolkit 普通版differenceWith的实现。源码见 src/fp/array/differenceWith.ts:
export function differenceWith<T, U>( secondArray: readonly U[], areItemsEqual: (item: T, other: U) => boolean ): (array: readonly T[]) => T[] { function differenceWithEager(array: readonly T[]): T[] { return differenceWithToolkit(array, secondArray, areItemsEqual); } const differenceWithLazy = createLazyFunction<T, T>((value, _index, emit) => { if (secondArray.every(other => !areItemsEqual(value, other))) { emit(value); } }); return combineEagerAndLazyFunctions(differenceWithEager, differenceWithLazy); }这段代码揭示了三个关键点:
- eager 路径复用普通实现:
differenceWithEager内部直接调用src/array/differenceWith.ts导出的differenceWith,签名变化只是把(firstArr, secondArr, areItemsEqual)柯里化成了(secondArray, areItemsEqual)(array)。 - lazy 路径按元素推送:
differenceWithLazy用createLazyFunction构造逐元素变换,每个元素只有通过secondArray.every(other => !areItemsEqual(value, other))的检查才会被emit推送到下游。 - 两者被
combineEagerAndLazyFunctions合并:返回的函数在直接调用时行为与 eager 完全一致;放进pipe后,附加的lazy变换让pipe可以将其与相邻惰性函数熔合为单趟遍历。
普通版differenceWith的算法本身非常直接(src/array/differenceWith.ts):
export function differenceWith<T, U>( firstArr: readonly T[], secondArr: readonly U[], areItemsEqual: (x: T, y: U) => boolean ): T[] { return firstArr.filter(firstItem => { return secondArr.every(secondItem => { return !areItemsEqual(firstItem, secondItem); }); }); }它等价于:对firstArr中的每个元素firstItem,仅当secondArr中不存在任何一个使areItemsEqual(firstItem, secondItem)为真的secondItem时保留。从源码结构看,其时间复杂度为 O(n×m)(n 为第一个数组长度,m 为第二个数组长度),比较函数每次被调用的次数等于两数组长度的乘积上界。
惰性求值:为什么在 pipe 里更快
es-toolkit/fp的核心卖点是管道熔合(pipeline fusion)。当pipe中连续出现可惰性函数(map、filter、take、differenceWith等)时,pipe会把它们熔合成一趟遍历:每个元素一次性穿过所有步骤,而不是每步都生成中间数组;一旦末尾的take(n)收集够结果,整趟遍历立即停止,剩余输入不再被访问。完整机制见 src/fp/pipe.ts 与惰性原语 src/fp/_internal/lazy.ts。
createLazyFunction实现的是一种"推送式"(push)管道:每个函数接收下游的emit(即 sink),再把值推入其中;take这类可提前终止的函数通过返回false立即停住驱动循环,无需生成器挂起/恢复,也没有逐元素的迭代器对象开销。
测试如何验证提前终止
differenceWith的惰性行为有专门的测试用例,见 src/fp/array/differenceWith.spec.ts:
it('supports lazy evaluation with a short-circuiting operator', () => { const spy = vi.fn((item: { id: number }) => item); expect( pipe( [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }], map(spy), differenceWith([1], (item, id) => item.id === id), take(2) ) ).toEqual([{ id: 2 }, { id: 3 }]); expect(spy).toHaveBeenCalledTimes(3); });这个测试用vi.fn间谍函数记录了map的回调调用次数:输入有 4 个元素,但spy只被调用了3 次。原因正是熔合与提前终止——take(2)在收集到 2 个结果后立即中断整趟遍历,第 4 个元素{ id: 4 }从未被访问。若按 eager 逐数组执行,map必然对全部 4 个元素各调用一次。
与 difference / differenceBy 的对比
es-toolkit/fp的数组模块同时提供了三个差集算子,见 src/fp/array/index.ts 的导出:
difference(secondArray):基于 Set 与 SameValueZero 相等语义做差集。fp 实现(src/fp/array/difference.ts)在配置阶段就构建new Set(secondArray),惰性变换用!secondSet.has(value)判断,查找为 O(1),适合处理原始值数组。differenceBy(secondArray, getDiffValue):先用映射函数把两数组元素变换成可比较键再做差集,适合"按某字段去重"的场景。differenceWith(secondArray, areItemsEqual):自由度最高,比较逻辑完全由你提供的areItemsEqual决定,适合对象数组按业务规则(如按id、按name忽略年龄差异)比较,也可比较类型不同的数组。
三者共享同一套"eager 复用普通实现 + lazy 逐元素变换 +combineEagerAndLazyFunctions合并"的结构,因此在pipe中都能参与惰性熔合。
实战:对象数组按业务键过滤
结合普通版文档(docs/reference/array/differenceWith.md)中的示例,我们可以在pipe中实现"忽略次要字段差异"的过滤。例如,用户名单中 Alice 的年龄从 30 变为 31,但我们仍认为她是同一用户,需要把users2中出现的用户从users1中剔除:
import { differenceWith, pipe } from 'es-toolkit/fp'; const users1 = [ { name: 'Alice', age: 30 }, { name: 'Bob', age: 25 }, { name: 'Charlie', age: 35 }, ]; const users2 = [ { name: 'Alice', age: 31 }, // 年龄不同但仍是同一用户 { name: 'David', age: 25 }, ]; pipe( users1, differenceWith(users2, (a, b) => a.name === b.name) ); // => [{ name: 'Bob', age: 25 }, { name: 'Charlie', age: 35 }]areItemsEqual只比较name,因此Alice(即使 age 不同)被排除,David不在users1中所以不影响结果。当这个differenceWith前后还串联了map、filter、take等算子时,整条管道会以单趟、可提前终止的方式执行。
小结
differenceWith(secondArray, areItemsEqual)返回一个数据后置函数,仅在areItemsEqual对secondArray全部元素都为false时保留输入元素;- 它是 fp 版柯里化封装,eager 路径直接复用普通版
differenceWith,lazy 路径用createLazyFunction实现逐元素推送; - 在
pipe中它与相邻惰性函数熔合为单趟遍历,配合take等短路算子可实现提前终止(测试用 spy 断言map仅调用 3 次即验证了这一点); - 普通代码请用
es-toolkit/array的普通版;需要按自定义规则(对象字段、跨类型比较)做差集时,fp 版differenceWith是pipe管道中的正确选择。
【免费下载链接】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),仅供参考