介绍
JavaScript 中的函数式编程有利于代码的可读性、可维护性和可测试性。 函数式编程思维的工具之一是以数组处理风格进行编程。 这需要将数组作为您的基本数据结构。 然后,您的程序就变成了对数组中元素的一系列操作。
在许多情况下这很有用,例如 使用 map 将 AJAX 结果映射到 React 组件,使用 filter
删除无关数据,以及使用 reduce
。 这些称为“Array Extras”的函数是对 for
循环的抽象。 这些功能没有您可以用 for
实现的功能,反之亦然。
在本教程中,您将通过查看 filter
、map
和 reduce
来更深入地了解 JavaScript 中的函数式编程。
先决条件
要完成本教程,您将需要以下内容:
- 对 JavaScript 的工作理解。 您可以查看 如何在 JavaScript 系列中编写代码以获取更多信息。
- 了解如何在 JavaScript 中构建和实现
for
循环。 这篇关于 JavaScript 中的循环的 文章是一个很好的起点。 - Node.js 安装在本地,您可以按照【X65X】如何安装Node.js 并创建本地开发环境【X134X】来完成。
第 1 步 — 使用 forEach
进行迭代
for
循环用于遍历数组中的每个项目。 通常,沿途对每个项目都做了一些事情。
一个例子是将数组中的每个字符串都大写。
const strings = ['arielle', 'are', 'you', 'there']; const capitalizedStrings = []; for (let i = 0; i < strings.length; i += 1) { const string = strings[i]; capitalizedStrings.push(string.toUpperCase()); } console.log(capitalizedStrings);
在此代码段中,您从一组称为 strings
的小写短语开始。 然后,一个名为 capitalizedStrings
的空数组被初始化。 capitalizedStrings
数组将存储大写的字符串。
在 for
循环内部,每次迭代的下一个字符串都大写并推送到 capitalizedStrings
。 在循环结束时,capitalizedStrings
包含 strings
中每个单词的大写版本。
可以使用 forEach
函数使这段代码更简洁。 这是一种“自动”循环遍历列表的数组方法。 换句话说,它处理初始化和递增计数器的细节。
代替上面手动索引到 strings
的方法,您可以调用 forEach
并在每次迭代时接收下一个字符串。 更新后的版本如下所示:
const strings = ['arielle', 'are', 'you', 'there']; const capitalizedStrings = []; strings.forEach(function (string) { capitalizedStrings.push(string.toUpperCase()); }) console.log(capitalizedStrings);
这非常接近初始功能。 但它消除了对 i
计数器的需要,使您的代码更具可读性。
这也引入了一个你会一次又一次看到的主要模式。 即:最好在 Array.prototype
上使用抽象出初始化和递增计数器等细节的方法。 这样,您就可以专注于重要的逻辑。 本文将讨论其他几种数组方法。 接下来,您将使用加密和解密来充分展示这些方法的功能。
第 2 步 — 了解凯撒密码和加密和解密
在下面的代码片段中,您将使用数组方法 map
、reduce
和 filter
来加密和解密字符串。
首先了解什么是加密很重要。 如果您将 'this is my super-secret message'
之类的普通消息发送给朋友并且其他人得到了它,即使不是预期的收件人,他们也可以立即阅读该消息。 如果您发送的敏感信息(例如密码)可能有人在监听,这将是一件坏事。
加密字符串的意思是:“加扰它以使其难以阅读而不加扰。” 这样,即使有人在听并且他们确实拦截了您的消息,在他们解读之前,它仍将保持不可读。
有不同的加密方法,Caesar cipher 是对这样的字符串进行加扰的一种方法。 此密码可在您的代码中使用。 创建一个名为 caesar
的常量变量。 要加密代码中的字符串,请使用以下函数:
var caesarShift = function (str, amount) { if (amount < 0) { return caesarShift(str, amount + 26); } var output = ""; for (var i = 0; i < str.length; i++) { var c = str[i]; if (c.match(/[a-z]/i)) { var code = str.charCodeAt(i); if (code >= 65 && code <= 90) { c = String.fromCharCode(((code - 65 + amount) % 26) + 65); } else if (code >= 97 && code <= 122) { c = String.fromCharCode(((code - 97 + amount) % 26) + 97); } } output += c; } return output; };
这个 GitHub gist 包含由 Evan Hahn 创建的这个凯撒密码函数的原始代码。
要使用凯撒密码进行加密,您必须选择一个介于 1 到 25 之间的密钥 n
,然后将原始字符串中的每个字母替换为字母表更靠后的一个 n
字母。 因此,如果选择键 2,a
变为 c
; b
变为 d
; c
变为 e
; 等等
像这样替换字母会使原始字符串不可读。 由于字符串是通过移动字母来加扰的,因此可以通过将它们移回来进行解扰。 如果您收到一条您知道已使用密钥 2 加密的消息,则解密所需要做的就是将字母向后移动两个空格。 因此,c
变为 a
; d
变为 b
; 等等 要查看实际的凯撒密码,请调用 caesarShift
函数并将字符串 'this is my super-secret message.'
作为第一个参数传入,将数字 2
作为第二个参数传入:
const encryptedString = caesarShift('this is my super-secret message.', 2);
在此代码中,通过将每个字母向前移动 2 个字母来对消息进行加扰: a
变为 c
; s
变为 u
; 等等 要查看结果,请使用 console.log
将 encryptedString
打印到控制台:
const encryptedString = caesarShift('this is my super-secret message.', 2); console.log(encryptedString);
引用上面的例子,消息 'this is my super-secret message'
变成了加扰消息 'vjku ku oa uwrgt-ugetgv oguucig.'
。
不幸的是,这种加密形式很容易被破解。 解密使用凯撒密码加密的任何字符串的一种方法是尝试使用每个可能的密钥对其进行解密。 结果之一将是正确的。
对于即将出现的一些代码示例,您需要解密一些加密消息。 此 tryAll
函数可用于执行此操作:
const tryAll = function (encryptedString) { const decryptionAttempts = [] while (decryptionAttempts.length < 26) { const decryptionKey = -decryptionAttempts.length; const decryptionAttempt = caesarShift(encryptedString, decryptionKey); decryptionAttempts.push(decryptionAttempt) } return decryptionAttempts; };
上面的函数接受一个加密字符串,并返回一个包含所有可能解密的数组。 这些结果之一将是您想要的字符串。 所以,这总是会破解密码。
扫描包含 26 种可能解密的数组具有挑战性。 有可能消除那些绝对不正确的。 您可以使用此功能 isEnglish
来执行此操作:
'use strict' const fs = require('fs') const _getFrequencyList = () => { const frequencyList = fs.readFileSync(`${__dirname}/eng_10k.txt`).toString().split('\n').slice(1000) const dict = {}; frequencyList.forEach(word => { if (!word.match(/[aeuoi]/gi)) { return; } dict[word] = word; }) return dict; } const isEnglish = string => { const threshold = 3; if (string.split(/\s/).length < 6) { return true; } else { let count = 0; const frequencyList = _getFrequencyList(); string.split(/\s/).forEach(function (string) { const adjusted = string.toLowerCase().replace(/\./g, '') if (frequencyList[adjusted]) { count += 1; } }) return count > threshold; } }
这个 GitHub gist 包含 Peleke Sengstacke 创建的 tryAll
和 isEnglish
的原始代码。
确保将这个英语中最常见的1000个单词列表保存为eng_10k.txt
。
您可以将所有这些函数包含在同一个 JavaScript 文件中,也可以将每个函数作为模块导入。
isEnglish
函数读取一个字符串,计算该字符串中最常见的 1,000 个英语单词中有多少个出现在该字符串中,如果它在句子中找到超过 3 个单词,则将该字符串分类为英语。 如果字符串包含该数组中少于 3 个单词,则将其丢弃。
在 filter
部分中,您将使用 isEnglish
函数。
您将使用这些函数来演示数组方法 map
、filter
和 reduce
如何工作。 map
方法将在下一步中介绍。
第 3 步 — 使用 map
转换数组
重构 for
循环以使用 forEach
暗示了这种风格的优点。 但仍有改进的余地。 在前面的示例中,capitalizedStrings
数组在 forEach
的回调中被更新。 这本身并没有什么错。 但最好尽可能避免这样的副作用。 如果不必更新位于不同范围内的数据结构,最好避免这样做。
在这种特殊情况下,您希望将 strings
中的每个字符串都转换为其大写版本。 这是 for
循环的一个非常常见的用例:获取数组中的所有内容,将其转换为其他内容,然后将结果收集到一个新数组中。
将数组中的每个元素转换为新元素并收集结果称为映射。 JavaScript 有一个用于此用例的内置函数,称为 map
。 使用 forEach
方法是因为它抽象了管理迭代变量 i
的需要。 这意味着您可以专注于真正重要的逻辑。 类似地,使用 map
是因为它抽象出初始化一个空数组并推送给它。 就像 forEach
接受一个对每个字符串值执行某些操作的回调,map
接受一个对每个字符串值执行某些操作的回调。
在最终解释之前,让我们看一个快速演示。 在以下示例中,将使用加密函数。 您可以使用 for
循环或 forEach
。 但在这种情况下最好使用 map
。
为了演示如何使用 map
函数,创建 2 个常量变量:一个名为 key
的值为 12 和一个名为 messages
的数组:
const key = 12; const messages = [ 'arielle, are you there?', 'the ghost has killed the shell', 'the liziorati attack at dawn' ]
现在创建一个名为 encryptedMessages
的常量。 在 messages
上使用 map
函数:
const encryptedMessages = messages.map()
在 map
中,创建一个具有参数 string
的函数:
const encryptedMessages = messages.map(function (string) { })
在此函数内部,创建一个 return
语句,该语句将返回密码函数 caesarShift
。 caesarShift
函数应该有 string
和 key
作为它的参数:
const encryptedMessages = messages.map(function (string) { return caesarShift(string, key); })
打印 encryptedMessages
到控制台查看结果:
const encryptedMessages = messages.map(function (string) { return caesarShift(string, key); }) console.log(encryptedMessages);
注意这里发生了什么。 map
方法在 messages
上使用 caesar
函数对每个字符串进行加密,并自动将结果存储在新数组中。
上述代码运行后,encryptedMessages
看起来像:['mduqxxq, mdq kag ftqdq?', 'ftq staef tme wuxxqp ftq etqxx', 'ftq xuluadmfu mffmow mf pmiz']
。 这是比手动推送到数组更高级别的抽象。
您可以使用箭头函数重构 encryptedMessages
以使您的代码更简洁:
const encryptedMessages = messages.map(string => caesarShift(string, key));
现在您已经彻底了解了 map
的工作原理,您可以使用 filter
数组方法。
第 4 步 — 使用 filter
从数组中选择值
另一种常见的模式是使用 for
循环来处理数组中的项,但只推送/保留一些数组项。 通常,if
语句用于决定保留哪些物品,丢弃哪些物品。
在原始 JavaScript 中,这可能看起来像:
const encryptedMessage = 'mduqxxq, mdq kag ftqdq?'; const possibilities = tryAll(encryptedMessage); const likelyPossibilities = []; possibilities.forEach(function (decryptionAttempt) { if (isEnglish(decryptionAttempt)) { likelyPossibilities.push(decryptionAttempt); } })
tryAll
函数用于解密encryptedMessage
。 这意味着您最终会得到 26 种可能性。
由于大多数解密尝试将不可读,因此使用 forEach
循环使用 isEnglish
函数检查每个解密的字符串是否为英文。 英文字符串被推送到一个名为 likelyPossibilities
的数组中。
这是一个常见的用例。 因此,它有一个内置函数,称为 filter
。 与 map
一样,filter
被赋予一个回调,它也获取每个字符串。 不同之处在于,如果回调返回 true
,filter
只会将项目保存在数组中。
您可以重构上面的代码片段以使用 filter
而不是 forEach
。 likelyPossibilities
变量将不再是空数组。 相反,将其设置为等于 possibilities
数组。 在 possibilities
上调用 filter
方法:
const likelyPossibilities = possibilities.filter()
在 filter
中,创建一个接受名为 string
的参数的函数:
const likelyPossibilities = possibilities.filter(function (string) { })
在此函数中,使用 return
语句返回 isEnglish
的结果,并将 string
作为其参数传入:
const likelyPossibilities = possibilities.filter(function (string) { return isEnglish(string); })
如果 isEnglish(string)
返回 true
,则 filter
将 string
保存在新的 likelyPossibilities
数组中。
由于此回调调用 isEnglish
,因此可以进一步重构此代码以更简洁:
const likelyPossibilities = possibilities.filter(isEnglish);
reduce
方法是另一个非常重要的抽象。
第 5 步 — 使用 reduce
将数组转换为单个值
遍历数组以将其元素收集到单个结果中是一个非常常见的用例。
一个很好的例子是使用 for
循环遍历数字数组并将所有数字相加:
const prices = [12, 19, 7, 209]; let totalPrice = 0; for (let i = 0; i < prices.length; i += 1) { totalPrice += prices[i]; } console.log(`Your total is ${totalPrice}.`);
prices
中的数字循环,每个数字添加到 totalPrice
。 reduce
方法是这个用例的抽象。
您可以使用 reduce
重构上述循环。 您将不再需要 totalPrice
变量。 在 prices
上调用 reduce
方法:
const prices = [12, 19, 7, 209]; prices.reduce()
reduce
方法将持有一个回调函数。 与 map
和 filter
不同,传递给 reduce
的回调接受两个参数:总累计价格和数组中要添加到总价格中的下一个价格。 这将分别是 totalPrice
和 nextPrice
:
prices.reduce(function (totalPrice, nextPrice) { })
为了进一步分解,totalPrice
类似于第一个示例中的 total
。 这是迄今为止看到的所有收到的价格相加后的总价格。
与前面的示例相比,nextPrice
对应于 prices[i]
。 回想一下 map
和 reduce
自动索引到数组中,并自动将此值传递给它们的回调。 reduce
方法做同样的事情,但将该值作为第二个参数传递给它的回调。
在将 totalPrice
和 nextPrice
打印到控制台的函数中包含两个 console.log
语句:
prices.reduce(function (totalPrice, nextPrice) { console.log(`Total price so far: ${totalPrice}`) console.log(`Next price to add: ${nextPrice}`) })
您需要更新 totalPrice
以包含每个新的 nextPrice
:
prices.reduce(function (totalPrice, nextPrice) { console.log(`Total price so far: ${totalPrice}`) console.log(`Next price to add: ${nextPrice}`) totalPrice += nextPrice })
就像 map
和 reduce
一样,每次迭代都需要返回一个值。 在这种情况下,该值为 totalPrice
。 所以为 totalPrice
创建一个 return
语句:
prices.reduce(function (totalPrice, nextPrice) { console.log(`Total price so far: ${totalPrice}`) console.log(`Next price to add: ${nextPrice}`) totalPrice += nextPrice return totalPrice })
reduce
方法有两个参数。 第一个是已经创建的回调函数。 第二个参数是一个数字,将作为 totalPrice
的起始值。 这对应于前面示例中的 const total = 0
。
prices.reduce(function (totalPrice, nextPrice) { console.log(`Total price so far: ${totalPrice}`) console.log(`Next price to add: ${nextPrice}`) totalPrice += nextPrice return totalPrice }, 0)
正如您现在所看到的,reduce
可用于将一组数字收集到一个总和中。 但是 reduce
是通用的。 它可用于将数组转换为任何单个结果,而不仅仅是数值。
例如,reduce
可用于构建字符串。 要查看实际情况,首先创建一个字符串数组。 下面的示例使用了一系列名为 courses
的计算机科学课程:
const courses = ['Introduction to Programming', 'Algorithms & Data Structures', 'Discrete Math'];
创建一个名为 curriculum
的常量变量。 在 courses
上调用 reduce
方法。 回调函数应该有两个参数:courseList
和 course
:
const courses = ['Introduction to Programming', 'Algorithms & Data Structures', 'Discrete Math']; const curriculum = courses.reduce(function (courseList, course) { });
courseList
需要更新以包含每个新的 course
:
const courses = ['Introduction to Programming', 'Algorithms & Data Structures', 'Discrete Math']; const curriculum = courses.reduce(function (courseList, course) { return courseList += `\n\t${course}`; });
\n\t
将在每个 course
之前创建一个用于缩进的换行符和制表符。
reduce
(回调函数)的第一个参数完成。 因为正在构造一个字符串,而不是一个数字,所以第二个参数也将是一个字符串。
下面的示例使用 'The Computer Science curriculum consists of:'
作为 reduce
的第二个参数。 添加 console.log
语句以将 curriculum
打印到控制台:
const courses = ['Introduction to Programming', 'Algorithms & Data Structures', 'Discrete Math']; const curriculum = courses.reduce(function (courseList, course) { return courseList += `\n\t${course}`; }, 'The Computer Science curriculum consists of:'); console.log(curriculum);
这会生成输出:
OutputThe Computer Science curriculum consists of: Introduction to Programming Algorithms & Data Structures Discrete Math
如前所述,reduce
是通用的。 它可用于将数组转换为任何类型的单个结果。 该单个结果甚至可以是一个数组。
创建一个字符串数组:
const names = ['arielle', 'jung', 'scheherazade'];
titleCase
函数将字符串中的第一个字母大写:
const names = ['arielle', 'jung', 'scheherazade']; const titleCase = function (name) { const first = name[0]; const capitalizedFirst = first.toUpperCase(); const rest = name.slice(1); const letters = [capitalizedFirst].concat(rest); return letters.join(''); }
titleCase
通过在 0
索引处抓取字符串的第一个字母,在该字母上使用 toUpperCase
,抓取字符串的其余部分,将字符串中的第一个字母大写,然后将一切连接在一起。
在 titleCase
就位后,创建一个名为 titleCased
的常量变量。 将其设置为等于 names
并在 names
上调用 reduce
方法:
const names = ['arielle', 'jung', 'scheherazade']; const titleCase = function (name) { const first = name[0]; const capitalizedFirst = first.toUpperCase(); const rest = name.slice(1); const letters = [capitalizedFirst].concat(rest); return letters.join(''); } const titleCased = names.reduce()
reduce
方法将有一个回调函数,它以 titleCasedNames
和 name
作为参数:
const names = ['arielle', 'jung', 'scheherazade']; const titleCase = function (name) { const first = name[0]; const capitalizedFirst = first.toUpperCase(); const rest = name.slice(1); const letters = [capitalizedFirst].concat(rest); return letters.join(''); } const titleCased = names.reduce(function (titleCasedNames, name) { })
在回调函数中,创建一个名为 titleCasedName
的常量变量。 调用 titleCase
函数并传入 name
作为参数:
const names = ['arielle', 'jung', 'scheherazade']; const titleCase = function (name) { const first = name[0]; const capitalizedFirst = first.toUpperCase(); const rest = name.slice(1); const letters = [capitalizedFirst].concat(rest); return letters.join(''); } const titleCased = names.reduce(function (titleCasedNames, name) { const titleCasedName = titleCase(name); })
这将使 names
中的每个名称大写。 回调函数 titleCasedNames
的第一个回调参数将是一个数组。 将 titleCasedName
(name
的大写版本)推送到此数组并返回 titleCaseNames
:
const names = ['arielle', 'jung', 'scheherazade']; const titleCase = function (name) { const first = name[0]; const capitalizedFirst = first.toUpperCase(); const rest = name.slice(1); const letters = [capitalizedFirst].concat(rest); return letters.join(''); } const titleCased = names.reduce(function (titleCasedNames, name) { const titleCasedName = titleCase(name); titleCasedNames.push(titleCasedName); return titleCasedNames; })
reduce
方法需要两个参数。 首先是回调函数完成。 由于此方法正在创建一个新数组,因此初始值将是一个空数组。 此外,包括 console.log
以将最终结果打印到屏幕上:
const names = ['arielle', 'jung', 'scheherazade']; const titleCase = function (name) { const first = name[0]; const capitalizedFirst = first.toUpperCase(); const rest = name.slice(1); const letters = [capitalizedFirst].concat(rest); return letters.join(''); } const titleCased = names.reduce(function (titleCasedNames, name) { const titleCasedName = titleCase(name); titleCasedNames.push(titleCasedName); return titleCasedNames; }, []) console.log(titleCased);
运行代码后,它将生成以下大写名称数组:
Output["Arielle", "Jung", "Scheherazade"]
您使用 reduce
将小写名称数组转换为标题大小写名称数组。
前面的例子证明了 reduce
可以用来把一个数字列表变成一个单一的和,也可以用来把一个字符串列表变成一个单一的字符串。 在这里,您使用 reduce
将小写名称数组转换为单个大写名称数组。 这仍然是有效的用例,因为大写名称的单个列表仍然是单个结果。 它恰好是一个集合,而不是原始类型。
结论
在本教程中,您学习了如何使用 map
、filter
和 reduce
编写更具可读性的代码。 使用 for
循环没有任何问题。 但是,通过这些函数提高抽象级别,必然会为可读性和可维护性带来立竿见影的好处。
从这里开始,您可以开始探索flatten
和flatMap
等其他数组方法。 这篇名为 Flatten Arrays in Vanilla JavaScript with flat() 和 flatMap() 的文章是一个很好的起点。