有段时间我帮同事做代码评审,在一段用户数据处理逻辑里看到了一个“连续剧式”的写法:先声明一个变量接收接口数据,然后 if 判断过滤空值,接着再用一个变量存 map 之后的结果,又用一个变量去补默认字段,最后才把数据返回。逻辑本身不复杂,可变量一多,读代码的人就得在脑子里把每个临时变量接到下个环节,一旦中间改了一处字段结构,后面全部要跟着动。
这种场景就是典型的“函数流水线”能解决的。在 JavaScript 高级编程里,函数流水线的核心是将多个独立函数按顺序连接,让上一个函数输出直接成为下一个函数输入。因为 JavaScript 的函数天然是一等公民,函数可以作为参数传递、作为返回值返回,这个思路才能真正落地。这篇文章目标很明确:拆解函数流水线的本质、手写实现、场景设计、常见坑位,适合已经写过一些 JavaScript 业务代码、却不满足于“能跑就行”的开发者。这一篇先讲同步纯函数版本的流水线,异步和错误处理放在后篇再细拆。
1. 函数流水线到底在解决什么问题
1.1 先从一段连续赋值的糟糕体验说起
大部分业务代码在没有刻意设计时,会长成下面这个样子:
js复制function buildUserList(input) {
let list = input.filter(Boolean);
let normalizedList = list.map((user) => {
return {
name: user.name.trim(),
email: user.email ? user.email.trim().toLowerCase() : "",
};
});
let validList = normalizedList.filter((user) => {
return user.name && isValidEmail(user.email);
});
return validList.map((user) => {
return user.role ? user : { ...user, role: "viewer" };
});
}
这段代码问题不在缩进,而在结构。每一个步骤的结果都存在一个临时变量里,后面一步去消费前一步的变量。本质上它是“线性的”,但没有一个统一的结构来表达“这条线”。临时变量的名字 list、normalizedList、validList 会随着逻辑扩张越来越难起,代码维护者要来回滚动屏幕追踪每一处引用,真正想新增或者删减一个处理环节时,只能在函数体里找到对应位置,手动插入或删除,操作风险不小。
当时这个业务后面要加一个“邮箱脱敏”的环节,同事的做法是在函数体最后又加了一段循环。第二天产品说要调整顺序,优先过滤无效用户再做空值清洗,他不得不再一次打开函数,把中间几段逻辑整块剪切、粘贴。这种经历多了,你自然会思考:能不能把每一步做成一等一的独立函数,再让这些函数“自动接力”?
1.2 函数流水线其实是“函数组合”的业务化叫法
项目里听“函数流水线”比较多,学术上更常见的叫法是函数组合。假设有三个函数 format、validate、save,要表达“先格式化,再校验,最后保存”,最笨的写法是:
js复制save(validate(format(rawData)));
但这种写法必须从内往外读,执行顺序却是从右到左,和中文里“先做 A 再做 B”的心智正好相反。函数流水线要做的,就是把它改写成像业务步骤一样的顺序:
js复制pipe(format, validate, save)(rawData);
pipe 接收一组函数,返回一个新函数。新函数接收初始输入后,让数据像传送带上的零件一样,依次经过 format、validate、save 三个工位,最终吐出结果。每个函数只需要关心自己能拿到的上一个环节返回值,并把结果交给下一个环节,不需要知道整个链路有多少步。
它的本质就是数学里的复合函数 h(g(f(x))),只是换了一种更贴近业务、更容易让同事理解的表达方式。生活里最像的例子是厨房配菜:洗菜、切菜、装盘,每个环节的人只处理前一个环节递过来的东西,做完就放到传送带上。函数流水线把这种“单向流动”的思想搬到了代码里。
1.3 什么样的代码环境才需要上流水线
不是所有函数都要拿流水线包一层。只调用两三个函数、数据关系简单时,直接用普通函数调用反而更快。可一旦出现以下信号,就值得考虑引入:
- 一条数据要依次经历清洗、校验、转换、补默认值、脱敏等多个环节;
- 环节可以被独立测试,也就是每个函数给定同样的输入,输出永远一样;
- 业务流程经常要调整顺序,或者未来很可能插入新步骤。
我在实际项目里见过很典型的数据同步服务,每天从上游拉到原始记录后要连续做“去重、格式标准化、过滤无效、字段映射、落库”。这类代码用流水线组织后,新人接手时只需看管道上列出的函数名,就能在一分钟内理解全局,不用钻进函数体逐行推导。适合人群是很明确的:写过一些原生 JavaScript 或前端框架代码,被复杂数据处理绕晕过,想让自己的函数更容易读、更容易测的人。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 手写一个函数流水线工具,核心只是 reduce
2.1 用循环实现的版本,先建立直觉
很多函数库都有现成的 pipe,比如 Lodash 的 flow、Ramda 的 pipe,但直接用第三方工具前,最好先理解它背后做了什么。自己写一个最直白的版本,只要十行以内。用循环实现:
js复制function pipe(...fns) {
return function (input) {
let result = input;
for (const fn of fns) {
result = fn(result);
}
return result;
};
}
这个版本很容易读:把传入的所有函数收集到数组 fns 中,返回一个新函数。新函数被调用时,用初始 input 作为第一轮结果,然后遍历函数数组,每一轮把当前函数执行后的返回值赋给 result,作为下一轮函数入参,直到全部执行完毕。
第一次看这段代码的人可能会好奇:pipe(format, validate, save) 在调用时明明只写了一次,为什么返回后还能继续传入 rawData?关键在于 pipe 返回的是一个闭包。闭包里保留了 fns 数组的引用,等到外部调用返回的这个新函数时,再真正开始遍历。对闭包不熟的话,可以先把它理解成一个“记住了参数的待执行函数”。
2.2 reduce 版本为什么更值得推荐
循环版本很直观,但我更推荐在真实工具库里使用 reduce 版本,因为 reduce 本身就是为了解决“持续把上一次的累积结果交给下一次处理”这个问题而存在的。代码还能进一步缩短:
js复制const pipe =
(...fns) =>
(input) =>
fns.reduce((result, fn) => fn(result), input);
在这里 reduce 遍历的对象不是普通数组数据,而是函数数组,这一点经常让人懵。普通情况里 reduce 的累积值是数组元素求和或者拼字符串,而这段代码中,fns 是数组,数组里的元素是函数,累积值 result 是上一步函数执行后的返回值。
函数数组这个说法的成立,正是依赖 JavaScript 的函数是一等公民。前面那句“函数可以作为参数传递、作为返回值返回”的抽象描述,在 reduce 里得到了最密集的体现:fn 是数组元素,fn(result) 是函数调用,整个 pipe 最终又返回一个新函数。
我们用一个例子来还原执行现场,先定义两个简单函数:
js复制const addOne = (x) => x + 1;
const double = (x) => x * 2;
调用 pipe(addOne, double)(3),reduce 的每一次迭代是这样的:
| 轮次 | 上一轮累积值 result | 当前函数 fn | 本轮返回值 |
|---|---|---|---|
| 1 | 3 | addOne | 4 |
| 2 | 4 | double | 8 |
所以 pipe(addOne, double)(3) 的结果是 8,计算顺序是 (3 + 1) * 2。如果调换顺序变成 pipe(double, addOne)(3),第一轮 3*2 = 6,第二轮 6+1 = 7,结果就完全不同。这一步想说明的是:函数流水线的执行顺序严格由参数顺序决定,这也是它最大的优点与最大的风险来源。
2.3 反向版本 compose,以及怎么选择
流水线的数据方向也可以反过来,写一个从右向左执行的 compose。它用 reduceRight 实现:
js复制const compose =
(...fns) =>
(input) =>
fns.reduceRight((result, fn) => fn(result), input);
调用 compose(addOne, double)(3) 时,会先从右侧函数开始,先执行 double(3) 得到 6,再执行 addOne(6) 得到 7。数学上的复合函数 f(g(x)) 习惯从右往左书写,Ramda、Lodash 的 flowRight 沿用了这套规则。但团队协作时,从右往左的阅读顺序并不符合大多数人“先发生的写在前面”的直觉。我自己实践下来的经验是,除非项目里已经大量使用 Ramda 或数学式组合,否则默认统一使用 pipe 就好。选择不重要的前提是团队必须只选一个,混用 pipe 和 compose 会让代码里同时出现两种方向,读起来非常痛苦。
2.4 多参函数怎么进流水线,柯里化是把钥匙
reduce 版本隐含了一个约束:除第一个函数接收初始输入外,中间的每个函数最好只接收一个参数。这个约束不是凭空制造的,而是为了让前一个输出的“单值”能够稳定对接后一个函数。但实际业务里经常出现 addRole(user, role) 这样需要传两个参数的函数,直接放进管道会导致 role 变成 undefined。
常用的解决办法是包一层箭头函数:
js复制const buildUser = pipe(normalize, (user) => addRole(user, "admin"));
也可以把函数柯里化,让多参函数拆成连续的单参调用:
js复制const addRole = (role) => (user) => ({ ...user, role });
const addAdminRole = addRole("admin");
const buildUser = pipe(normalize, addAdminRole);
柯里化后的 addRole("admin") 返回一个新函数,新函数只等 user 这一个参数,天然适合放进流水线。这中间的关键点是,柯里化不是为了让函数少写几个字,而是为了让“配置参数”和“处理数据”分离,配置参数可以在管线定义阶段完成,处理数据则在数据流经时执行。如果只是偶尔一次的多参场景,箭头函数包一层就够了,不必为了柯里化而强行柯里化。
3. 实战拆解:用函数流水线重构用户数据处理
3.1 需求背景与最朴素的实现
下面用一个相对完整的例子说明整个设计过程。需求是:接口可能返回不规则的用户列表,里面混着 null、名字带空格、邮箱大小写不统一、缺少默认角色,甚至邮箱是无效格式。最终要返回一个清洗后的用户数组,无效记录需要被过滤,如果该用户原本没有角色,则自动补上 viewer,已经存在的角色不能被覆盖。
上游数据的可能形态:
js复制const originList = [
{ name: " 张三 ", email: "ZHANGSAN@EXAMPLE.COM " },
null,
{ name: " ", email: "not-an-email" },
{ name: " 李四 ", email: "lisi@example.com", role: "admin" },
];
期望得到的输出是:
js复制[
{ name: "张三", email: "zhangsan@example.com", role: "viewer" },
{ name: "李四", email: "lisi@example.com", role: "admin" },
];
第三位用户因为邮箱格式无效被过滤,空指针记录在第一轮被丢弃。如果不用函数流水线,很多同期同事会直接在主函数里写一大段数组链式调用,也能得到结果:
js复制function buildUserList(rawList) {
return rawList
.filter(Boolean)
.map((user) => ({
...user,
name: String(user.name || "").trim(),
email: String(user.email || "").trim().toLowerCase(),
}))
.filter((user) => user.name.length > 0 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(user.email))
.map((user) => (user.role ? user : { ...user, role: "viewer" }));
}
这段代码的可读性其实已经比全变量版本好,filter/map/filter/map 让人能猜到步骤。但问题也很清楚:第一个 filter(Boolean) 干了“去空值”,第二个 filter 干了“校验有效性”,两个 filter 之间夹着 map,后续要想在“名字清洗后、邮箱校验前”插入新处理,只能再次进入函数体修改链式调用,加一个 .map 或者 .filter。函数内部的每一步都没有独立命名,难以单独单元测试。
3.2 先把每个处理环节抽成独立函数
要让流水线发挥作用,第一步不是急着写 pipe,而是抽函数。先后把用户数据处理拆成四个纯函数:清洗原始列表、标准化用户字段、校验邮箱有效性、补默认角色。
js复制function compact(list) {
return list.filter((item) => item && typeof item === "object");
}
function normalizeUser(rawUser) {
return {
...rawUser,
name: String(rawUser.name || "").trim(),
email: String(rawUser.email || "").trim().toLowerCase(),
};
}
function hasValidEmail(user) {
return user.name.length > 0 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(user.email);
}
function addDefaultRole(user) {
if (user.role) {
return user;
}
return { ...user, role: "viewer" };
}
这四个函数都符合一个共同契约:输入一种数据,输出一种数据,不依赖外部状态。normalizeUser 不管上下游,只负责把传入的用户字段整理干净;addDefaultRole 只需要知道 user.role 是否为空。注意我保留了 ...rawUser,否则清洗过程会把用户原有字段丢掉,这是一个很容易踩的细节。
3.3 用高阶函数给数组方法做适配
但上面这些函数里,compact 处理的是整个数组,normalizeUser 和 addDefaultRole 处理的是单个对象,hasValidEmail 输入也是单个对象。如果直接用 pipe(compact, normalizeUser, hasValidEmail, addDefaultRole),数据经过 compact 后是一个数组,传给 normalizeUser 时它会被当成单个用户对象处理,得到的结果完全不对。
中间需要一个适配层,让“单用户处理函数”能自动作用于数组中的每一项。常用的写法是构造两个高阶函数 map 和 filter,让它们接收处理函数后返回一个新函数,这个新函数等数组进来:
js复制const map = (fn) => (arr) => arr.map(fn);
const filter = (fn) => (arr) => arr.filter(fn);
map(normalizeUser) 的意思是“我要构造一个能把数组每一项都经过 normalizeUser 处理的转换器”。现在可以组装真正的生产线了:
js复制const buildUserList = pipe(
compact,
map(normalizeUser),
filter(hasValidEmail),
map(addDefaultRole)
);
const result = buildUserList(originList);
整条代码读起来就像一个需求描述:压缩空记录、标准化每个字段、过滤掉无效邮箱、给缺少角色的用户补默认角色。函数名字本身就是流程文档。
3.4 数据在流水线中每一站的真实变化
在接收别人写的流水线代码时,最怕一眼看不到数据在中间每一步怎么变。我们直接把原始输入放进这条管道,看每一步的中间态。
| 步骤 | 数据内容 |
|---|---|
| 原始输入 | [ { name:" 张三 ", email:"ZHANGSAN@EXAMPLE.COM " }, null, { name:" ", email:"not-an-email" }, { name:" 李四 ", email:"lisi@example.com", role:"admin" } ] |
| pipe 初始输入 | compact 收到整个数组 |
| 经过 compact | 丢弃 null,只剩三个对象 |
| 经过 map(normalizeUser) | 所有 name 清理空格,email 转小写去空格 |
| 经过 filter(hasValidEmail) | 邮箱无效的第三位用户被过滤,只剩张三和李四 |
| 经过 map(addDefaultRole) | 张三没有 role,补 viewer;李四原本有 admin,保持不变 |
最终输出与预期一致。这里最重要的一点是,hasValidEmail 是一个过滤函数,它返回布尔值,所以放进 filter(...) 而不是 map(...)。如果放错位置,把它塞进 map,每一轮拿到的结果是布尔值,下一步会把布尔值当成用户处理,整个流程瞬间乱掉。这种类型不匹配在运行时往往不会立刻抛异常,而是会产出奇怪数据,需要格外小心。
4. 拆解流水线过程中的常见坑与排查技巧
4.1 某一步忘记 return,结果成了 undefined
JavaScript 函数如果没写 return,默认返回 undefined。一旦流水线里的某一环节忘了返回处理结果,下一环节拿到的就是 undefined。如果下一环节刚好在访问属性,会立刻报出类似 Cannot read properties of undefined 的错。这种错误相对好查,因为报错信息指向了用户代码里具体的位置。
更危险的情况是下一环节的函数对 undefined 做了兼容,例如 (user) => user?.name || "anonymous",那么错误会被静默吞掉,直到最终数据里出现一批“anonymous”用户,你才意识到链路中间断了。排查这类问题的第一建议是给每个环节加一个追踪函数,也就是业内常说的 trace:
js复制const trace = (label) => (value) => {
console.log(`${label}:`, value);
return value;
};
把 trace 插到任意两个环节之间,就能看到数据经过该环节后的实际形态:
js复制const buildUserList = pipe(
compact,
trace("after compact"),
map(normalizeUser),
trace("after normalize"),
filter(hasValidEmail),
map(addDefaultRole)
);
trace 做的事很朴素:打印当前值,然后原样把值返回给下一个环节。看起来简单,但在排查流水线问题时比断点调试还高效,因为你可以一次看到数据从第一站流到最后一站的全貌。生产环境记得把这类日志调用移除,或者用一个可配置的 logger 替换 console.log,否则每次调用流水线都会刷屏。
4.2 副作用混进中间环节,让函数不再“可预测”
流水线能顺畅工作的前提是环节函数尽量“纯”。所谓纯函数,就是相同输入永远得到相同输出,过程中不改变外部状态、不读外部可变数据。写日志、发送埋点、写入数据库、修改全局缓存,这些都是副作用。
假设你在流水线中间加了一个 saveToDatabase 环节:
js复制const saveToDatabase = (user) => {
db.insert(user);
return user;
};
表面上数据确实从 saveToDatabase 流到了下一站,但这条管线从此有了对外部数据库的依赖。本地单元测试跑一次会自动写一条数据到库里,测试完还得清理;并发调用时数据库连接失败会让整条流水线中断。我见过更隐蔽的副作用是在数据处理函数里偷偷改了一个 Map 缓存,第二次调用同一函数时结果基于缓存变化,导致重复执行结果不一致。
在设计流水线时,我建议把副作用函数放在整条管道的最外层或终点。如果一定要在中间执行,也要明确它只是“路过留痕”,不能依赖副作用执行完的外部状态来指导后续计算。换句话说,流水线的计算过程尽量保持明确和可预测,真正需要落库、发通知的动作,等在流水线末端拿到最终结果后统一处理。
4.3 方法里的 this 被偷走了
在对象方法作为流水线环节时,很容易踩 this 丢失的坑。拿类方法举例:
js复制class UserFormatter {
constructor(prefix) {
this.prefix = prefix;
}
format(user) {
return { ...user, displayName: this.prefix + user.name };
}
}
const formatter = new UserFormatter("[用户]");
const formatUser = formatter.format;
pipe(normalizeUser, formatUser)(user);
调用 pipe 时,formatter.format 被作为普通函数传入,这个函数内部使用了 this。当它在流水线里被调用时,this 已经不再指向 formatter,于是要么抛出 Cannot read properties of undefined,要么得到错误结果。这是因为 JavaScript 里方法的 this 指向调用点决定,把方法单独取出来调用时,原来的对象语境就丢了。
解决办法有三种:在放进去之前用 bind 绑好:
js复制const formatUser = formatter.format.bind(formatter);
或者在构造类时就绑定方法,也可以在调用时包一层箭头函数:
js复制pipe(normalizeUser, (user) => formatter.format(user))(user);
但我的建议是尽量别把带 this 的对象方法直接塞进流水线。类方法天然绑定对象状态,和流水线的纯函数风格并不契合,不如把这个方法重构成一个接收 user 和 prefix 两个参数的普通纯函数,或者把所需的 prefix 通过柯里化提前注入。
4.4 流水线也可能被用过头,变成“箭头套娃”
并不是所有代码都适合流水线化。遇到只有两个环节、中间又要根据条件不同走不同分支的场景,硬套 pipe 反而会增加阅读负担。最典型的问题是,为了让某个带多参数的函数能放进管线,在外面包了很多层箭头函数:
js复制pipe(
validate,
(user) => transform(user, optionsObj),
(user) => {
if (user.type === "admin") return handleAdmin(user);
return user;
},
notify
);
中间包裹的箭头函数越多,读者越要跳进箭头函数里看它到底包了什么逻辑,流水线的“快速浏览”价值也就被稀释了。判断是否需要上流水线的标准,我会同时问自己两个问题:这个数据流是不是单向线性的?每一步是否都是可命名、可独立测试的函数?如果两个回答都是否,那它可能更适合用普通函数、if 分支或者更专门的状态机来表达。技术选型永远服务于可读性,不是为了展示 reduce 用得有多溜。
5. 函数流水线工具选型与个人实践心得
5.1 现成库到底用不用,我的选择是做减法
不少经典函数库都提供组合工具。Lodash 的 flow 和 flowRight 可以直接用,Ramda 里则叫 pipe 和 compose,函数式编程生态里的 fp-ts 也有自己的 pipe 函数。使用现成库的好处是处理边界情况更全面,例如有的版本会处理参数个数为 0 或者函数数组为空的情况,并做类型检查。
可我在实际项目里倾向于不为了这一个函数引一个大库。一个完整的 pipe 实现可能不超过五到十行代码,放在自己的 utils 文件夹里,维护成本极低。等到真的需要复杂管线能力,比如要支持异步、要感知每个环节的异常、要做可中断的流水线时,再考虑引入成熟方案也不迟。
话虽如此,如果项目里已经在用 Lodash,那直接用 _.flow 是完全合理的,没必要重新造轮子。这里的原则是“顺手”,而不是“为了手写而手写”。自己实现更能理解原理,但不代表每个项目都要从零开始写。
5.2 我踩过几次坑之后总结出的流水线使用原则
分享几条我现在写代码和做评审时会提醒自己、也会提醒同事的原则。
- 优先抽纯函数,再考虑组合。先把链路里的每个处理环节都写成不依赖外部状态的函数,让它们具备可测性,之后再组装成流水线。
- 让每一步函数都尽量保持“一进一出”的简单形态。如果某个函数既要校验又要转换还要触发埋点,先把它拆开,而不是塞给流水线。
- 数据形态在环节之间要按类型流动。要格外小心数组和对象之间的切换,这几乎是我见过最多的流水线 bug 来源。
- 不排斥箭头函数包装,但包装层数最好不超过一层。超过两层,就要思考是不是函数抽象出了问题。
在实际写代码时,我最看重的标准是:这段逻辑能不能靠一列函数名就读懂。函数流水线让我不用写大段注释去解释“先做了什么、再做了什么”,因为处理环节的名称已经按顺序写在管道上。遇到那些超过三步、数据形态单向流动、环节又可以被独立验证的处理任务,我会直接考虑这个写法。如果链路里第一步就是异步请求、中间还要处理失败重试,那就要给流水线补上异步包装和错误处理机制,这一块我会在下一篇里继续拆。
