JavaScript 函数式管道的简单解释
有时我会被问到为什么 RxJS 中不再使用“点链”,或者为什么 RxJS 转而使用pipe
。原因有很多,但这确实需要从比 RxJS 更高的层次来看待。
管道函数的需求源于两个相互冲突的问题:希望拥有一套适用于简单类型(如 Array、Observable、Promise 等)的广泛可用开发 API,以及希望发布更小的应用程序。
尺寸问题
JavaScript 是一种非常独特的语言,但它有一个大多数其他编程语言都没有的问题:通常,JavaScript 是在用户想要使用 JavaScript 所驱动的应用时通过网络传输、解析和执行的。传输的 JavaScript 越多,下载和解析所需的时间就越长,从而降低应用的响应速度。这会对用户体验造成巨大的影响。
这意味着,努力保持 JavaScript 应用的精简至关重要。幸运的是,如今我们有很多优秀的工具可以做到这一点。我们有很多“构建时”打包器和优化器,它们可以执行诸如 tree-shaking 之类的操作,以便在构建时提前清除未使用的代码,从而尽可能减少 JavaScript 代码量。
不幸的是,如果不能静态地确保代码没有在某处被使用,那么摇树就不会删除代码。
提供广泛的 API
为了使类型尽可能实用,最好将一组精心设计的已知功能附加到该类型上。特别是这样,可以通过从左到右对该类型进行调用来实现“链式调用”。
JavaScript 为给定类型提供广泛 API 的“内置”方式是原型扩充。这意味着你可以向任何给定类型的prototype
对象添加方法。因此,如果我们想向数组添加自定义odds
过滤器,可以这样做:
Array.prototype.odds = function() {
return this.filter(x => x % 2 === 1)
}
Array.prototype.double = function () {
return this.map(x => x + x);
}
Array.prototype.log = function () {
this.forEach(x => console.log(x));
return this;
}
原型增强存在问题
修改全局变量。你现在操作的是其他人都能接触到的东西。这意味着其他代码可能会依赖这个odds
方法在 上执行Array
,而不知道它实际上来自第三方。这也意味着另一段代码可能会通过odds
它自己定义的来破坏它odds
。有一些解决方案,比如使用Symbol
,但这仍然不是理想的选择。
原型方法无法进行 tree-shaking。打包器目前不会尝试移除已添加到原型的未使用方法。具体原因请参见上文。打包器无法获知是否有第三方依赖该原型方法。
函数式编程 FTW!
一旦你意识到上下文this
实际上只是将另一个参数传递给函数的一种奇特方式,你就会意识到你可以像这样重写上述方法:
function odds(array) {
return array.filter(x => x % 2 === 0);
}
function double(array) {
return array.map(x => x + x);
}
function log(array) {
array.forEach(x => console.log(x));
return array;
}
现在的问题是,您必须从右到左读取数组中发生的事情,而不是从左到右:
// Yuck!
log(double(odds([1, 2, 3, 4, 5])))
不过,优点是,如果我们不使用它double
,那么捆绑器将能够进行树形摇动并double
从发送给用户的最终结果中删除该功能,从而使您的应用程序更小、更快。
管道设计,方便从左到右阅读
为了获得更好的从左到右的可读性,我们可以使用pipe
函数。这是一个常见的函数模式,可以用一个简单的函数来实现:
function pipe(...fns) {
return (arg) => fns.reduce((prev, fn) => fn(prev), arg);
}
它的作用是返回一个新的高阶函数,该函数接受一个参数。返回的函数会将参数传递给函数列表中的第一个函数fns
,然后获取其结果,并将其传递给列表中的下一个函数,依此类推。
这意味着我们现在可以从左到右编写这些内容,这样更易读一些:
pipe(odds, double, log)([1, 2, 3, 4, 5])
您还可以创建一个助手,允许您提供该参数作为第一个参数,以使其更具可读性(如果可重用性稍差),如下所示:
function pipeWith(arg, ...fns) {
return pipe(...fns)(arg);
}
pipeWith([1, 2, 3, 4, 5], odds, double, log);
对于pipeWith
,现在它将获取第一个参数,并将其传递给参数列表中紧接着它的函数,然后它将获取其结果并将其传递给参数列表中的下一个函数,依此类推。
带有参数的“可管道化”函数
要创建一个可以通过管道传输但带有参数的函数,只需使用高阶函数即可。例如,如果我们想创建一个multiplyBy
函数来代替double
:
pipeWith([1, 2, 3, 4, 5], odds, multiplyBy(2), log);
function multiplyBy(x) {
return (array) => array.map(n => n * x);
}
作品
因为它们只是函数,所以您可以通过pipe
创建其他可重用和可管道化的函数来简化代码并使其更具可读性!
const tripleTheOdds = pipe(odds, multiplyBy(3));
pipeWith([1, 2, 3, 4, 5], tripleTheOdds, log)
更大的 JS 生态系统和管道运算符
这与RxJS 操作符通过 Observable方法使用的模式大致相同pipe
。这样做是为了解决上面提到的所有原型问题。但这显然适用于任何类型。
虽然prototype
增强可能是 JavaScript 中向类型添加方法的“幸运”方式,但在我看来,它有点反模式。JavaScript 需要更多地拥抱这种模式,理想情况下,我们可以在 JavaScript 中实现一个简化版本的管道操作符提案。
使用管道运算符,上述代码可能看起来像这样,但功能相同,并且不需要声明pipe
帮助程序。
pipeWith([1, 2, 3, 4, 5], odds, double, log);
// becomes
[1, 2, 3, 4, 5] |> odds |> double |> log