No Silver Bullet
老黄牛的 for
几乎每一个编程语言都有 for
,JavaScript 也不例外。
在 JavsScript 中,for
广泛用于遍历数组中,也能用于遍历对象的属性。
语法:
1 | for ([initialization]; [condition]; [final-expression]) |
initialization
是初始化语句,通常用于初始化计数变量(比如,你们最爱的 i
);condition
是判断本次是否执行 statement
;final-expression
在 statement
执行完后执行,通常用于对计数变量进行变换。
例如,要遍历一个数组 arr
,那么可以这样写:
1 | for (var i=0; i<arr.length; i++) { |
或者经常见到所谓的性能优化:
1 | for (var i=0, len=arr.length; i<len; i++) { |
强烈建议使用
const
或let
声明变量而不是使用var
,因为var
会在for
语句外声明变量,结果就是变量可能会在意外的地方被读取到。如果你不能使用 ES2015 或更新的版本,下文同样有解决方法(同样是本文的主要内容)。
如果你写过 C 系列,那么你有可能忍不住自己的麒麟臂,写出「炫技」的代码来。比如 MDN 上的这个例子。
1 | function showOffsetPos(sId) { |
这么写看着很酷,但是实际上不要这样写,尤其是在团队合作中。这样的代码一是混杂难懂,二是难以维护。代码首先是写给人看的,接着才是给机器运行的。
或许你已经非常习惯写 for
了,习惯到了看见一个数组就自然而然打出 for (...)
来。但是你有没有想过,很多时候,遍历数组其实跟索引并没有什么关系,代码只是要将数组里面的元素按顺序处理完。然而,数组天然就应该是顺序的,根本无需要一个额外的 i
来保证。换句话说,数组应该利用自身属性,提供无需索引的顺序读取方法,而索引只是在顺序读取的过程中的一个记录变量。
那这样有什么优势呢?
从处理流程上说,举个例子:假设你是一个接待员,工作是处理一列队伍的咨询。使用 for
的处理方法是:先计算整个队伍的长度,然后喊第一个人开始处理;每处理完一个人,就将序号加一再喊;直到序号等于队伍长度。而使用直接顺序读取的处理方法是:从队伍最前开始处理,每处理完一个,直接转到下一个重新开始处理,直到队伍没有下一个需要处理。这样以来就能省去了对队伍长度和索引的处理。
从代码逻辑上说,在数组上提供顺序读取方法,是将全局的语法转化为了相当于成员函数的执行,解除了和全局的耦合的同时,结合链式调用和灵活的回调函数能解放出极大的数组处理潜力。你能轻松在一行代码内基于数组进行非常多而灵活的处理,并且将「肮脏」的处理过程隐藏起来,直接得到一个处理得「连你阿妈都唔识」的结果,干净利落。
下面来认识一下这些顺序读取方法吧。
优雅的 forEach, map, filter, reduce
在 ES5(ES5.1) 中,JavaScript 新增了多个数组方法,包括:forEach, map, filter, reduce。
每个方法都接受一个回调函数作为参数传入,每个方法都会在取得一个元素的时候调用此回调函数,不同在于不同方法对待回调函数的结果上。
forEach
forEach 返回值为 undefined,适合通过数组来操作其他对象。
1 | arr.forEach(function callback(currentValue, index, array) { |
map
map 返回值为回调函数返回值组成的数组,适合处理数组变换。
1 | var new_array = arr.map(function callback(currentValue, index, array) { |
filter
filter 返回值为回调函数返回真所对应的元素组成的数组,适合处理数组筛选。
1 | var new_array = arr.filter(function callback(currentValue, index, array) { |
reduce
reduce 返回值为初始值经过和每个元素作用后得到的最终值,适合遍历数组后得到一个值或者一个对象的情况。
1 | var new_array = arr.reduce(function callback(accumulator, currentValue, index, array) { |
别忘了在回调函数中返回结果!
新方法新思路
配合几个编写代码时的常见场景,看看不使用 for
的解决方法。
循环多次执行某些动作
给定一个数组,打印其元素。
1 | var arr = [1, 2, 3] |
for
1 | for (var i=0; i<arr.length; i++) { |
炫技
1 | for ( |
改写
1 | arr.forEach(el => console.log(el)) // 1 2 3 |
对数组的每一个元素进行变换
给定一个数组,将其元素都加一。
1 | var arr = [1, 2, 3] |
for
1 | for (var i=0; i<arr.length; i++) { |
炫技
1 | for ( |
改写
1 | arr.forEach((el, i, ar) => ar[i] = ar[i] + 1) |
更好
1 | const newArr = arr.map(el => el + 1) |
在允许的情况下尽量不要去修改原数据,而是返回一个新的数组。
提取数组中符合某个标准的元素
给定一个数组,筛选出大于 2 的元素。
1 | var arr = [1, 2, 3] |
for
1 | var newArr = [] |
改写
1 | const newArr = arr.filter(el => el > 2) |
使用数组生成新数组
给定一个数组,要求使用其元素内容作为键,元素下表作为值,生成一个新数组
1 | var arr = ['a', 'b', 'c'] |
for
1 | var newArr = [] |
改写
1 | var newArr = arr.map(function(el, i) { |
ES2015
1 | const newArr = arr.map((el, i) => { return { [el]: i } }) |
遍历数组,得到一个最终值
给定一个数字数组,将其包含的数字累加
1 | var arr = [1, 2, 3] |
for
1 | var result = 0 |
改写
1 | const result = arr.reduce((ret, el) => ret + el, 0) |
给定一个键值数组,将其转换为一个对象
1 | var arr = [ |
for
1 | var result = {} |
改写
1 | const result = arr.reduce((obj, { key, value }) => { |
从以上例子中可以看到,ES2015 的代码更加清晰可读,而且代码打起来流畅省时(你自己试试!)。如果你还没使用上 ES6,那么应该赶紧去学!或许这篇文章和这篇文章能说服你。
也可以看看本人写的 《Understanding ECMAScript 6》笔记。
不灭的 for
尽管数组新增的方法十分强大,但是 for
除了会在遍历数组中使用,还会在处理对象的时候使用,比如使用 for...in
遍历对象的属性(及其原型上的属性)。在这些场合上,就需要具体情况具体分析了。
遍历对象问题
给出 app 的版本以及版本的使用量,统计最新两个大版本的使用量。版本命名符合 semver 标准,形如 ‘x.x.x’。
1 | var apps = { |
for
1 | let largest = 0 |
ES2015
1 | let largest = 0 |
使用 for
和不使用 for
相比,相差不大,甚至代码看起来更清晰,而且有 ES2015 的加成,消除了变量泄露的影响。所以如果是遍历对象,就没必要去用数组的方法了。
性能问题
截止目前位置(2017-10-17),从 benchmark 来看,在性能上,for
> forEach
> for...of
。
因此在一些对性能要求比较高的代码中,使用 forEach
和 for...of
需要谨慎,这有可能会成为性能瓶颈。另外 for...of
在浏览器上的支持度不高,所以还是可以暂时不使用,除非你清楚自己在干什么。
不过,仍然是那句话,代码首先是写给人看的,性能优化应该在功能实现之后再考虑。
循环中断问题
一般来说,使用 for
和使用数组方法在功能实现上是一样的,但是由于 for
是编程语言层面的实现,可以使用 break
和 return
手段进行中断;上文中的数组方法由于是遍历调用函数,并不存在什么停止的条件,因此肯定是会将所有元素都过一遍。在这种情况下,就乖乖使用 for
吧。
当然也可以使用
Array.some
等方法模拟中断效果,但要是那样做还不如直接for
呢。
Promise 的问题
使用数组方法时,最容易出错的地方是和 Promise 一起使用的时候。
比如需要从不同的 URL 请求数据,极其容易写成以下错误的代码。
1 | const URLs = [ |
你会发现 results
的内容只是 Promise 实例,根本不是期望的值。代码的问题在于几乎所有的网络请求 API,返回的都是一个 Promise 实例。
正确的做法是使用 Promise.all
将多个 Promise 实例包装成一个 Promise 实例:
1 | const URLs = [ |
如果你使用 async/await,千万不要这样写:
1 | const URLs = [ |
同样这种写法只能得到一个 Promise 实例数组。应该这样:
1 | const URLs = [ |
⚠️ 注意你不能在没有
async
标识的函数中使用await
,因此在各种全局状态下是无法使用await
的。幸好 async/await 处理的就是 Promise,你只需要改用.then
就好了。
举一个更极端的例子,URL 请求需要按顺序发送,上一次的结果需要作为下一次请求的参数。怎么写呢?
Promise 方法:
1 | const URLs = [ |
async/await 方法:
1 | const URLs = [ |
但是看看用 for
会如何?
1 | const URLs = [ |
意外的简洁。这归功于数组的有序性,以及 for
在语言层面上的可被打断性。
可见在处理顺序的异步请求上,for
有着很大的优势,但在并发请求上,还是乖乖用 .map
吧(比 for
+ .push
要好)。