pipe 与 compose:函数式流水线的构建
在函数式编程中,数据流是核心概念。我们希望数据像流水线一样,经过一系列纯函数的处理,最终得到结果。
pipe 和 compose 正是实现这一模式的两大利器。它们将分散的函数连接成一条“管道”,让代码更声明式、更易读、更易测试。
虽然语义相似,但执行顺序的差异决定了它们的适用场景。
pipe:从左到右的流水线
核心语义
pipe(f, g, h)(x) 等价于 h(g(f(x)))。
数据从左向右流动,符合人类阅读习惯。
const add1 = (x: number) => x + 1
const mul2 = (x: number) => x * 2
const toString = (x: number) => x.toString()
const fn = pipe(add1, mul2, toString)
fn(3) // => toString(mul2(add1(3))) => toString(mul2(4)) => toString(8) => '8'实现:reduce 累积函数调用
const pipe = <T>(...fns: Array<(arg: any) => any>) => {
return (value: T) => {
return fns.reduce((acc, fn) => fn(acc), value)
}
}关键逻辑
...fns收集所有函数。- 返回一个新函数,接受初始值
value。 reduce以value为初始值,依次将上一步结果传给下一个函数。
compose:从右到左的组合
核心语义
compose(f, g, h)(x) 等价于 f(g(h(x)))。
数据从右向左流动,符合数学中函数组合 f ∘ g ∘ h 的习惯。
const fn = compose(toString, mul2, add1)
fn(3) // => toString(mul2(add1(3))) => '8' (结果相同)实现:reduceRight
const compose = <T>(...fns: Array<(arg: any) => any>) => {
return (value: T) => {
return fns.reduceRight((acc, fn) => fn(acc), value)
}
}对比 pipe vs compose
| 特性 | pipe | compose |
|---|---|---|
| 执行顺序 | 左 → 右 | 右 ← 左 |
| 阅读习惯 | 更直观 | 数学传统 |
| 推荐 | 优先使用 | 在需要 f ∘ g 语义时使用 |
在现代前端开发中,pipe 因其直观性更受欢迎。
类型推导:如何实现多函数链的类型安全?
pipe 的最大挑战是:如何让 TypeScript 正确推导出整个链的输入和输出类型?
简单实现中,any 会破坏类型安全。我们需要更精确的方案。
方案一:函数重载(推荐)
通过重载定义不同长度函数链的类型:
function pipe<A, B>(fn1: (a: A) => B): (a: A) => B
function pipe<A, B, C>(
fn1: (a: A) => B,
fn2: (b: B) => C
): (a: A) => C
function pipe<A, B, C, D>(
fn1: (a: A) => B,
fn2: (b: B) => C,
fn3: (c: C) => D
): (a: A) => D
// 可继续扩展...
function pipe(...fns: Function[]) {
return (value: any) => fns.reduce((acc, fn) => fn(acc), value)
}优势
- 类型推导精准。
- IDE 支持自动补全和错误提示。
- 适用于大多数实际场景(通常链长 ≤ 5)。
方案二:泛型数组推导(实验性)
尝试用递归类型推导任意长度链:
type Pipe<T extends Array<(arg: any) => any>> = (
T extends [(arg: infer A) => infer B]
? (a: A) => B
: T extends [(arg: infer A) => infer B, ...infer Rest]
? (a: A) => Pipe<Rest>
: never
)但此方案复杂且易触发 TypeScript 的递归深度限制,不推荐生产使用。
多参数函数的链式传递
pipe 默认设计为一进一出的函数链。若中间函数需要多参数,需提前柯里化(Curry)或封装。
问题:多参数函数无法直接接入
const add = (a: number, b: number) => a + b
pipe(add, mul2)(3) // ❌ add 需要两个参数解法:柯里化
将多参数函数转换为单参数函数的链:
const add = (a: number) => (b: number) => a + b
const add5 = add(5)
pipe(add5, mul2)(3) // => mul2(add5(3)) => mul2(8) => 16柯里化是函数式编程的基础技巧,使函数更灵活、可组合。
错误处理:中间函数抛错如何传递?
pipe 本身不处理错误,但错误会自然向上传播。
默认行为:异常中断
const divide = (x: number) => (y: number) => {
if (y === 0) throw new Error('Division by zero')
return x / y
}
const safeDivide = pipe(divide(10), Math.sqrt)
safeDivide(0) // ❌ 抛出 'Division by zero'错误会中断流水线,直接抛出,调用者需用 try/catch 捕获。
解法:异常安全的 pipe(Either 模式)
使用 Result<T, E> 模式,将错误作为值传递:
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E }
const safePipe = <T>(...fns: Array<(arg: T) => Result<T, any>>) => {
return (value: T): Result<T> => {
return fns.reduce(
(result, fn): Result<T> => {
if (!result.ok) return result
try {
return fn(result.value)
} catch (error) {
return { ok: false, error }
}
},
{ ok: true, value } as Result<T>
)
}
}此模式将异常处理内化,适合需要高容错的场景。
实战:数据转换流水线
// 原始用户数据
const rawData = [
{ name: ' Alice ', email: 'ALICE@EXAMPLE.COM', active: '1' },
{ name: ' Bob ', email: 'BOB@EXAMPLE.COM', active: '0' }
]
// 构建处理流水线
const processUser = pipe(
(user: any) => ({ ...user, name: user.name.trim() }), // 清理姓名
(user) => ({ ...user, email: user.email.toLowerCase() }), // 统一邮箱
(user) => ({ ...user, active: user.active === '1' }) // 转换状态
)
const processedUsers = rawData.map(processUser)
// [
// { name: 'Alice', email: 'alice@example.com', active: true },
// { name: 'Bob', email: 'bob@example.com', active: false }
// ]代码清晰表达了“数据经过哪些步骤”,而非“如何一步步操作”。
结语:pipe 是函数式思维的入口
pipe 与 compose 不仅是工具函数,更是函数式编程的思维方式:
- 组合优于嵌套:将复杂逻辑拆解为小函数,再组合。
- 不可变性:每一步都返回新值,不修改原数据。
- 声明式:描述“做什么”,而非“怎么做”。
当你使用 pipe 构建数据流水线时,你已迈入函数式编程的大门。
而类型系统的加持,让这种组合不仅是运行时的动态链接,更是编译时的类型级程序构造——这才是 TypeScript 优先设计的真正力量。