永远高效 — 通过Transducers最大化JavaScript的性能

像map()、filter()和reduce()这样的声明性方法具有清晰性的优势,但在某些情况下可能会带来性能成本。本文将向您展示如何使用转换器来完全优化操作序列,而不会损失清晰度。

以声明式方式工作时,鼓励使用 map()filter()reduce() 方法。然而,当处理大型数组并应用一系列这样的操作时,会创建、处理和最终丢弃许多中间数组,这些都会导致延迟。当然,对于小型数组,这对性能影响不大,但如果你需要处理更大量的数据(比如数据流或大型数据库查询的结果),你就需要优化这个过程。幸运的是,有一种简洁的方法可以实现这一点:using transducers

对于喜欢较真的人来说,transduce是一个拉丁词,意思是转化、改变、转换或运输。这个术语在许多领域中常被使用,包括生物学、电子学、机器学习等等。

Transducers 是一种函数式编程的概念,用于处理集合(如数组)的转换和组合。它们提供了一种通用的方式来定义和组合转换操作,以便在不同的上下文中重用代码。

开始

让我们首先准备一些函数和数据来进行实验。

js 复制代码
const testOdd = (x: number): boolean => x % 2 === 1;
const duplicate = (x: number): number => x + x;
const testUnderFifty = (x: number): boolean => x < 50;
const addThree = (x: number): number => x + 3;

我们将按顺序将这些功能应用于一个数组:

  • 过滤掉偶数值,只保留奇数值;
  • 将结果数字加倍;
  • 只保留小于50的数字;
  • 并最后将结果加上三。
js 复制代码
const myArray = [22, 9, 60, 24, 11, 63];

const a0 = myArray
 .filter(testOdd)
 .map(duplicate)
 .filter(testUnderFifty)
 .map(addThree);

console.log(a0);
// Output: [ 21, 25 ]

程序的执行方式是这样的:

我们从原始数组开始,然后:

  1. 使用 testOdd() 对该数组进行过滤,产生了一个新的数组
  2. duplicate() 映射应用于第二个数组,创建了第三个数组
  3. 使用 testUnderFifty() 对第三个数组进行过滤,得到了第四个数组
  4. addThree() 映射应用于它,生成了最终结果,即第五个数组。

在这么小的数组中,你甚至不会注意到性能问题,但是在一个更大的数组中,所有这些数组的创建可能需要很多时间。我们必须考虑一种更好的处理方式。

更好的方法

我们如何能够优化这个步骤序列呢?本质上,我们是逐步应用所有的变换;在伪代码中,它可能类似于以下算法:

js 复制代码
for each transformation to be applied:
   for each element in the input list:
       apply the transformation to the element

更好的方法是逐个取出元素,并按顺序对它们应用所有的转换,这样可以直接得到最终结果,无需中间数组。这种方法的伪代码如下:我们交换了两个循环。

js 复制代码
for each element in the input list:
   for each transformation to be applied:
       apply the transformation to the element

解决方案有了,但是我们如何实施呢?让我们一起来解决这个问题。

实施新方法

问题出现了:我们如何在一步中完成所有操作?一个重要的概念是,你可以通过巧妙地使用 reduce() 来模拟 map()filter() 。关键在于我们 reduce() 调用中使用什么函数;一切都取决于这个。我们将按照下面的图片所示改变执行顺序,使其在更短的时间内仍然得到相同的结果。

我们将使用 reduce() 来实现我们优化的单步骤过程。我们将把所有的转换函数组合在一起;每个函数将负责调用下一个函数。让我们看一下代码是如何实现的------看起来并不明显!稍后解释它是如何运行的。

ts 复制代码
const mapTR =  
<V, W>(fn: (x: V) => W) =>  
<A>(reducer: (am: A, wm: W) => A) =>  
(accum: A, value: V): A =>  
reducer(accum, fn(value));  
  
const filterTR =  
<V>(fn: (x: V) => boolean) =>  
<A>(reducer: (af: A, wf: V) => A) =>  
(accum: A, value: V): A =>  
fn(value) ? reducer(accum, value) : accum;
js 复制代码
const mapTR = (fn) => (reducer) => (accum, value) =>
  reducer(accum, fn(value));

const filterTR = (fn) => (reducer) => (accum, value) =>
  fn(value) ? reducer(accum, value) : accum;

我们已经构建了两个Transducers(变换器)的辅助函数,这两个辅助函数都是高阶函数,将一个减少函数作为输入,并产生一个新的减少函数作为输出。在正常情况下, reducer() 是序列中的下一个Transducers。

mapTR 接受一个函数 fn,该函数将类型为 V 的值转换为类型为 W 的值。然后,它返回一个函数,该函数接受一个 reducer 函数,并返回一个新的 reducer 函数。这个新的 reducer 函数在每次调用时都会将累加器 accum 和经过 fn 转换后的值 fn(value) 传递给原始的 reducer 函数。

换句话说,mapTR 的作用是将一个值转换函数 fn 应用于输入流中的每个值,并将转换后的值传递给下一个 reducer 函数。

filterTR 接受一个函数 fn,该函数将类型为 V 的值作为输入,并返回一个布尔值,表示是否应该保留该值。然后,它返回一个函数,该函数接受一个 reducer 函数,并返回一个新的 reducer 函数。这个新的 reducer 函数在每次调用时都会检查 fn(value) 的返回值。如果返回值为 true,则将当前值 value 传递给原始的 reducer 函数;否则,直接返回累加器 accum

换句话说,filterTR 的作用是根据传入的条件函数 fn 过滤输入流中的值。只有满足条件的值才会传递给下一个 reducer 函数,否则将被忽略。

我们如何使用Transducers呢?让我们来看看吧。

使用(Using transducers)

我们将通过以下代码来解决我们的优化问题。首先,我们将原始函数转换为transducers。

js 复制代码
const testOddR = filterTR(testOdd);
const duplicateR = mapTR(duplicate);
const testUnderFiftyR = filterTR(testUnderFifty);
const addThreeR = mapTR(addThree);

我们转换后的函数将计算它们的结果并调用一个reducer来进一步处理。我们还需要一个最终的特殊函数 addToArray() ,通过实际创建输出数组来完成转换序列。

ts 复制代码
const addToArray = <V>(a: V[], v: V): V[] => {
   a.push(v);
   return a;
};

所以现在的解决方案是完美的,尽管有点费事!

js 复制代码
const a1 = myArray.reduce(
   testOddR(duplicateR(testUnderFiftyR(addThreeR(addToArray)))),
   []
);

console.log(a1);
// Output: [ 21, 25 ], again

这一切是如何运行?

  • 使用 reduce() ,将一个空数组(将成为最终结果)作为累加器
  • testOddR() 进行测试,如果是奇数,则将其传递给下一个函数
  • duplicateR() 将接收到的值复制一份并传递给下一个
  • testUnderFiftyR() 测试数值,如果小于50,则将其传递给下一个
  • addThreeR() 将接收到的值加上3,并将该值传递给最后一个函数
  • addToArray() 只是将其输入推送到累加器数组中

这个方法可行,而且只需要一次操作!但它还不够完美!

概括

我们可以有很多 map()filter() 的调用,但我们编写的代码总是以一个数组结束--如果我们想要进行最后的 reduce() 怎么办?这将需要对数组进行新的遍历,而我们只想进行一次遍历。

有两件事情我们需要改变:我们可能不想在我们的转换系列中使用 addToArray() 作为最终函数,并且我们可能有一个不同的起始值,而不是 [] 。例如,如果我们想要找到所有数字的总和,我们可以这样写:

js 复制代码
const sum = (acc: number, value: number): number => acc + value;

const a2 = myArray.reduce(
   testOddR(duplicateR(testUnderFiftyR(addThreeR(sum)))),
   0
);

console.log(a2);
// Output: 46

请注意两个点:我们现在传递的是 sum0 ,而不是 addToArray[] ,并且我们已经优化了 map()filter()reduce() 调用的顺序,只需处理输入数组一次,直接生成所需的输出,而无需任何中间数组;真正的完美!

总结

使用函数式编程并不意味着会导致性能下降。

在本文中,我们已经看到了如何将任何映射、过滤和归约调用的序列转换为一次遍历,这个过程不需要中间数组并且不需要额外的时间。

我们可以通过使用函数组合高阶函数来更完美的解决这个问题!

相关推荐
一颗花生米。3 小时前
深入理解JavaScript 的原型继承
java·开发语言·javascript·原型模式
学习使我快乐013 小时前
JS进阶 3——深入面向对象、原型
开发语言·前端·javascript
bobostudio19953 小时前
TypeScript 设计模式之【策略模式】
前端·javascript·设计模式·typescript·策略模式
勿语&4 小时前
Element-UI Plus 暗黑主题切换及自定义主题色
开发语言·javascript·ui
黄尚圈圈4 小时前
Vue 中引入 ECharts 的详细步骤与示例
前端·vue.js·echarts
浮华似水5 小时前
简洁之道 - React Hook Form
前端
正小安7 小时前
如何在微信小程序中实现分包加载和预下载
前端·微信小程序·小程序
_.Switch9 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
一路向前的月光9 小时前
Vue2中的监听和计算属性的区别
前端·javascript·vue.js