阅读 513

Array.prototype.reduce的正确打开方式

前言

在很多时候,我很喜欢使用 Array.prototype.reduce,它的功能很强大,适用范围非常广,但是很多新手对它却难以理解,即使是具备了非常多例子和使用场景的 MDN 里的 reduce 文档,看完后也让人无从下手。

我在刚认识 reduce 的时候也是如此,看了 MDN 的文档后依旧一头雾水,只感受到 reduce 很强大,但在实战中却难以使用它,在各种技术社群问 reduce 还有什么骚操作,但是好像知道的人并不是很多,亦或者大佬们都没时间理我这种连 reduce 都不知道怎么使用的技术小白。后来我才发现,对于 reduce 这个方法,人们更需要的是去理解它,而不在于它如何使用或者它有哪些操作,在理解了之后自然就会知道什么时候可以使用它。

性能说明

很多人很纠结 forforEachmap 这几个方法和 reduce 到底哪个效率更高,事实上这对于前端来说不需要考虑太多,如果你觉得你的前端项目很卡,问题大多不会出现在代码之中,页面重排重绘才是页面卡顿的幕后黑手。

你的页面不会因为你用哪个循环方法而产生巨大的性能改变,除非你的数据真的达到了那种千万亿万的等级以至于一个循环终于需要好几秒而且必须要前端处理,对此我们除了可以在一些特定场景中使用一些高效的算法比如排序或者是二分查询等等,如果硬要全部循环一遍我们也有 webWorkerwebAsemmbly 这些更优的选择来处理这种情况,而并不需要我们在选用循环方法上面去考虑,这不仅会破坏我们整体代码的语义结构,也会使你在写代码时如履薄冰,实在是没有必要。(当然如果是追求 leetcode 速度,那就用 for 吧)

一千万次数据处理,只需要三秒左右,而且 V8 会对每次相同的循环操作自动优化,后续再执行时连一秒都不需要。

理解 reduce

在讲我的理解之前,还是要贴一下 reduce 的一般使用方式。

const arr = Array.from({length: 10}, (item, index) => ~~(Math.random()*100) * index)

const result = arr.reduce((acc, cur, index, source) => {
    // your code
    return acc
}, defaultValue)
复制代码

reduce 应该是个数据生成器

其实 reduce 很多人很难上手它是因为它本身就很不像一个循环方法,像数组原型常见的 map,forEach,some,every等等循环方法,使用的循环方法传参都是一样的,而 reduce 则像个另类,因为它能返回任何类型的数据。

有使用过前端框架 React 的朋友可能知道,在 React 中有个 useReducerhooks,在 Redux 中也有个 reducer,他们的名称里都带有 reduce,虽然在使用上有所差别,但他们也都有一个共同点,就是将上次处理的结果,返回给下次使用

我觉得 reduce 确实跟循环的意义有所不同,数组循环是对每一项数组项进行处理,而 reduce 更像是一个步骤器,每一次循环是一个步骤,每个步骤结束后把这个步骤处理好的数据丢给下个步骤使用,直到循环结束抛出最后结果。

像一条工厂流水线一样, 当中每次的方法处理和处理结果,对应每次循环中循环体代码传入循环体的形参 acc

这也是为什么在我们使用 reduce 时,每次方法结束都必须要返回值,若不返回值的话,下个步骤就无法拿到 acc 的值(undefined),就像在工厂流水线中,在某个流程有人把整个产品直接扔掉了,那做下一步工作的人就拿不到这个产品了。

所以在我看来,reduce 并不是一个好的循环方法,但它是一个很强大的数据生成器,它可以无拘无束地生成各种类型的数据包括数组、字符串、普通对象、 Set 类型等等。

从 polyfill 去理解

if (!Array.prototype.reduce) {
  Object.defineProperty(Array.prototype, 'reduce', {
    value: function(callback) {
      var o = Object(this);
      var len = o.length >>> 0;
      var k = 0;
      var value = arguments[1];
      while (k < len) {
        if (k in o) value = callback(value, o[k], k, o);
        k++;
      }
      return value;
    }
  });
}
复制代码

MDN 中 reduce 的部分 polyfill 代码(断言部分我们就不摘出来了)

可以看到,整个 reduce 的流程简洁明了,在 while 中,value 在被不断地重写新值,返回最后一次被重写的 value 值,这个 value 就是我们每次拿到的 acc,也就是每次方法处理后的值,最后的一次 acc 就是 reduce 返回的结果。

不应该用 reduce 去替代常规的循环方法

这一点其实很多人都懂,语义化问题嘛,但我总觉得大多数不用完完全全是因为不会使用 reduce 而不去使用,而不是真正的因为语义问题。

语义问题有时候可以说是一门代码哲学,像 html 标签也可以有语义问题,但是很多人还是 div+class 一把梭,因为语义问题并不是一种代码问题,它是为了代码能更好的被每个写代码的人理解。

极限视角来看,如果你完全不在乎代码语义的话,计算机为什么还要去编译代码呢?你直接用计算机懂的语言写不用编译不用解释高性能无敌它不香吗?这里说一句题外话,这也是 webAssembly 的思想所在。

所以说,代码的语义对于整个代码项目是很有帮助的,它让我们可以通过合理的语义了解这段代码在执行什么,从而提高代码的可维护性和易读性。

如果你的项目中没有 map, forEach 或者其他循环方法,而是充满了 reduce,那是多么可怕的一件事情,因为你并不能清楚地看出每一个 reduce 产出的结果到底是什么东西。

reduce 使用场景

你只要不将 reduce 当作循环方法,而是将它当成一个数据生成器,只是它的原材料来自于某个数组当中的元素,这样的话很多场景都很好理解。

这里主要介绍一下几种常用的场景。

生成 Object

这个场景大多数是数据归类,例如把数据 [1,2,3,'a','b','c',[1,2,3],['a','b','c'],{a:1},{b:2},{c:3}] 按数据类型归类或者生成数据字典。

①数据归类

const data = [1,2,3,'a','b','c',[1,2,3],['a','b','c'],{a:1},{b:2},{c:3}]

function classify (data) {
    return Object.prototype.toString.call(data).slice(8, -1)
}

const result = data.reduce((acc,cur) => {
    const className = classify(cur)
    acc[className] ? acc[className].push(cur) : acc[className] = [cur]
    return acc
}, {})

console.log(result)
复制代码

②生成数据字典

const data = [
  { name: 'Alice', id: 1 },
  { name: 'Max', id: 2 },
  { name: 'Jane', id: 3 },
  { name: 'roxz', id: 4 },
  { name: 'zonby', id: 5 },
  { name: 'zark', id: 6 },
];
const dataMap = data.reduce((acc, cur) => (acc[cur.id] = cur, acc), {})

console.log(dataMap)
复制代码

生成 String

这种场景比较不多见,因为很少有要根据数组元素对应生成不同的字符串并拼接,很多时候可以用数组方法跟 join 方法实现。但我们还是可以举点特别的例子。

const data = ['a','b','d','h',123,'x']
function dispatchString (string) {
  switch(data) {
    case 'a':
      return 'abc'
    case 'd':
      return 'def'
    case 'x':
      return 'xyz'
    default:
      return '|'
  }
}

const result = data.reduce((acc,cur) => acc += dispatchString(cur), '')

console.log(result)
复制代码

这已经是几乎没有使用场景的功能了,如果要对数组中每个字符串替换处理可以使用 map 搭配 join 实现

生成 Array

这个使用场景倒是非常常见,很多场景下它可以节约循环的次数,比如对于某个数组需要先对数据进行处理然后再进行过滤,一般来说就是先 mapfilter,但是可以用 reduce 一次循环解决。但是对应的也有语义问题,最好是简短一些或者封装成方法。

  const data = [1,2,3,4,5,6,7,8,9]
  // 数组正常处理
  const mapFilterResult = data.map(item => ~~(Math.random()*100) * item).filter(item => item > 50)
  // reduce 处理
  const reduceResult = data.reduce((acc, cur) => {
      const data = ~~(Math.random()*100) * cur
      if (data <= 50) return acc // <= 50 直接跳过进入下个步骤
      acc.push(data) // >50 保留下来
      return acc
  }, [])

  console.log(mapFilterResult)
  console.log(reduceResult)
复制代码

其他

除此之外,还可以用来把二维数组转换成一维数组,但是其实 Array 对象类型是有原型方法 Array.prototype.flat 的,所以并不需要特意用 reduce 来实现。

reduce 的限制

虽然 reduce 手脚通天,可以无拘无束的生成各种数据,但它也有本身的限制,和大多数循环方法一样,reduce 的循环也是按基本法来的,无法在循环中去决定谁先被处理谁后被处理,只能按照所提供的数组里的顺序来执行。

但与常规循环方法不同的是,它可以审视之前被处理过的值,每次循环都可以拿到此次循环前已经处理过的数据。

比如用 map 写个循环

const data = [1,2,3,4,5,6,7,8,9,10]

const result = data.map((item, index) => {
    if (item > 5) item = data[0] //
    return item * index
})
复制代码

在这里我们虽然看得懂代码,但是都不得不思考一下,这个 data[0]map 处理后的值还是处理前的值呢?

答案是处理前的值,依然是 1 * 5/6/7/8/9... 因为 map 不会改变旧数组

再让我们看看 reduce 实现这个过程

const data = [1,2,3,4,5,6,7,8,9,10]

const result = data.reduce((acc, cur, index) => {
    if (cur > 5) cur = acc[0]
    acc.push(cur * index)
    return acc
}, [])
复制代码

因为我们在循环体中取的是 acc 的值,它已经与原数组无关了,所以这里的 acc[0] 取的必定是指处理后的值了。

acc[0] 为处理后的值,依次为 0 * 5/6/7/8/9

总结

在理解了 reduce 之后再使用它,才能让我们不会误会它的语义或者行为,同时能够写出更好更准确的代码(如果你想成为那个“公司不可替代的人才”,倒是不用考虑这点)。

这其实跟现在的前端环境一样,像开发人员使用前端框架,如果不了解框架本身,我们写项目代码就跟黑盒测试一样,出错了只能要么百度谷歌,要么叨扰群里的大神,但是他们毕竟不能设身处地的为你着想,从而会让你浪费掉许许多多的时间来处理错误。相反,如果你通读框架源码,你就会知道你的项目在什么时候在执行什么,整个过程如何,从而快速定位错误,解决问题。

除此之外,知晓源码还能让你对项目的性能理解透彻,写出性能更佳,更为安全稳固的项目代码。

说明

文章是作者单一理解,也有一些主观的想法,例子也全都是纯手写的,如有错漏,可以在下方评论指出,我会积极面对错误并将其改正,感谢大家!

文章分类
前端
文章标签