es-toolkit/fp 函数式编程指南:unzipWith 实现分组数组的按位重组与聚合
【免费下载链接】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中的数组工具unzipWith,讲解如何在pipe数据流水线中,将按位置分组的二维数组重新拆组,并用自定义iteratee聚合同一位置的元素。读完本文,你将掌握unzipWith(iteratee)的签名、参数与返回值约定、在pipe中的组合方式,以及它底层如何复用主库实现完成按列聚合的完整原理。
一、unzipWith 是什么
在es-toolkit/fp中,unzipWith用于"重新分组已压缩(zipped)的数组,并对每个位置上的元素做组合"。它是unzip的增强版:unzip只负责把分组数组按位置拆开,而unzipWith在拆开的同时,把同一位置的元素交给iteratee合并成一个新值。
它最典型的用法是配合pipe使用,作为数据流水线中的一个操作符:
const result = pipe(array, unzipWith(iteratee));从源码结构看,es-toolkit/fp中所有数组函数都遵循统一的"data-last"调用约定:先传入配置参数(这里是iteratee),返回一个等待数据到达的函数,再由pipe把数据喂进去。这一点在 docs/fp/intro.md 中有明确说明。
::: info
在普通代码中,优先使用 es-toolkit 主库的unzipWith;只有在使用pipe组合变换时,才使用es-toolkit/fp的这个函数式变体。
:::
二、基础用法:在 pipe 中按位聚合
unzipWith会从分组的行中收集同一位置的数值,将这些值作为参数调用iteratee,并把每次调用的返回值按顺序组成结果数组返回。
import { pipe, unzipWith } from 'es-toolkit/fp'; pipe( [ [1, 10], [2, 20], ], unzipWith((a, b) => a + b) ); // => [3, 30]这个例子的执行过程可以拆解为:
- 输入是一个包含两行的二维数组
[[1, 10], [2, 20]]; unzipWith按列取值:第 0 列取到1和2,第 1 列取到10和20;iteratee依次执行1 + 2 = 3与10 + 20 = 30;- 结果数组为
[3, 30]。
该行为由 src/fp/array/unzipWith.spec.ts 中的测试用例直接验证:
import { describe, expect, it } from 'vitest'; import { unzipWith } from './unzipWith.ts'; import { pipe } from '../pipe.ts'; describe('unzipWith', () => { it('works in a pipe', () => { expect( pipe( [ [1, 10], [2, 20], ], unzipWith((a, b) => a + b) ) ).toEqual([3, 30]); }); });三、参数与返回值
参数
iteratee((...args: T[]) => R):用于组合同一位置元素的函数。它接收从每行同一位置收集到的值作为参数,返回一个合并后的新值。
返回值
- (
(target: readonly T[][]) => R[]):一个函数,接收"按位置分组的二维数组"作为输入,返回按位置组合后的结果数组。
这里值得注意类型设计:iteratee是可变参数(variadic),类型为(...args: T[]) => R,意味着同一位置的元素个数不固定,取决于输入二维数组的行数。返回的函数输入类型为readonly T[][],只读修饰保证了该操作符不会修改原始数据。
四、底层实现:从 fp 变体到主库核心
es-toolkit/fp的unzipWith本身是一个薄封装层。查看 src/fp/array/unzipWith.ts 可以看到,它把传入的iteratee捕获进闭包,返回一个等待数据的函数,并在数据到达时直接委托给主库实现:
import { unzipWith as unzipWithToolkit } from '../../array/unzipWith.ts'; export function unzipWith<T, R>(iteratee: (...args: T[]) => R): (target: readonly T[][]) => R[] { return function (target: readonly T[][]): R[] { return unzipWithToolkit(target, iteratee); }; }真正的按位聚合逻辑在主库 src/array/unzipWith.ts 中完成:
export function unzipWith<T, R>(target: readonly T[][], iteratee: (...args: T[]) => R): R[] { const maxLength = Math.max(0, ...target.map(innerArray => innerArray.length)); const result: R[] = new Array(maxLength); for (let i = 0; i < maxLength; i++) { const group = new Array(target.length); for (let j = 0; j < target.length; j++) { group[j] = target[j][i]; } result[i] = iteratee(...group); } return result; }算法核心可以归纳为三步:
- 确定结果长度:用
Math.max(0, ...target.map(inner => inner.length))求出所有行中最长的长度,作为结果数组的长度。Math.max(0, ...)的写法还保证了空数组输入时长度为 0,不会出现Math.max()返回-Infinity的边界问题; - 按列收集:外层循环
i遍历列下标,内层循环j遍历行下标,把target[j][i](第j行第i列)收集进group,即"同一位置的值"; - 应用 iteratee:
result[i] = iteratee(...group),把整组值展开作为参数传给iteratee,结果存入结果数组对应位置。
这段实现的时间复杂度为 O(行数 × 列数),即与二维数组元素总数成正比,且不产生中间数组(group仅是一个行数的临时小数组),保持了 es-toolkit 一贯的轻量风格。
五、行为细节与边界情况
1. 行长度不一致时,缺失位置传入 undefined
主库实现以最长行的长度为准遍历,因此较短的行在缺失位置会以undefined传给iteratee。这一行为在 src/array/unzipWith.spec.ts 中有明确测试:
it('should handle arrays of different lengths', () => { const zipped = zip([1, 20, 300, 4000], [100, 200, 300]); const result = unzipWith(zipped, (item, item2, item3, item4) => item + item2 + item3 + item4); expect(result).toEqual([4321, NaN]); });在这个用例中,第二行只有 3 个元素,第 3 列(下标 3)缺失,item4为undefined,导致300 + undefined得到NaN。在实际业务中,通常需要在iteratee内对undefined做防御,例如(a || 0) + (b || 0),这在主库文档 docs/reference/array/unzipWith.md 中有相应示例。
2. 空数组输入
当输入为空数组[]时,主库实现中Math.max(0, ...[])得到0,循环不会执行,返回空数组[]。这一行为同样有测试覆盖:
it('should return an empty array when given an empty array', () => { const result = unzipWith([], (...items: number[]) => items.reduce((sum, value) => sum + value, 0)); expect(result).toEqual([]); });需要说明的是:主库文档 docs/reference/array/unzipWith.md 中记载"传入空数组会抛出错误",而当前仓库源码(src/array/unzipWith.ts)的实际行为是返回空数组——两者存在出入,从测试证据看,返回空数组是当前实现的真实行为,文档所述可能在后续版本中调整。若你的代码依赖这一行为,建议以实际运行结果为准。
3. 与 zip 的互逆关系
unzipWith常与zip配合使用:先用zip把多个数组按位置打包成组,再用unzipWith拆开并聚合。上面"行长度不一致"的测试正是先zip再unzipWith的完整链路。这种组合在 fp 变体中同样成立——es-toolkit/fp同时导出了 unzip.ts(src/fp/array/unzip.ts)与 unzipWith.ts,它们都通过 src/fp/array/index.ts 统一导出,可从es-toolkit/fp直接引入。
六、实战组合:在数据流水线中使用 unzipWith
由于 fp 变体的返回值是一个"等待数据的函数",它可以自由嵌入pipe流水线的任意位置,与其他操作符衔接。一个更完整的场景:先zip多组数据,再unzipWith聚合,最后继续交给后续操作符处理。
import { pipe, unzipWith, map } from 'es-toolkit/fp'; // 三组学生的成绩,按列聚合为平均分后再放大 10 倍 const scores = [ [80, 90], [85, 95], [75, 88], ]; const result = pipe( scores, unzipWith((a, b, c) => (a + b + c) / 3), // => [80, 91] map(avg => avg * 10) // => [800, 910] );这种写法的优势在于:每一步变换从上到下顺序阅读,无需像嵌套调用那样从内向外解析,也无需在步骤之间声明临时变量。这与 docs/fp/intro.md 中描述的 fp 设计理念一致——"用pipe表达数据变换,替代嵌套调用与临时变量"。
需要注意,unzipWith本身不是惰性(lazy)操作符:它必须完整读取整个二维数组才能确定最长行长度并完成聚合,因此在pipe中它会作为普通(非 lazy)步骤执行。这与map、filter、take等可惰性融合的操作符不同,pipe的实现在 src/fp/pipe.ts 中通过chunkFunctions将连续函数分组、对惰性函数做融合处理,而unzipWith会走逐个应用的普通路径。
七、快速参考
| 项目 | 说明 |
|---|---|
| 导入路径 | import { unzipWith } from 'es-toolkit/fp'; |
| 签名 | unzipWith<T, R>(iteratee: (...args: T[]) => R): (target: readonly T[][]) => R[] |
| iteratee | 接收同一位置的多个值,返回合并结果 |
| 返回值 | 一个 contenteditable="false">【免费下载链接】es-toolkitA modern JavaScript utility library that's 2-3 times faster and up to 97% smaller, a major upgrade to lodash. 创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考 |