无循环 JavaScript(map、reduce、filter和find)


Posted in Javascript onApril 08, 2017

之前有讨论过,缩进(非常粗鲁地)增加了代码复杂性。我们的目标是写出复杂度低的 JavaScript 代码。通过选择一种合适的抽象来解决这个问题,可是你怎么能知道选择哪一种抽象呢?很遗憾的是到目前为止,没有找到一个具体的例子能回答这个问题。这篇文章中我们讨论不用任何循环如何处理 JavaScript 数组,最终得出的效果是可以降低代码复杂性。

循环是一种很重要的控制结构,它很难被重用,也很难插入到其他操作之中。另外,它意味着随着每次迭代,代码也在不断的变化之中。——Luis Atencio

我们先前说过,像循环这样的控制结构引入了复杂性。但是也没有给出确切的证据证明这一点,我们先看看 JavaScript 中循环的工作原理。

循环

在 JavaScript 中,至少有四、五种实现循环的方法,最基础的是 while 循环。我们首先先创建一个示例函数和数组:

// oodlify :: String -> String
function oodlify(s) {
 return s.replace(/[aeiou]/g, 'oodle');
}

const input = [
 'John',
 'Paul',
 'George',
 'Ringo',
];

现在有了一个数组,我们想要用 oodlify 函数处理每一个元素。如果用 while 循环,就类似于这样:

let i = 0;
const len = input.length;
let output = [];
while (i < len) {
 let item = input[i];
 let newItem = oodlify(item);
 output.push(newItem);
 i = i + 1;
}

注意这里发生的事情,我们用了一个初始值为 0 的计数器 i,每次循环都会自增。而且每次循环中都和 len 进行比较以保证循环特定次数以后终止循环。这种利用计数器进行循环控制的模式太常用了,所以 JavaScript 提供了一种更加简洁的写法: for 循环,写起来如下:

const len = input.length;
let output = [];
for (let i = 0; i < len; i = i + 1) {
 let item = input[i];
 let newItem = oodlify(item);
 output.push(newItem);
}

这一结构非常有用,while循环非常容易把自增的 i 给忘掉,进而引起无限循环;而for循环把和计数器相关的代码都放到了上面,这样你就不会忘掉自增 i,这确实是一个很好的改进。现在回到原来的问题,我们目标是在数组的每个元素上运行 oodlify() 函数,并且将结果放到一个新的数组中。

对一个数组中每个元素都进行操作的这种模式也是非常普遍的。因此在 ES2015 中,引入了一种新的循环结构可以把计数器也简化掉: for...of 循环。每一次返回数组的下一个元素给你,代码如下:

let output = [];
for (let item of input) {
 let newItem = oodlify(item);
 output.push(newItem);
}

这样就清晰很多了,注意这里计数器和比较都不用了,你甚至都不用把元素从数组里面取出来。for...of 帮我们做了里面的脏活累活。如果现在用 for...of 来代替所有的 for 循环,其实就可以很大程度上降低复杂性。但是,我们还可以做进一步的优化。

mapping

for...of 循环比 for 循环更清晰,但是依然需要一些配置性的代码。如不得不初始化一个 output 数组并且每次循环都要调用 push() 函数。但有办法可以让代码更加简洁有力,我们先扩展一下问题。

如果有两个数组需要调用 oodlify 函数会怎么样?

const fellowship = [
 'frodo',
 'sam',
 'gandalf',
 'aragorn',
 'boromir',
 'legolas',
 'gimli',
];

const band = [
 'John',
 'Paul',
 'George',
 'Ringo',
];

很容易想到的方法是对每个数组都做循环:

let bandoodle = [];
for (let item of band) {
 let newItem = oodlify(item);
 bandoodle.push(newItem);
}

let floodleship = [];
for (let item of fellowship) {
 let newItem = oodlify(item);
 floodleship.push(newItem);
}

这确实ok,有能正确执行的代码,就比没有好。但是重复的代码太多了——不够“DRY”。我们来重构它以降低重复性,创建一个函数:

function oodlifyArray(input) {
 let output = [];
 for (let item of input) {
  let newItem = oodlify(item);
  output.push(newItem);
 }
 return output;
}

let bandoodle = oodlifyArray(band);
let floodleship = oodlifyArray(fellowship);

这看起来好多了,可是如果我们想使用另外一个函数该怎么办?

function izzlify(s) {
 return s.replace(/[aeiou]+/g, 'izzle');
}

上面的 oodlifyArray() 一点用都没有了。但如果再创建一个 izzlifyArray() 函数的话,代码又重复了。不管那么多,先写出来看看什么效果:

function oodlifyArray(input) {
 let output = [];
 for (let item of input) {
  let newItem = oodlify(item);
  output.push(newItem);
 }
 return output;
}

function izzlifyArray(input) {
 let output = [];
 for (let item of input) {
  let newItem = izzlify(item);
  output.push(newItem);
 }
 return output;
}

这两个函数惊人的相似。那么是不是可以把它们抽象成一个通用的模式呢?我们想要的是:给定一个函数和一个数组,通过这个函数,把数组中的每一个元素做操作后放到新的数组中。我们把这个模式叫做 map 。一个数组的 map 函数如下:

function map(f, a) {
 let output = [];
 for (let item of a) {
  output.push(f(item));
 }
 return output;
}

这里还是用了循环结构,如果想要完全摆脱循环的话,可以做一个递归的版本出来:

function map(f, a) {
 if (a.length === 0) { return []; }
 return [f(a[0])].concat(map(f, a.slice(1)));
}

递归解决方法非常优雅,仅仅用了两行代码,几乎没有缩进。但是通常并不提倡于在这里使用递归,因为在较老的浏览器中的递归性能非常差。实际上,map 完全不需要你自己去手动实现(除非你自己想写)。map 模式很常用,因此 JavaScript 提供了一个内置 map 方法。使用这个 map 方法,上面的代码变成了这样:

let bandoodle  = band.map(oodlify);
let floodleship = fellowship.map(oodlify);
let bandizzle  = band.map(izzlify);
let fellowshizzle = fellowship.map(izzlify);

可以注意到,缩进消失,循环消失。当然循环可能转移到了其他地方,但是我们已经不需要去关心它们了。现在的代码简洁有力,完美。

为什么这个代码这么简单呢?这可能是个很傻的问题,不过也请思考一下。是因为短吗?不是,简洁并不代表不复杂。它的简单是因为我们把问题分离了。有两个处理字符串的函数: oodlify 和 izzlify,这些函数并不需要知道关于数组或者循环的任何事情。同时,有另外一个函数:map ,它来处理数组,它不需要知道数组中元素是什么类型的,甚至你想对数组做什么也不用关心。它只需要执行我们所传递的函数就可以了。把对数组的处理中和对字符串的处理分离开来,而不是把它们都混在一起。这就是为什么说上面的代码很简单。

reducing

现在,map 已经得心应手了,但是这并没有覆盖到每一种可能需要用到的循环。只有当你想创建一个和输入数组同样长度的数组时才有用。但是如果你想要向数组中增加几个元素呢?或者想找一个列表中的最短字符串是哪个?其实有时我们对数组进行处理,最终只想得到一个值而已。

来看一个例子,现在一个数组里面存放了一堆超级英雄:

const heroes = [
 {name: 'Hulk', strength: 90000},
 {name: 'Spider-Man', strength: 25000},
 {name: 'Hawk Eye', strength: 136},
 {name: 'Thor', strength: 100000},
 {name: 'Black Widow', strength: 136},
 {name: 'Vision', strength: 5000},
 {name: 'Scarlet Witch', strength: 60},
 {name: 'Mystique', strength: 120},
 {name: 'Namora', strength: 75000},
];

现在想找最强壮的超级英雄。使用 for...of 循环,像这样:

let strongest = {strength: 0};
for (hero of heroes) {
 if (hero.strength > strongest.strength) {
  strongest = hero;
 }
}

虽然这个代码可以正确运行,可是实在太烂了。看这个循环,每次都保存到目前为止最强的英雄。继续提需求,接下来我们想要所有超级英雄的总强度:

let combinedStrength = 0;
for (hero of heroes) {
 combinedStrength += hero.strength;
}

在这两个例子中,都在循环开始之前初始化了一个变量。然后在每一次的循环中,处理一个数组元素并且更新这个变量。为了使这种循环套路变得更加明显一点,现在把数组中间的部分抽离到一个函数当中。并且重命名这些变量,以进一步突出相似性。

function greaterStrength(champion, contender) {
 return (contender.strength > champion.strength) ? contender : champion;
}

function addStrength(tally, hero) {
 return tally + hero.strength;
}

const initialStrongest = {strength: 0};
let working = initialStrongest;
for (hero of heroes) {
 working = greaterStrength(working, hero);
}
const strongest = working;

const initialCombinedStrength = 0;
working = initialCombinedStrength;
for (hero of heroes) {
 working = addStrength(working, hero);
}
const combinedStrength = working;

用这种方式来写,两个循环变得非常相似了。它们两个之间唯一的区别是调用的函数和初始值不同。两个的功能都是对数组进行处理,最终得到一个值。所以,我们创建一个 reduce 函数来封装这个模式。

function reduce(f, initialVal, a) {
 let working = initialVal;
 for (item of a) {
  working = f(working, item);
 }
 return working;
}

reduce 模式在 JavaScript 中也是很常用的,因此 JavaScript 为数组提供了内置的方法,不需要自己来写。通过内置方法,代码就变成了:

const strongestHero = heroes.reduce(greaterStrength, {strength: 0});
const combinedStrength = heroes.reduce(addStrength, 0);

ok,如果足够细心的话,你会注意到上面的代码其实并没有短很多。不过也确实比自己手写的 reduce 代码少写了几行。但是我们的目标并不是使代码变短或者少写,而是降低代码复杂度。现在的复杂度降低了吗?我会说是的。把处理每个元素的代码和处理循环代码分离开来了,这样代码就不会互相纠缠在一起了,降低了复杂度。

reduce 方法乍一看可能觉得非常基础。我们举的 reduce 大部分也比如做加法这样的简单例子。但是没有人说 reduce 方法只能返回基本类型,它可以是一个 object 类型,甚至可以是另一个数组。当我第一次意识到这个问题的时候,自己也是豁然开朗。所以其实可以用 reduce 方法来实现 map 或者 filter,这个留给读者自己做练习。

filtering

现在我们有了 map 处理数组中的每个元素,有了 reduce 可以处理数组最终得到一个值。但是如果想获取数组中的某些元素该怎么办?我们来进一步探索,现在增加一些属性到上面的超级英雄数组中:

const heroes = [
 {name: 'Hulk', strength: 90000, sex: 'm'},
 {name: 'Spider-Man', strength: 25000, sex: 'm'},
 {name: 'Hawk Eye', strength: 136, sex: 'm'},
 {name: 'Thor', strength: 100000, sex: 'm'},
 {name: 'Black Widow', strength: 136, sex: 'f'},
 {name: 'Vision', strength: 5000, sex: 'm'},
 {name: 'Scarlet Witch', strength: 60, sex: 'f'},
 {name: 'Mystique', strength: 120, sex: 'f'},
 {name: 'Namora', strength: 75000, sex: 'f'},
];

ok,现在有两个问题,我们想要:

找到所有的女性英雄;
找到所有能量值大于500的英雄。
使用普通的 for...of 循环,会得到如下代码:

let femaleHeroes = [];
for (let hero of heroes) {
 if (hero.sex === 'f') {
  femaleHeroes.push(hero);
 }
}

let superhumans = [];
for (let hero of heroes) {
 if (hero.strength >= 500) {
  superhumans.push(hero);
 }
}

逻辑严密,看起来还不错?但是里面又出现了重复的情况。实际上,区别在于 if 的判断语句,那么能不能把 if 语句重构到一个函数中呢?

function isFemaleHero(hero) {
 return (hero.sex === 'f');
}

function isSuperhuman(hero) {
 return (hero.strength >= 500);
}

let femaleHeroes = [];
for (let hero of heroes) {
 if (isFemaleHero(hero)) {
  femaleHeroes.push(hero);
 }
}

let superhumans = [];
for (let hero of heroes) {
 if (isSuperhuman(hero)) {
  superhumans.push(hero);
 }
}

这种只返回 true 或者 false 的函数,我们一般把它称作断言(predicate)函数。这里用了断言(predicate)函数来判断是否需要保留当前的英雄。

上面代码的写法会看起来比较长,但是把断言函数抽离出来,可以让重复的循环代码更加明显。现在把种循环抽离到一个函数当中。

function filter(predicate, arr) {
 let working = [];
 for (let item of arr) {
  if (predicate(item)) {
   working = working.concat(item);
  }
 }
}

const femaleHeroes = filter(isFemaleHero, heroes);
const superhumans = filter(isSuperhuman, heroes);

同 map 和 reduce 一样,JavaScript 提供了一个内置数组方法,没必要自己来实现(除非你自己想写)。用内置数组方法,上面的代码就变成了:

const femaleHeroes = heroes.filter(isFemaleHero);
const superhumans = heroes.filter(isSuperhuman);

为什么这段代码比 for...of 循环好呢?回想一下整个过程,我们要解决一个“找到满足某一条件的所有英雄”。使用 filter 使得问题变得简单化了。我们需要做的就是通过写一个简单函数来告诉 filter 哪一个数组元素要保留。不需要考虑数组是什么样的,以及繁琐的中间变量。取而代之的是一个简单的断言函数,仅此而已。

与其他的迭代函数相比,使用 filter 是一个四两拨千斤的过程。我们不需要通读循环代码来理解到底要过滤什么,要过滤的东西就在传递给它的那个函数里面。

finding

filter 已经信手拈来了吧。这时如果只想找一个英雄该怎么办?比如找 “Black Widow”。使用 filter 会这样写:

function isBlackWidow(hero) {
 return (hero.name === 'Black Widow');
}

const blackWidow = heroes.filter(isBlackWidow)[0];

这段代码的问题是效率不够高。filter 会检查数组中的每一个元素,而我们知道这里面只有一个 “Black Widow”,当找到她的时候就可以停住,不用再看后面的元素了。那么,依旧利用断言函数,我们写一个 find 函数来返回第一次匹配上的元素。

function find(predicate, arr) {
 for (let item of arr) {
  if (predicate(item)) {
   return item;
  }
 }
}

const blackWidow = find(isBlackWidow, heroes);

同样地,JavaScript 已经提供了这样的方法:

const blackWidow = heroes.find(isBlackWidow);

find 再次体现了四两拨千斤的特点。通过 find 方法,把问题简化为:你只要关注如何判断你要找的东西就可以了,不必关心迭代到底怎么实现等细节问题。

总结

这些迭代函数的例子很好地诠释“抽象”的作用和优雅。回想一下我们所讲的内置方法,每个例子中我们都做了三件事:

消除了循环结构,使得代码变的简洁易读;
通过适当的方法名称来描述我们使用的模式,也就是:map,reduce,filter 和 find;
把问题从处理整个数组简化到处理每个元素。
注意在每一种情况下,我们都用几个纯函数来分解问题和解决问题。真正令人兴奋的是通过仅仅这么四种模式模式(当然还有其他的模式,也建议大家去学习一下),在 JS 代码中你就可以消除几乎所有的循环了。这是因为 JS 中几乎每个循环都是用来处理数组,或者生成数组的。通过消除循环,降低了复杂性,也使得代码的可维护性更强。

作者:James Sinclair 
编译:胡子大哈

翻译原文:http://huziketang.com/blog/posts/detail?postId=58ad37c3204d50674934c3ab 
英文原文:JAVASCRIPT WITHOUT LOOPS

Javascript 相关文章推荐
javascript SocialHistory 检查访问者是否访问过某站点
Aug 02 Javascript
动态添加js事件实现代码
Mar 12 Javascript
javascript 常用方法总结
Jun 03 Javascript
SyntaxHighlighter语法高亮插件使用说明
Aug 14 Javascript
为Javascript中的String对象添加去除左右空格的方法(示例代码)
Nov 30 Javascript
使用Plupload实现直接上传附件至七牛云存储
Dec 26 Javascript
js实现动画特效的文字链接鼠标悬停提示的方法
Mar 02 Javascript
JavaScript实现MIPS乘法模拟的方法
Apr 17 Javascript
JavaScript每天定时更换皮肤样式的方法
Jul 01 Javascript
vue与django集成打包的实现方法
Nov 11 Javascript
基于 Vue 的 Electron 项目搭建过程图文详解
Jul 22 Javascript
vue中使用mockjs配置和使用方式
Apr 06 Vue.js
JavaScript中的遍历详解(多种遍历)
Apr 07 #Javascript
分享十三个最佳JavaScript数据网格库
Apr 07 #Javascript
Google 爬虫如何抓取 JavaScript 的内容
Apr 07 #Javascript
正则表达式基本语法及表单验证操作详解【基于JS】
Apr 07 #Javascript
js实现图片加载淡入淡出效果
Apr 07 #Javascript
AngularJS中的拦截器实例详解
Apr 07 #Javascript
Vue.js如何优雅的进行form validation
Apr 07 #Javascript
You might like
Linux下ZendOptimizer的安装与配置方法
2007/04/12 PHP
基于Snoopy的PHP近似完美获取网站编码的代码
2011/10/23 PHP
php中filter函数验证、过滤用户输入的数据
2014/01/13 PHP
PHP中一些可以替代正则表达式函数的字符串操作函数
2014/11/17 PHP
php实现购物车功能(以大苹果购物网为例)
2017/03/09 PHP
解决在laravel中auth建立时候遇到的问题
2019/10/15 PHP
jquery实现图片灯箱明暗的遮罩效果
2013/11/15 Javascript
JavaScript使用slice函数获取数组部分元素的方法
2015/04/06 Javascript
Javascript中的Prototype到底是什么
2016/02/16 Javascript
微信小程序之小豆瓣图书实例
2016/11/30 Javascript
微信小程序调用PHP后台接口 解析纯html文本
2017/06/13 Javascript
Angular 2.0+ 的数据绑定的实现示例
2017/08/09 Javascript
Node.js中sequelize时区的配置方法
2017/12/10 Javascript
使用vue的transition完成滑动过渡的示例代码
2018/06/25 Javascript
create-react-app安装出错问题解决方法
2018/09/04 Javascript
简单说说如何使用vue-router插件的方法
2019/04/08 Javascript
Vue2.X和Vue3.0数据响应原理变化的区别
2019/11/07 Javascript
Vue实现PC端靠边悬浮球的代码
2020/05/09 Javascript
[29:59]完美世界DOTA2联赛PWL S3 Forest vs access 第二场 12.11
2020/12/13 DOTA
详细解析Python中__init__()方法的高级应用
2015/05/11 Python
一步步教你用Python实现2048小游戏
2017/01/19 Python
Python 绘图和可视化详细介绍
2017/02/11 Python
python异步实现定时任务和周期任务的方法
2019/06/29 Python
K最近邻算法(KNN)---sklearn+python实现方式
2020/02/24 Python
python PyAUtoGUI库实现自动化控制鼠标键盘
2020/09/09 Python
Django 权限管理(permissions)与用户组(group)详解
2020/11/30 Python
班级道德讲堂实施方案
2014/02/24 职场文书
银行柜员求职自荐书
2014/06/18 职场文书
小学生一分钟演讲稿
2014/08/26 职场文书
2014年超市工作总结
2014/11/19 职场文书
安全保证书怎么写
2015/02/28 职场文书
重阳节座谈会主持词
2015/07/03 职场文书
2016年禁毒宣传活动总结
2016/04/05 职场文书
python 下载文件的几种方式分享
2021/04/07 Python
Python可视化学习之seaborn绘制矩阵图详解
2022/02/24 Python
Java 常见的限流算法详细分析并实现
2022/04/07 Java/Android