前端中的 Pipeline | 七日打卡

1,280 阅读4分钟

pipeline

前言

Pipeline 思想在现实生活中很常见,想象一个污水处理过程,来自上游的污水经过“沉淀”、“生物降解”、“加氯”等一系列管道,最终在下游我们得到过滤后的水。

而在计算机领域中,“管道” 也是现代软件工程中一个非常有用的架构模型,最早见于 unix 架构中[1]。在 unix 操作系统,我们可以使用 | 操作符,“链式”调用多个操作,比如:

$ cat /usr/share/dict/words |     # Read in the system's dictionary.
$ grep purple                  # Find words containing 'purple'

一个 Pipeline 过程可以描述为[3]:

  • 每个子流程之间是互相独立的
  • 每个子流程具有约定一致的接口标准(一个子流程的输出作为下一个子流程的输入
  • 一个子流程结束会自动进行下一个子流程
  • 可以通过一定的形式组织多个子流程以解决复杂的问题,比如操作符或者管道函数

在系统设计或者代码设计中,我们总是尽可能地想要达到“高内聚、低耦合”的目标,Pipeline 思想就是尽可能地将复杂的任务切割成一个个小任务并有序执行的过程,因此在实际工作中我们可以合理使用这个思想进行代码组织的优化。

代码应用

  • 假设我们需要实现这样一段逻辑:选择某个对象对象的 text 属性并转化成大写字符串,那么我们可以使用以下代码进行组织
const pick = (obj: object):string => obj.text;
const upperCase = (str: string):string => str.toUpperCase();
upperCase( pick({ name: 'juaner' }) ); // JUANER

这当然是一种简单的情况,但是假如我们在转化之后又要进行其他很多操作,最终的代码可能会变得非常复杂,而且代码组织形式上也不符合我们的理解直觉。最简单的 pipe 实践其实就是用 reduce 组织子过程[4]:

type Process<T> = (input: T) => T
function pipe<T>(...processes: Process<T>[]) {
	return (input: T) => processes.reduce((v, fn) => fn(v), input);
}

通过处理后,前面的代码我们可以表示成以下形式,更加清晰地展示处理的方法和处理的顺序:

pipe(
  pick,
  upperCase
)({ text: 'juaner' });

实际上 TS39[5] 已经有提案,比如 JS 中增加一个操作符|>来处理这种管道化的过程。那么前面的代码可以通过操作符组织成以下形式(看起来很优雅的样子):

let result = { text: 'juaner' }
  |> pick
  |> upperCase;

Rxjs 中的 pipe 思想

在现代 Web 应用特别是数据驱动的应用中,我们可以把页面的所有交互、请求都看成一个个流(同步流/异步流),Rxjs 基于流提供了一系列组织和处理流的操作符用于处理流。

pipe 是 Rxjs 最基础的操作符。使用过 Rxjs 的小伙伴可能会发现 Rxjs5 到 Rxjs6 有一个非常大的变更就是将数据流处理的链式调用过程改为管道调用过程。依旧使用前面的例子,在 Rxjs5 中,你可能需要这样写:

import { Observable } from "rxjs";
const source$ = Observable.of({ text: 'juaner' });
const result$ = source$
				.map(pick)
                	.map(upperCase);

而在 Rxjs6 中,则需要这样写:

import { of } from "rxjs";
import { map } from "rxjs/operators";
const source$ = of({ text: 'juaner' });
const result$ = source$.pipe(
				map(pick),
                	map(upperCase)
                );

看起来区别不大?其实这里很好地实践了“低耦合”“可插拔”思想。

在 v6 之前,链式调用意味着所有操作符都需要绑定在 Observable 类上。在代码打包阶段,由于 Tree Shaking 只能进行静态代码检查,因此,即使是没用到的操作符都不能做删除优化。而且当我们需要删除或者优化某个操作符,都需要影响到全局的 Observable。通过拆分操作符并 pipe 调用,可以很好地隔绝操作符之间的影响,更好地组织优化我们的代码。

Promise.pipe ?

目前,Promise 提供了一些操作多个异步方法的操作符,比如:

  • Promise.all:可以将多个实例组装成一个新的实例,成功的时候返回一个成功数组,失败的时候则返回最先被reject失败状态的值
  • Promise.race:有任意一个返回成功后,就算完成
  • Promise.allSettled(proposal):实例全部成功或者全部失败返回

那么,如果我们想依次调用某个方法呢?尝试封装一个 Promise.pipe

// 非常简略的版本
Promise.prototype.pipe = async function(promises) {
	for(let promise of promises) {
    		await promise;
    }
}

Pipeline 无处不在

pipe 与流的概念如影随形,比如 gulp 的 task stream,nodejs 的 CI/CD pipeline,更多有趣的应用等待我们探索。

参考文章