ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

函数组合实战:从compose、pipe到TypeScript类型安全

函数组合实战:从compose、pipe到TypeScript类型安全

函数组合(Function Composition)这个词,几乎每个接触过函数式编程的人都会撞见,但真正在项目里把它用出价值来的,远没有想象中多。我入坑的经过很普通:一开始写 JavaScript 时偷懒用工具库的 flow 少写几个中间变量,后来在某个数据平台项目里被一段层层嵌套的字段处理逻辑折磨到头疼,才回头认真研究组合的本质——把若干单一职责的小函数像积木一样拼起来,让数据沿着一条清晰路径流动,最后得到一个新的、可命名、可复用的函数。这篇文章想做的,就是把函数组合从“听说过”讲到“能实战”:先聊清楚它背后的数学直觉与设计取舍,再给出 TypeScript / JavaScript 里可以直接抄的代码、踩过的坑和排查技巧。无论你是刚接触函数式的初学者,还是在生产环境里已经写过不少组合链的工程师,应该都能从里面找到一些对胃口的东西。

1. 函数组合到底是什么:先把它从玄学变成直觉

1.1 数学定义很简单,难的是思维切换

组合的数学定义朴素到让人怀疑它有什么好讲:给定两个函数 f 和 g,组合函数 h = f ∘ g,意思是 h(x) = f(g(x))。先执行 g,再把 g 的输出交给 f,仅此而已。但这条简单规则在命令式编程里很少被刻意建模,在函数式编程里却被提升为核心的组织方式。为什么?因为它解决的不是“少写几行代码”这么表面的事,而是把“数据处理流程”从一堆散落的语句变成了一种可命名、可传递、可验证的值。

我习惯用流水线来解释它。车间里有清洗、切割、包装三道工序,每条流水线把上一步的产物交给下一步。组合函数就是把这些工序用传送带接起来,而传送带本身也是一段可拆装的设施,不是焊死在车间地板上的结构。这个类比能带出组合最重要的特性:你可以在任意两段工序之间插入一道质检,也可以把“切割加包装”打包成一条新产线,供其他车间直接复用。函数组合所追求的一切——复用、可插拔、单向流动——都藏在这个画面里。

1.2 对比命令式写法:临时变量才是隐形敌人

先用一段代码感受差异。假设输入需要经过规范化、校验、富化、格式化四个步骤,命令式写法通常是:

const a = normalize(input); const b = validate(a); const c = enrich(b); const result = format(c);

这段代码没有任何错误,但它把“流程”摊开了,每一行都引入一个临时变量。临时变量本身就是一种心智负担:你得记住 a 是什么、b 代表哪个阶段,还得祈祷后面没有人手贱在中间修改它们。流程一长,这种负担会指数级上升,更麻烦的是,这个流程本身无法被当作一个整体传递或测试。

组合式写法是这样的:

const process = pipe(normalize, validate, enrich, format); const result = process(input);

关键区别在于:命令式版本里“流程”是多行散落的行动序列,组合版本里“流程”变成了一个值 process。这个值可以被测试、被丢弃、被传给另一个函数,甚至可以在运行时根据条件选择不同的流程。把行为数据化,才是组合在架构层面的真正价值,这也是它值得在现代编程里被认真对待的原因。

1.3 组合的适用边界:不是所有流程都适合

组合不是银弹,这点必须泼点冷水。如果每一步处理都需要访问外部上下文,比如读取某个全局配置、依赖可变状态、或者中途要触发副作用,组合的“纯函数假设”就会被打破,硬套反而会把代码写得又绕又难查。

我判断一段逻辑适不适合组合,用一条标准:这个流程能否用“输入输出”准确描述?能,就拆成小函数再组合;不能,先把副作用和外部依赖推到系统边界,让核心流程保持纯粹。这不是函数式教条,而是工程上的止损策略——保证组合链内部是可信的、可预测的,边界处再处理现实世界的混乱。

2. 组合的落地形态:手写原理与工具库的取舍

2.1 手写 compose 与 pipe:十行代码看透原理

不管用什么库,组合函数的核心实现其实就几行。先看 compose,它的执行顺序是从右往左:

function compose<T>(...fns: Array<(arg: T) => T>): (arg: T) => T { return fns.reduceRight((acc, fn) => (x) => fn(acc(x))); }

pipe 正好相反,从左往右执行:

function pipe<T>(...fns: Array<(arg: T) => T>): (arg: T) => T { return fns.reduce((acc, fn) => (x) => fn(acc(x))); }

理解这段代码有两个关键点。第一,reduceRight 和 reduce 的方向差异就是 compose 与 pipe 的语义差异;第二,acc 是一个“已经组合好的函数”,每遍历到一个新函数 fn,就生产一个新的包装函数,把 acc 的返回结果再交给 fn。整个过程就是不断地用闭包嵌套闭包。

这里有值得展开的地方:数学记法 f ∘ g 天然是从右往左读的,compose 忠实复刻了数学传统;而 pipe 刻意反过来,让阅读顺序和数据流动方向一致。我自己的团队里,绝大多数场景我更推荐 pipe,因为排查问题的时候,从左往右顺着数据流看,和大多数人的阅读习惯一致,新人接手也更快。这些底层原理一定要自己推一遍,不然以后遇到库里组合函数行为怪异(比如参数顺序搞反),会浪费很多调试时间。

2.2 常用组合工具:lodash/fp、Ramda、fp-ts、RxJS 怎么选

既然原理不复杂,为什么还要用库?因为库解决的不只是组合本身,还有柯里化、类型推导、容器类型等配套能力。下面这几个是我在不同项目里实际用过的方案,适用场景差异很大:

工具核心优势主要短板适合场景
lodash/fp 的 flow生态最普及,团队基本都有TypeScript 类型推导偏弱传统 JS/TS 项目快速上手
Ramda自动柯里化,组合体验顺滑包体积偏大,风格要团队统一偏函数式的 JS 代码库
fp-ts类型安全极强,自带 Option/Either学习曲线陡,类型体操密集追求类型完备的 TS 项目
RxJS 的 pipe异步流和事件流的天然组合概念重,只适合响应式场景数据流、前端状态管理

选型上没有标准答案。我的建议是:如果团队是第一次引入函数式思维,从 lodash/fp 或 Ramda 起步最稳妥,学习成本低,收益立竿见影;如果项目对类型安全有执念,而且大家愿意投入时间啃文档,fp-ts 能带来更强的编译期保障;至于响应式编程,那是另一套组合哲学,别混在一起用。

2.3 函数式语言的原生组合给我们的启发

看一圈函数式语言,组合几乎是语法级别的能力,这能帮我们理解 JS 生态里为什么组合“要靠库”。比如 Haskell 里用.运算符:

process = format . enrich . validate . normalize

F# 和 Elixir 用管道运算符|>,读起来像极了 pipe:

input |> normalize |> validate |> enrich |> format

这类原生支持让组合变得零成本、零学习门槛。相比之下,JavaScript 的管道运算符提案还在漫长标准化过程中,所以现阶段我们只能用库或者手写函数模拟。这个差异背后的启示是:组合不是某种语言的专利,而是一种通用的思维工具,语言是否原生支持只影响使用成本,不影响它本身的价值。理解这一点,就不会纠结“我要不要学函数式语言”,而是专注于把组合思维落到当前项目里。

3. 类型系统与组合:让错误停在编译期

3.1 TypeScript 如何推导组合链

组合最怕的事情之一就是中间某个函数的输入输出接不上,运行时才发现。在 TypeScript 里,只要我们写的 compose/pipe 带上了正确的泛型签名,编译器能帮我们提前抓住大部分接不上的情况。

const toStr = (n: number): string => String(n); const toLen = (s: string): number => s.length; const result = pipe(toStr, toLen)(12345); // TS 正确推导 result 为 number

如果手滑把顺序写反了:

const result = pipe(toLen, toStr)(12345); // 编译错误:number 不能赋值给 string

这段代码看着简单,背后的推导机制值得说一句:TypeScript 会从第一个函数的参数类型开始,逐步推断下一个函数的输入必须等于上一个函数的输出,任何一个环节对不上,都会在编辑器里直接标红。这就是“类型安全的组合”带来的实际红利——很多联调阶段才会爆的接口错位问题,提前到写代码时就解决了。

3.2 泛型函数组合:从普通函数到容器类型

普通函数的组合还算简单,真正进阶的是泛型函数和“容器类型”的组合,比如数组、Promise、Option。这里要引入一个直觉:map 就是“在不改变容器结构的前提下,对容器里的值应用函数”。

const nums = [1, 2, 3]; const doubled = nums.map((x) => x * 2);

数组的 map 本质就是一种组合:把“数组容器”和“翻倍函数”组合成“翻倍后的数组”。再往前走一步,chain(或者叫 flatMap)允许你组合的函数返回一个新的容器,从而支持链式处理。这就是函数式编程里 Functor 和 Monad 的朴素直觉——不用背定义,只需要理解:组合的对象从 A → B 扩展到了 F → F,容器帮我们处理了额外的上下文,比如可选值、错误、异步。

在 fp-ts 这类库里,这种组合随处可见:

pipe( option.fromNullable(input), option.map(validate), option.chain(fetchEnrichment) );

每个环节都在操作 Option 容器,而不是裸值,空值的处理被类型系统和组合逻辑一起接管了。

3.3 类型安全带来的团队协作红利

说一个我实际观察到的现象:在某中台项目里推行组合式写法之前,接口对接的 bug 大多集中在“我以为你返回的是字符串,结果是对象”“这个字段可能为空,没人处理”这类低级问题上。改造之后,同样的错误大幅度减少,因为组合链的类型签名把每个环节的契约写死在编译期,改了一个函数签名,所有依赖它的组合链会立刻爆红,逼着你同步处理。

这种体验带来的不只是更少的 bug,还有更敢重构的心态。过去重构一个处理流程,得担心牵一发动全身;现在组合链的每个环节都是独立函数,签名清晰,编译器盯着,改起来心里有底。我个人认为,这才是类型系统与函数组合结合后最珍贵的价值——它不是让代码显得高级,而是让改动变得安全。

4. 组合的演进线:从 compose、pipe 到异步与代数结构

4.1 compose 与 pipe 的方向之争:工程视角的答案

很多初学者会纠结:同一个需求,compose 能写,pipe 也能写,到底学哪个?这个问题放理论层面可以争论很久,但放工程层面答案很实际:多数业务代码用 pipe,因为它顺着数据流从左往右读,直觉上就是“先干这个,再干那个”。

// pipe:左边先执行,右边后执行 const process = pipe(validate, enrich, format); // compose:右边先执行,左边后执行 const process = compose(format, enrich, validate);

我见过不止一次,团队里混用两种风格导致 code review 时反复确认执行顺序。所以我的建议是:项目里定一个规矩,默认用 pipe,除非你在写一个需要贴近数学记法的抽象层。规则的价值不在于选哪个方向,而在于消除歧义,让执行顺序一眼可读。

4.2 点自由风格:参数消失后,代码在表达什么

组合的进阶玩法里,有一种叫 point-free(点自由)的风格,意思是组合过程的代码里不出现数据参数本身:

// 带参数版本 const process = (input) => format(validate(enrich(normalize(input)))); // point-free 版本 const process = pipe(normalize, validate, enrich, format);

第二种写法里我们不再关心数据叫什么名字,只描述流程本身。好处是代码更贴近“组合代数”,函数之间是一种声明式的装配关系;坏处是读代码的人如果对 pipe 不熟悉,会一时反应不过来 input 去哪了。我在实践中的体会是:point-free 适合流程清晰、每个函数都是单参数的场合;一旦需要中途访问参数以外的变量,或者调试时要打印中间值,就老老实实写回显式参数。优雅是好事,但别让优雅变成阅读理解障碍。

4.3 错误处理也能被组合:Option 与 Either 的短路机制

命令式代码里,错误处理最常见的形态是一连串的 if:

let result; if (input == null) { result = 'empty'; } else if (!isValid(input)) { result = 'invalid'; } else { result = transform(input); }

这种代码写多了,业务逻辑和分支逻辑完全纠缠在一起。函数式风格用 Either 类型编码“可能失败”的计算:Right 代表成功值,Left 代表错误信息。组合链遇到 Left 会自动短路,后续函数不再执行:

pipe( validateInput, // Either<Error, Input> checkPermission, // Either<Error, Input> transformToOutput // Either<Error, Output> );

每一步如果成功就继续往下传,如果失败就带着错误信息停在原地。这个机制让我在重构校验类代码时体会很深:错误处理的骨架被抽象成了容器行为,业务代码里不再有到处穿插的 if else,每个函数只需要回答“我成功或失败”,不用关心整个链路怎么走。这种思维方式上的转变,比语法本身更有价值。

4.4 异步组合:Promise 链本质就是组合

JavaScript 里的 Promise.then 链,其实就是组合的异步形态。每一次 then 都在把上一个 Promise 的结果传给下一个处理函数。理解了这一点,再回头看 async/await 和 pipe 的关系会更通透:

const fetchUserData = pipe( fetchUserById, // (id) => Promise<User> map(toViewModel), // User => ViewModel map(attachMeta) // ViewModel => ViewModel );

这里 map 是在 Promise 容器上应用同步函数,组合链对异步是透明的。实际项目里,我建议把“同步核心流程”和“异步边界”分开:核心业务逻辑尽量用同步纯函数组合,只把网络请求、数据库读写这类异步操作留在链的边界。这样组合链中间部分可以轻易单测,不用每次测试都 mock 异步环境,测试成本会低很多。

5. 实战复盘:用函数组合重构表单校验模块

5.1 命令式校验代码的问题在哪

选一个大家都有体感的例子:表单字段校验。一个常见但有点乱的命令式版本长这样:

function validateUsername(input: string): string[] { const errors: string[] = []; if (!input) { errors.push('用户名不能为空'); } if (input.length < 3 || input.length > 20) { errors.push('用户名长度必须在 3 到 20 之间'); } if (!/^[a-zA-Z0-9_]+$/.test(input)) { errors.push('用户名只能包含字母、数字和下划线'); } return errors; }

问题很清楚:校验规则全写在了一个函数里,新增一条规则就要修改这个函数;想复用一个规则到别的字段,只能复制粘贴;单元测试也只能针对整个函数,规则组合出问题时定位不直观。这就是典型的“规则没有和流程分离”。

5.2 组合式重构:把校验器变成可拼装的积木

组合式改造的第一步,是定义校验器的统一形态。最简单的建模是:

type Validator = (value: string) => string[]; // 返回错误列表,空数组代表通过

然后每个规则是一个独立函数,或者一个“接收参数返回校验器”的工厂函数:

const required: Validator = (value) => value ? [] : ['该字段不能为空']; const lengthBetween = (min: number, max: number): Validator => (value) => value.length >= min && value.length <= max ? [] : [`长度需在 ${min} 到 ${max} 之间`]; const pattern = (regex: RegExp, message: string): Validator => (value) => regex.test(value) ? [] : [message];

接着写一个组合多个校验器的函数,把每一条规则产生的错误都收集起来:

function combineValidators(...validators: Validator[]): Validator { return (value) => validators.flatMap((validate) => validate(value)); } const validateUsername = combineValidators( required, lengthBetween(3, 20), pattern(/^[a-zA-Z0-9_]+$/, '只能包含字母、数字和下划线') );

到这里,校验行为变成了一组可以自由拼装的积木。新增一个字段校验时,不用再写主逻辑,只声明规则即可;规则之间互相独立,可以在其他地方复用。这种重构的收益不是靠花哨语法,而是靠“规则与流程的分离”。

5.3 测试策略:单独测零件,集成测装配

组合式代码的测试策略很清晰:每个规则函数单独测,再用一个集成测试验证组合顺序和错误收集逻辑。

describe('required', () => { it('空字符串应该报错', () => { expect(required('')).toEqual(['该字段不能为空']); }); it('非空字符串应该通过', () => { expect(required('abc')).toEqual([]); }); }); describe('validateUsername', () => { it('多个规则同时失败时收集所有错误', () => { expect(validateUsername('')).toContain('该字段不能为空'); expect(validateUsername('')).toContain('长度需在 3 到 20 之间'); }); it('非法字符应被规则拦截', () => { expect(validateUsername('abc!')).toContain('只能包含字母、数字和下划线'); }); });

这里的经验是:组合的单元测试要在两个层次写。第一层验证单个规则,第二层验证组合顺序和结果聚合。只测单个规则而忽略组合,会漏掉“顺序错了”“错误收集逻辑有 bug”这类问题;只测整体而忽略单个规则,出问题时就只能对着大函数猜。两个层次配合,成本不高,但排障效率会明显提升。

6. 组合实践的常见坑与排查技巧

6.1 组合链不好调试?给链上装一个探针

组合链越长,中间数据长什么样就越难想象。最直接的调试手段是往链里塞一个“探针函数”,打印当前值并原样返回:

const tap = <T>(label: string) => (value: T): T => { console.log(label, value); return value; }; const result = pipe( normalize, tap('after normalize'), validate, tap('after validate'), enrich, format );

这段代码在生产环境记得移除,或者用环境变量控制开关。我一般还会配合浏览器 devtools 的断点来用:在某个组合函数源码处打断点,调用栈会清楚地显示数据走到哪一步断了。切忌一上来就怀疑组合函数实现本身,多数情况下问题都出在某个环节函数对数据形状的假设上,探针一打,立刻见分晓。

6.2 组合失败的三种典型姿势

我把实际踩过的坑归纳成三类,基本覆盖了组合链翻车的主要情形。

第一种是参数个数不匹配。组合默认每个环节是“接收一个值,返回一个值”,遇到多个参数的函数,比如(a, b) => result,直接放进 pipe 会乱套。解决办法是柯里化或提前固定参数:

const add = (x: number) => (y: number) => x + y; // 或者 pipe(getBaseValue, (v) => v + 1);

第二种是返回值类型错位。环节 A 返回 null,环节 B 却期望必填对象,类型系统能拦一部分,但遇到 any 或者运行时数据时,还是要靠探针确认。第三类是同步异步混用。async 函数放进同步 pipe 链里,拿到的是 Promise 而不是真正的值,后续函数会莫名其妙。方案是统一:要么整条链都异步,要么在进入链之前把 Promise resolve 掉。

6.3 过度组合:当优雅变成灾难

函数组合的副作用之一是容易上瘾,症状是:所有代码都被压缩成一行省略号连着省略号的 pipe,十几个函数首尾相接,看起来极简,但任何新同事接手都要对着函数列表猜半天。

我的平衡标准很简单:一个组合链超过五到六个环节,或者中途出现只可意会不可言传的上下文依赖,就该停下来。这时候可以把一段组合抽成一个具名函数:

const buildUserProfile = pipe( normalizeUserData, validateProfile, enrichWithDepartment, formatForDisplay );

具名函数给组合链一个语义封口,读起来像在读目录,而不是钻进每个实现细节。记住:组合是把复杂性封装起来,不是把复杂性堆到一行里。

6.4 性能:组合链到底慢不慢

每次调用 pipe,reduce 都会创建一串嵌套闭包,组合链越长,闭包越多。对绝大多业务代码来说,这点开销可以忽略不计,但在高频热路径,比如每帧执行的渲染循环、超大规模数组遍历里,会变成可测量的开销。

我的态度是:先用组合把代码写清楚,性能问题留给 profiler 说话。真到了瓶颈,再去优化:可以把高频路径上的组合链提前声明成常量,避免每次调用都重新装配;也可以跳过组合,直接内联几个函数调用。工程上的最优解永远是“先正确、后可读、profile 之后再优化”,而不是凭空担心那几微秒。

最后分享一个我反复验证过的经验:组合思维的价值不在于把所有代码都改写成 pipe,而在于帮你养成“流程可以拆解、规则可以装配”的习惯。项目里最能体现这种习惯的地方,往往是那些数据流转清晰、边界明确的模块——校验、格式化、数据清洗、状态映射。在这些地方用组合,收益最直接,也最容易让团队感受到函数式思维的实际好处。如果你刚接触这个概念,不妨从手写一个 pipe 开始,再挑一个小模块做一次组合式重构,体验一次“改一处、全链路类型帮你盯梢”的感觉,你会回来的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进