1. 从“能跑但很难改”的嵌套代码说起
1.1 为什么连续转换会被写成从里到外
我经常在代码评审里看到这种场面:明明要做的事情很直接——把原始数据从这个形状变成那个形状,再过滤掉一批脏数据,最后整理成展示层需要的结构。但代码一落地,就变成了一团嵌套括号。
javascript复制const rawText = " Apple, Banana , Cherry, apple ";
const result = removeStopWords(splitByComma(removeSpace(rawText)));
这段代码执行起来没有任何问题。removeSpace 先把空格清掉,splitByComma 把字符串切分,removeStopWords 再去掉无意义的词。但你冷静读一下,会发现一行代码里塞了三个函数的执行顺序问题。JavaScript 对实参的求值是从内往外,代码写出来却是从左往右,于是人的阅读大脑和 V8 引擎的求值方向天然就拧着。
我见过最夸张的版本,是七八个函数套在一起,每个函数都有自己的选项参数和默认值。真正要排查数据在哪一步被污染,得先在脑内手动把括号一层层剥开。修一个边界条件,感觉像在拆线头,拆错一次就把别的逻辑带偏。
这就是函数流水线要解决的问题。它不改变函数的本质,只改变你组织函数的方式:把“一个函数套另一个函数”变成“一个函数接另一个函数”,让每一步的输出都成为下一步的输入。
1.2 中间变量方案:不是不好,而是要看清代价
很多人第一次想摆脱嵌套括号,本能反应就是拆中间变量。
javascript复制const cleaned = removeSpace(rawText);
const words = splitByComma(cleaned);
const result = removeStopWords(words);
这当然比嵌套可读。但我后来发现,当流程拉长以后,中间变量会带来另一层烦恼:这些变量会被外部作用域接管,如果一个数据在中间步骤被复用了两三次,变量之间的依赖关系就开始乱掉。更现实的是,大家都在同一个函数里维护,偶尔还会不小心覆盖了别人定义的变量,或者把后期逻辑接在了一个“已经不打算再用的中间产物”上。
流水线的做法是把步骤收拢成一个数组、一段列表。数据从第一段进入,从最后一段出来,中间产物被锁在函数内部,根本不暴露给外部作用域。它更适合表达那种“线性推进”的数据处理流程,比如字段清洗、规则校验、格式标准化、列表转化。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从 map/filter/reduce 中发现流水线模型
2.1 数组自带方法链就是最简单的流水线
熟悉数组操作的人,其实早就在用流水线思想,只是一般把它叫“链式调用”。
javascript复制const users = [
{ active: true, age: 18, name: 'A' },
{ active: false, age: 25, name: 'B' },
{ active: true, age: 30, name: 'C' }
];
const activeUsers = users
.filter((user) => user.active)
.map((user) => ({ ...user, group: 'VIP' }))
.sort((a, b) => a.age - b.age);
每个方法都接收一个数组,返回一个新数组,后一个方法会马上收到前一个方法的结果。这个模式的关键是什么?是顺序。filter 先筛人,map 再加工人,sort 最后排人。如果你调换一下 filter 和 map,大多数情况也能跑,但含义就完全变了——因为数据在每一步之后已经变了一轮,后面的人看到的字段可能就不一样了。
数组链式调用的问题也很明显:它把所有能力绑在了数组这个具体类型上。一旦你的步骤不是“数组进、数组出”,而是对象进、字段出,数组方法链就用不上了。真正通用的机制其实藏在 reduce 里面。
2.2 reduce 其实是一个“微缩流水线”
reduce 是数组方法里抽象程度最高的那个。它接收一个累加器,然后对数组里的每个元素执行同一个函数,不断更新累加器。
javascript复制const list = [1, 2, 3, 4];
const total = list.reduce((acc, n) => acc + n, 0);
看起来只是在求和,但实际上,reduce 做的事情和“让数据经过一串函数”非常像。如果把累加器想象成数据当前的值,把数组想象成步骤清单,那每一步都在做同一件事:拿上一个步骤的值,经过当前函数处理,再交给下一步。
我第一次意识到这一点,是看到有人用一个函数数组加 reduce 把流程串了起来:
javascript复制function runPipeline(input, steps) {
return steps.reduce((currentValue, stepFn) => stepFn(currentValue), input);
}
这行代码虽然简单,但它把流水线最核心的机制挑明了:任何一次转换,本质上都是“上一次结果传入下一个函数”。只要 currentValue 的类型和 stepFn 的参数类型对得上,这个机制就不局限于数组。它可以处理字符串、数字、对象、甚至 Promise。
我后来在项目里反复用这个模式,越用越觉得,很多看起来高级的函数式写法,内里都只是 reduce 的变形。有人爱说高阶函数、函数组合,其实拆开来看,就是“一个函数数组 + 累加器的循环”。
3. 从 reduce 到正式的 pipe/compose 组合子
3.1 pipe 的源码只有十几行,难的是理解闭包里的执行时机
现在我们把上面的 runPipeline 再升级一层,做一个通用 pipe。
javascript复制function pipe(...steps) {
return function run(input) {
return steps.reduce((value, stepFn) => stepFn(value), input);
};
}
用起来长这样:
javascript复制const removeSpace = (text) => text.replace(/\s+/g, '');
const splitByComma = (text) => text.split(',');
const toLowerCaseAll = (words) => words.map((word) => word.toLowerCase());
const normalizeText = pipe(
removeSpace,
splitByComma,
toLowerCaseAll
);
const result = normalizeText(" Apple, Banana , Cherry ");
// result => ["apple", "banana", "cherry"]
注意看这里是两层函数,不是一层。pipe(removeSpace, splitByComma, toLowerCaseAll) 返回的是 run 函数,步骤数组被关在闭包里。真正的计算是在后面调 normalizeText("...") 时才开始。这个“延迟计算”不等于不计算,而是把你的完整流程拆成了“定义阶段”和“执行阶段”。
为什么要这样设计?因为定义和执行分离以后,流水线本身变成了一个可传递的复合函数。你可以把它当作普通函数传给别人,或者在多个地方复用同一个流水线,甚至还能继续往前面、后面追加步骤。
javascript复制const addEmoji = (words) => words.map((word) => word + '!');
const finalize = pipe(
normalizeText,
addEmoji
);
finalize 的内部已经包含了 normalizeText。这种能力用嵌套写很难维护,用 array reduce 临时串也不好复用,用 pipe 则是一张天然的“接线图”。
3.2 compose 的倒序不是故意难为你,它尊重数学习惯
和 pipe 经常一起出现的还有 compose。差别只有一行:
javascript复制function compose(...steps) {
return function run(input) {
return steps.reduceRight((value, stepFn) => stepFn(value), input);
};
}
reduce 从左往右执行,reduceRight 从右往左执行。数学上描述 f(g(x)) 的时候,我们习惯说“先 g 后 f”。而 compose 正好让最后一个函数先执行,所以它特别贴近教科书里的复合函数写法。
但实际业务代码里,我几乎总是优先用 pipe,不首选 compose。原因不是 compose 不对,是我自己读代码时习惯从左往右扫。看到 pipe(a, b, c),我立刻明白数据先经过 a,再经过 b,最后到 c。看到 compose(a, b, c),我得先想一下“哦,原来 c 先跑”,如果这个文件已经写了很长时间,头脑要多转一个弯。
项目里如果只有我一个人写函数式代码,也就算了。但如果团队里有接触少一点的同事,我认为尽量让代码的执行顺序和阅读顺序一致,是更稳妥的选择。这也是我在本文里集中写 pipe 的原因。
4. 柯里化不是炫技,它是单参数管道得以衔接的接插件
4.1 多参数函数进入管道前,要先做一层“参数固化”
看到这里,你可能已经发现一个潜在痛点:pipe 里的每个 stepFn 都只接收一个参数。可真实业务里的函数动不动就要传配置、传选项,怎么办?
比如要给商品价格加税,税率可能是外来的。如果写成一个普通函数:
javascript复制function addTax(rate, price) {
return Math.round(price * (1 + rate));
}
放到 pipe 里就麻烦了,因为 stepFn 只能拿到前一步的数据,它没有地方再额外接收一个 rate。除非我们在中间包一层箭头函数:
javascript复制const calcPrice = pipe(
(price) => addTax(0.06, price),
toDisplay
);
这当然能用。但更符合流水线思路的做法,是把函数改成柯里化形式:
javascript复制const addTax = (rate) => (price) => Math.round(price * (1 + rate));
const calcPrice = pipe(
addTax(0.06),
toDisplay
);
第一次调用 addTax(0.06) 并不会真正开始计算税率,它只是把 0.06 存在闭包里,返回一个等待价格数据的函数。这个返回出来的函数,正好符合流水线对“单参数步骤函数”的要求。
所以我一直觉得,柯里化不是语言层面的炫技,它更像是流水线的接插件。它让你把步骤里的“业务配置”和“流经的数据”拆开。配置可以在组装流水线的时候就固化,数据则在实际调用时才流进来。相当于把一条生产线的设备参数事先调好,只等原料到位。
4.2 data-last 习惯为什么更适合函数组合
现在再看“数据参数放最后”这件事。很多 JavaScript 原生方法都是数据在前,比如 parseInt(text, radix)。如果你想把 parseInt 接进一条字符串清洗链,就会很别扭:
javascript复制const parseAsInt = pipe(
trim,
(text) => parseInt(text, 10)
);
这行代码里多包了一层函数,就是因为 parseInt 把 text 放在了 radix 前面。当我们自己定义函数,并且希望它将来能被组合时,最好写成“前参数是配置,最后一个参数是数据”。
javascript复制const parseAsInt = (radix) => (text) => parseInt(text, radix);
这样 parseAsInt(10) 返回的就是一个干净的步骤函数,可以直接放到任何管道里。很多人第一次接触函数式库时,会看到 data-last 和数据优先的争论,总觉得这是风格洁癖。等真的开始组装函数流水线,才发现数据参数的位置直接决定了整个管道能不能顺畅组装。它不是品位问题,是接口设计问题。
5. 真实例子:fetch 拿回来的列表,如何一步步变成页面可用数据
5.1 先画步骤图,再写代码
我习惯在动手写流水线之前,先用注释或者白板把步骤写出来。比如一个常见场景:从接口拿到一批商品,要清洗后展示。
我们期望的流程是:
- 把每个商品对象的字段规范化。
- 过滤掉价格非法或库存为负的商品。
- 按价格从低到高排序。
- 渲染到页面上。
在没有 pipe 之前,常规写法可能是先定义一个巨大的函数,在里面连续写好几个临时数组。有了 pipe,步骤之间的关系能写得很直白:
javascript复制const normalizeProduct = (product) => ({
...product,
priceCents: Math.round(Number(product.price) * 100),
stock: Number(product.stock) || 0,
});
const isValidProduct = (product) =>
Number.isFinite(product.priceCents) && product.stock >= 0;
const cleanProductList = pipe(
(list) => list.map(normalizeProduct),
(list) => list.filter(isValidProduct),
(list) => list.sort((a, b) => a.priceCents - b.priceCents)
);
你可能会问,这不就是用数组方法链加个外壳吗?没太大区别啊。对啊,区别本来就不在写起来的观感,而是在“步骤可以被拿去测试和替换”。数组方法链只能当场执行;而 cleanProductList 是一个独立函数,它可以被命名、被导出、被传进 async 流程。
在调用层,它甚至能直接接在异步流程后面:
javascript复制async function loadProducts() {
const response = await fetch('/api/products');
const raw = await response.json();
const products = cleanProductList(raw);
renderProductList(products);
}
这段代码的亮点是 fetch 负责异步拿数据,cleanProductList 负责同步做清洗转换。两者不混在一起,职责很清晰。万一前端以后要接缓存或本地 mock,只需要替换数据来源,清洗管线完全不需要动。
5.2 为什么组件化改造后,单元测试反而好写
以前这段商品处理逻辑如果写在组件内部,测试时就要 mock 请求、mock 渲染层,再把一堆临时数组状态考虑进去。当我把 normalizeProduct、isValidProduct 和 cleanProductList 拆成独立的管道成员之后,测试就变成了单纯地喂数据、看结果:
javascript复制// 下列代码可以在 Node 测试环境中直接运行
const rawList = [
{ name: '笔', price: 'bad', stock: 1 },
{ name: '纸', price: '3.5', stock: 5 },
{ name: '盒子', price: '10', stock: 0 }
];
const output = cleanProductList(rawList);
console.log(output);
凡是 price 转不成数字的商品会被过滤掉,库存为 0 但价格合法的商品会保留,顺序按价格排序。每一步都符合直觉,单独改某一条清洗规则,也不会影响其他规则。这就是拆流水线最实在的收益:它逼着你把每一步逻辑压小、压纯粹,而纯粹的函数天然就适合测试。
有人担心拆太多会影响执行效率。我的看法是:在绝大多数管理后台、内容页、配置平台场景里,一次清洗几百上千条数据,多循环几轮根本不是瓶颈。真正让人头疼的从来不是那几百毫秒,而是逻辑改错之后在线上排查两小时。在数据量大到需要优化之前,不要为了省一次循环去写一坨又长又难读的函数。
6. 管道引入的几个隐蔽问题:探针、引用、执行顺序
6.1 调试时先挂一个 tap 探针
流水线把中间变量封在函数内部后,调试反倒需要换个思路。以前你可以 console.log(cleaned),现在中间变量没那么容易被外部访问。解决办法是加一个“探针”步骤,它不改变数据,只负责打印:
javascript复制const tap = (label) => (value) => {
console.log(label, value);
return value;
};
const debugPipeline = pipe(
removeSpace,
tap('after removeSpace'),
splitByComma,
tap('after splitByComma'),
toLowerCaseAll,
tap('after toLowerCaseAll')
);
这个 tap 真的很像一条生产线上临时接出来的一块观测屏。我可以看看每段管道流出的是什么类型、什么形状。排查完了,把 tap 从数组里移掉就算清理干净,不影响其他步骤。
在复杂排查中,我还会把探针往上提一步,直接在具体业务函数外面包一层日志,比如 tap('normalizeProduct: ' + index)。实际经验告诉我,大多数流水线 bug 并不在函数逻辑本身,而是某一步返回的数据形状和下一步预期不一致。你只要把断点放在两个 step 之间,通常一眼就能看到节点在哪断掉。
6.2 最坑的是在 step 里直接改原对象,然后返回同一个引用
流水线思想里,每一步最好把输入当作“给定值”,不要随便改动它。如果你在 step 里把入参数对象改了字段,又返回同一个对象,表面上每一步都正常往下传,实际上所有步骤都在操作同一个对象的历史版本。排查时你会看到数据在某一个 step 里突然带上了后面的印记,非常让人困惑。
javascript复制// 不推荐:直接修改传入对象并返回原引用
const markDiscount = (product) => {
product.price = Math.round(product.price * 0.8);
return product;
};
// 推荐:返回新对象
const markDiscount = (product) => ({
...product,
price: Math.round(product.price * 0.8),
});
不要觉得“前端项目里对象又不大,改一下怎么了”。一旦你的管道里有多个步骤,后面步骤可能依赖前面的计算结果,如果中间步骤不小心改了原始引用,后面拿到的是“被改过的值”,但排查的时候又很难靠时间线看出来。这就像一条流水线上,同一个零件被多个工位反复刻标记,最后根本分不清是哪道工序留下的痕迹。保持不修改输入,只返回新结果,是让函数流水线可预测的底线。
6.3 流水线不是万能图纸:这些场景先别硬套
我说了这么多好处,也得说说什么场景不要硬套。
当一段处理逻辑包含多个强分支时,流水线就不一定合适。比如“如果用户是会员走会员价,否则走原价,会员还要再判断是否黑名单用户”。这种流程本质上是一棵决策树,不是一条单行道。强行把判断塞进 pipe 里,最后只会变成一堆包着逻辑的 step,可读性反而下降。
还有一个典型场景是“需要多次使用同一个中间结果”。例如你算出了订单的原始金额,后面既要展示原始金额,又要计算折扣金额,还要算税费。这时候让每一步都只接收上一个结果,是不自然的。你可以把数据包装成 context 对象,让每一步读取多个字段、更新多个字段,也可以直接放弃用 pipe,改用正常的普通函数组合。不要为了统一风格牺牲直观性。
我自己的判断标准很简单:如果你能用一句话把流程说清楚,而且流程里没有复杂回环,流水线值得用;如果你要用“如果……否则……”说三遍,就应该退回普通条件逻辑。
至于“函数流水线 上”里没讲到的异步管道、错误处理、并发编排,我在下一篇会展开说。你如果想提前体会,可以先做一个小练习:把上面 cleanProductList 里的纯函数换成 async 函数,再看看 pipe 的返回结果会变成什么。那正是异步流水线要解决的入口问题。
