如何在JavaScript中使用Map、Filter和Reduce

172 阅读13分钟

最近,函数式编程在开发界引起了很大的轰动。这是有原因的。函数式技术可以帮助你写出更多的声明性代码,使你更容易理解,重构和测试。

函数式编程的基石之一是它对列表和列表操作的特殊使用。这些东西正是它们听起来的样子:事物的数组和你对它们所做的事情。但函数式思维对待它们的方式与你想象的有些不同。

本文将仔细研究我称之为 "三大 "列表操作的东西。map,filter, 和reduce 。掌握这三个函数是编写简洁的函数式代码的重要一步,它为函数式和反应式编程的强大技术打开了大门。

好奇吗?让我们深入了解一下。

从列表到列表的map

通常,我们会发现自己需要使用一个数组,并以完全相同的方式修改其中的每个元素。典型的例子是对数字数组中的每个元素进行平方运算,从用户列表中检索名字,或者对字符串数组运行一个重码。

map 是一种方法,正是为了做这些事情而建立的。它被定义在 ,所以你可以在任何数组上调用它,并且它接受一个回调作为它的第一个参数。Array.prototype

map 的语法如下所示。

let newArray = arr.map(callback(currentValue[, index[, array]]) {
  // return element for newArray, after executing something
}[, thisArg]);

当你在一个数组上调用map ,它对其中的每个元素执行回调,返回一个新的数组,其中包含回调返回的所有值。

在引擎盖下,map 向你的回调传递三个参数:

  1. 数组中的当前项
  2. 当前项的数组索引
  3. 你调用map整个数组

让我们看看一些代码。

map 在实践中

假设我们有一个应用程序,维护你一天的任务数组。每个task 是一个对象,每个对象都有一个nameduration 属性。

// Durations are in minutes 
const tasks = [
  {
    'name'     : 'Write for Envato Tuts+',
    'duration' : 120
  },
  {
    'name'     : 'Work out',
    'duration' : 60
  },
  {
    'name'     : 'Procrastinate on Duolingo',
    'duration' : 240
  }
];

假设我们想创建一个只包含每个任务名称的新数组,这样我们就可以查看我们今天所做的一切。使用for 循环,我们会写成这样的东西。

const task_names = [];
 
for (let i = 0, max = tasks.length; i < max; i += 1) {
    task_names.push(tasks[i].name);
}

console.log(task_names) // [ 'Write for Envato Tuts+', 'Work out', 'Procrastinate on Duolingo' ]

JavaScript也提供了一个forEach 循环。它的功能类似于for 循环,但它为我们处理了检查循环索引和数组长度的所有混乱问题。

const task_names = [];
 
tasks.forEach(function (task) {
    task_names.push(task.name);    
});

console.log(task_names) // [ 'Write for Envato Tuts+', 'Work out', 'Procrastinate on Duolingo' ]

使用map ,我们可以简单地写:

const task_names = tasks.map(function (task, index, array) {
    return task.name; 
});

console.log(task_names) // [ 'Write for Envato Tuts+', 'Work out', 'Procrastinate on Duolingo' ]

这里我包括了indexarray 参数,以提醒你,如果你需要它们,它们就在那里。不过,由于我在这里没有使用它们,你可以不使用它们,代码就可以正常运行。

在现代JavaScript中,用箭头函数来编写map ,是一种更简洁的方法。

const task_names = tasks.map(task => task.name)

console.log(task_names) // ['Write for Envato Tuts+', 'Work out', 'Procrastinate on DuoLingo']

箭头函数是单行函数的一种简称,它只有一个return 语句。没有比这更可读的了。

不同的方法之间有几个重要的区别:

  1. 使用map ,你不需要自己管理for 循环的状态。
  2. 使用map ,你可以直接对元素进行操作,而不是对数组进行索引。
  3. 你不必创建一个新的数组并将pushmap 一次性返回成品,所以我们可以简单地将返回值分配给一个新的变量。
  4. 必须记住在你的回调中包含一个return 语句。如果你不这样做,你会得到一个充满undefined 的新数组。

事实证明,我们今天要看的所有函数都有这些特点。

我们不需要手动管理循环的状态,这使得我们的代码更简单,更容易维护。我们可以直接对元素进行操作,而不必对数组进行索引,这使得事情更容易阅读。

使用forEach 循环为我们解决了这两个问题。但是map 仍然有至少两个明显的优势。

  1. forEach 返回undefined ,所以它不会与其他数组方法连锁。map 返回一个数组,所以你可以与其他数组方法连锁。
  2. map 返回 一个带有成品的数组,而不是要求我们在循环中突变一个数组。

把修改状态的地方保持在绝对最小的范围内是函数式编程的一个重要原则。它使代码更安全、更容易理解。

缺点

你传递给map 的回调必须有一个明确的return 语句,否则map 会吐出一个充满undefined 的数组。要记住包含一个return 的值并不难,但要忘记也不难。

如果你真的忘记了,map 不会抱怨。相反,它将安静地交回一个满是空白的数组。像这样的无声错误可能会令人惊讶地难以调试。

幸运的是,这是map唯一问题。但这是一个很常见的陷阱,我不得不强调一下。一定要确保你的回调包含一个return 语句。

实现

阅读实现是理解的一个重要部分。因此,让我们自己写一个轻量级的map ,以更好地了解引擎盖下发生了什么。

let map = function (array, callback) {
  const new_array = [];
 
  array.forEach(function (element, index, array) {
    new_array.push(callback(element));
  });
 
  return new_array;
};

这段代码接受了一个数组和一个回调函数作为参数。然后它创建一个新的数组,对我们传入的数组中的每个元素执行回调,将结果推送到新的数组中,并返回新的数组。如果你在控制台运行这个程序,你会得到和以前一样的结果。

虽然我们在引擎盖下使用for循环,但把它包装成一个函数,隐藏了细节,让我们用抽象的方式工作。

这使得我们的代码更具声明性--它说的是要做什么,而不是怎么做。你会发现,这样做可以使你的代码更易读、更易维护、更易调试

过滤掉噪音

我们的下一个数组操作是filter 。它的作用和它听起来一样。它接收一个数组并过滤掉不需要的元素。

过滤器的语法是。

let newArray = arr.filter(callback(currentValue[, index[, array]]) {
  // return element for newArray, if true
}[, thisArg]);

就像mapfilter 传递给你的回调三个参数:

  1. 当前项
  2. 当前的索引
  3. 你调用filter数组

考虑下面的例子,过滤掉任何少于8个字符的字符串。

const words = ['Python', 'Javascript', 'Go', 'Java', 'PHP', 'Ruby'];
const result = words.filter(word => word.length < 8);
console.log(result);

预期的结果将是:

[ 'Python', 'Go', 'Java', 'PHP', 'Ruby' ]

让我们再来看看我们的任务例子。假设我想得到的不是每个任务的名称,而是花了两个小时或更多时间完成的任务的列表。

使用forEach ,我们会写:

const difficult_tasks = [];
 
tasks.forEach(function (task) {
    if (task.duration >= 120) {
        difficult_tasks.push(task);
    }
});

console.log(difficult_tasks)

//  [{ name: 'Write for Envato Tuts+', duration: 120 },
//   { name: 'Procrastinate on Duolingo', duration: 240 }
//   ]

使用filter ,我们可以简单地写。

const difficult_tasks = tasks.filter((task) => task.duration >= 120 );

就像mapfilter 让我们。

  • 避免在forEachfor 循环中突变一个数组
  • 将其结果直接分配给一个新的变量,而不是推到我们在其他地方定义的数组中。

缺点

如果你想让它正常工作,你传递给map 的回调必须包括一个返回语句。使用filter ,你也必须包括一个返回语句(除非你使用箭头函数),而且你必须确保它返回一个布尔值。

如果你忘记了你的返回语句,你的回调将返回undefined ,而filter 将无助地将其胁迫到false 。它将不会抛出一个错误,而是默默地返回一个空数组

如果你走另一条路,返回的东西不是明确的truefalse ,那么filter 将试图通过应用JavaScript的类型强制规则来弄清你的意思。更多的时候,这是一个错误。而且,就像忘记了你的返回语句一样,这将是一个无声的错误。

一定要确保你的回调包括一个明确的返回语句。而且一定要确保你在filter 中的回调会返回truefalse 。你的理智会感谢你。

实施

再一次,理解一段代码的最好方法是......嗯,写它。让我们推出我们自己的轻量级filter

const filter = function (array, callback) {
 
    const filtered_array = [];
 
    array.forEach(function (element, index, array) {
        if (callback(element, index, array)) {
            filtered_array.push(element);    
        }
    });
 
    return filtered_array;
 
};

缩减方法

在JavaScript中,reduce 数组方法的语法是。

let newArray = arr.filter(callback(currentValue, accumulatedValue) {
  // return the accumulated value, given the current and previous  accumulated value
}, initialValue[, thisArg]);

map 通过单独转换数组中的每个元素来创建一个新的数组。 通过删除不属于该数组的元素来创建一个新的数组。在另一方面,"减法 "将一个数组中的所有元素filter reduce为一个单一的值。

就像mapfilterreduce 是在Array.prototype 上定义的,所以在任何数组上都可以使用,你可以传递一个回调作为它的第一个参数。但它也需要第二个参数:开始将所有数组元素组合成的值。

reduce 传递给回调的四个参数:

  1. 当前值
  2. 前一个值
  3. 当前的索引
  4. 你调用reduce数组

注意,回调在每次迭代时都会得到一个前值。在第一次迭代时,没有前值。这就是为什么你可以选择传递给reduce 一个初始值。它作为第一次迭代的 "前值",否则就没有前值了。

最后,请记住,reduce 返回一个单一的值,而不是一个包含单一项目的数组。这一点比它看起来更重要,我将在例子中再讨论这个问题。

reduce 在实践中

比方说,你想找到一串数字的总和。使用一个循环,它看起来像这样

let numbers = [1, 2, 3, 4, 5],
    total = 0;
      
numbers.forEach(function (number) {
    total += number;
});

console.log(total); // 15

虽然这对forEach 来说并不是一个坏的用例,但reduce 仍有一个优势,即允许我们避免突变。使用reduce ,我们会写

const total = [1, 2, 3, 4, 5].reduce(function (previous, current) {
    return previous + current;
}, 0);
console.log(total); // 15

首先,我们在我们的数字列表上调用reduce 。我们传递给它一个回调,它接受前一个值和当前值作为参数,并返回它们相加的结果。由于我们把0 作为第二个参数传给了reduce ,它将在第一次迭代中使用这个参数作为previous 的值。

对于箭头函数,我们会这样写:

const total = [1, 2, 3, 4, 5].reduce((previous, current) => previous+current),0;
console.log(total) // 15

如果我们一步一步来,它看起来像这样:

迭代前一个当前的总数
1011
2123
3336
46410
510515

如果你不喜欢表格,可以在控制台运行这个片段:

const total = [1, 2, 3, 4, 5].reduce(function (previous, current, index) {
    const val = previous + current;
    console.log("The previous value is " + previous + 
                "; the current value is " + current +
                ", and the current iteration is " + (index + 1));
    return val;
}, 0);
 
console.log("The loop is done, and the final value is " + total + ".");
 

回顾一下:reduce 遍历一个数组的所有元素,以你在回调中指定的方式组合它们。在每一次迭代中,你的回调可以访问前一个 ,也就是迄今为止的总数,或累积值当前值当前索引;以及整个数组,如果你需要它们的话。

让我们回头看看我们的任务例子。我们已经从map ,得到了一个任务名称的列表,并且用......嗯,filter ,得到了一个花了很长时间的过滤的任务列表。

如果我们想知道我们今天工作的总时间,会怎样?

使用forEach 循环,你会写:

let total_time = 0;
      
tasks.forEach(function (task) {
    // The plus sign just coerces 
    // task.duration from a String to a Number
    total_time += (+task.duration);
});
 
console.log(total_time)
 
//expected result is 420

reduce ,那就变成了

total_time = tasks.reduce((previous, current) => previous + current.duration, 0);
console.log(total_time); //420

这几乎是所有的东西了。几乎,因为JavaScript还为我们提供了一个鲜为人知的方法,叫做reduceRight 。在上面的例子中,reduce 从数组的第一项开始,从左到右迭代。

let array_of_arrays = [[1, 2], [3, 4], [5, 6]];
const concatenated = array_of_arrays.reduce( function (previous, current) {
        return previous.concat(current);
});
  
console.log(concatenated); // [1, 2, 3, 4, 5, 6];

reduceRight 做同样的事情,但方向相反。

let array_of_arrays = [[1, 2], [3, 4], [5, 6]];
const concatenated = array_of_arrays.reduceRight( function (previous, current) {
        return previous.concat(current);
});
 
console.log(concatenated); // [5, 6, 3, 4, 1, 2];

我每天都在使用reduce ,但我从来不需要reduceRight 。我想你可能也不会。但如果你需要的话,现在你知道它就在那里。

缺点

reduce 的三个大问题是:

  1. 忘记了return
  2. 忘记了一个初始值
  3. reduce 返回一个单一的值时,却期待着一个数组。

幸运的是,前两个问题很容易避免。决定你的初始值应该是什么取决于你在做什么,但你很快就会掌握它。

最后一个问题可能看起来有点奇怪。如果reduce 只返回一个单一的值,为什么你会期望一个数组呢?

这有几个很好的理由。首先,reduce 总是返回一个单值,而不总是一个单数。例如,如果你减少一个数组,它将返回一个单一的数组。如果你有减少数组的习惯,那么就可以预期包含单项的数组不会是一个特殊的情况。

其次,如果reduce 确实返回一个含有单个值的数组,那么它自然会与mapfilter 以及其他你可能会使用的数组上的函数配合使用。

实施

是时候让我们最后看看引擎盖下面了。像

let reduce = function (array, callback, initial) {
    let accumulator = initial || 0;
     
    array.forEach(function (element) {
       accumulator = callback(accumulator, array[i]);
    });
     
    return accumulator;
};

这里有两件事需要注意:

  1. 这一次,我使用了accumulator ,而不是previous 。这是你在野外通常会看到的情况。
  2. 如果用户提供了一个初始值,我就给accumulator ,如果没有就默认为0 。这也是真正的reduce 的行为方式。

把它放在一起。Map、Filter、Reduce和Chainability

在这一点上,你可能没有那么深刻的印象。有道理:mapfilterreduce ,就它们本身而言,并不十分有趣。毕竟,它们真正的力量在于它们的可链性。

比方说,我想做以下事情:

  1. 收集两天的任务量。
  2. 将任务的持续时间转换成小时而不是分钟。
  3. 筛选出所有花了两个小时或更多时间的任务。
  4. 将其全部加总。
  5. 将结果乘以每小时的费率,以便计费。
  6. 输出一个格式化的美元数额。

首先,让我们定义星期一和星期二的任务。

const monday = [
        {
            'name'     : 'Write a tutorial',
            'duration' : 180
        },
        {
            'name'     : 'Some web development',
            'duration' : 120
        }
    ];
 
const tuesday = [
        {
            'name'     : 'Keep writing that tutorial',
            'duration' : 240
        },
        {
            'name'     : 'Some more web development',
            'duration' : 180
        },
        {
            'name'     : 'A whole lot of nothing',
            'duration'  : 240
        }
    ];
    
const tasks = [monday, tuesday];

而现在,我们可爱的转变:

                  
const result = tasks
    // Concatenate our 2D array into a single list
    .reduce((acc, current) => acc.concat(current))
    // Extract the task duration, and convert minutes to hours
    .map((task) => task.duration / 60)
    // Filter out any task that took less than two hours
    .filter((duration) => duration >= 2)
    // Multiply each tasks' duration by our hourly rate
    .map((duration) => duration * 25)
    // Combine the sums into a single dollar amount
    .reduce((acc, current) => [(+acc) + (+current)])
    // Convert to a "pretty-printed" dollar amount
    .map((amount) => '$' + amount.toFixed(2))
    // Pull out the only element of the array we got from map
    .reduce((formatted_amount) =>formatted_amount); 

如果你已经走到了这一步,这应该是很简单的了。不过,有两点奇怪的地方需要解释。

首先,在第10行,我必须写:

// Remainder omitted
reduce(function (accumulator, current) {
    return [(+accumulator) + (+current_];
})

这里有两件事要解释:

  1. accumulatorcurrent 前面的加号使它们的值变成数字。如果你不这样做,返回值将是一个相当无用的字符串,"12510075100"
  2. 如果你不把这个总和包在括号里,reduce 会吐出一个单一的值,而不是一个数组。这将导致抛出一个TypeError ,因为你只能在一个数组上使用map!

第二个可能让你有点不舒服的地方是最后一个reduce ,即:

// Remainder omitted
map(function (dollar_amount) {
    return '$' + dollar_amount.toFixed(2);
}).reduce(function (formatted_dollar_amount) {
    return formatted_dollar_amount;
});

map 的调用返回一个包含单个值的数组。这里,我们调用reduce 来拉出这个值。

最后,让我们看看我们的朋友forEach 循环会如何完成它。

let concatenated = monday.concat(tuesday),
    fees = [],
    hourly_rate = 25,
    total_fee = 0;
  
concatenated.forEach(function (task) {
    let duration = task.duration / 60;
      
    if (duration >= 2) {
        fees.push(duration * hourly_rate);
    }
});
  
fees.forEach(function (fee) {
    total_fee += fee;
});



console.log(total_fee); //400
 

可以忍受,但很嘈杂。

结论和后续步骤

在本教程中,你已经了解了mapfilterreduce 的工作原理;如何使用它们;以及它们的大致实现方式。你已经看到它们都允许你避免突变状态,而使用forforEach 循环需要突变状态,现在你应该对如何将它们连在一起有了很好的概念。