JavaScript-函数式入门指南-全-
JavaScript 函数式入门指南(全)
一、函数式编程简介
函数的第一条规则是它们应该很小。函数的第二个规则是它们应该比那个小。
罗伯特·马丁
欢迎来到函数式编程的世界,一个只有函数的世界,快乐地生活着,没有任何外部世界的依赖,没有状态,没有突变——永远。函数式编程现在是个时髦词。你可能在你的团队或当地的小组会议中听说过这个术语。如果你已经意识到这意味着什么,很好。对于那些不知道这个术语的人,不用担心。本章旨在用简单的英语向您介绍功能性术语。
我们将以问一个简单的问题开始这一章:数学中的函数是什么?稍后,我们将使用我们的函数定义用 JavaScript 创建一个简单的函数。本章最后解释了函数式编程给开发人员带来的好处。
什么是函数式编程?为什么重要?
在我们开始探讨函数式编程意味着什么之前,我们必须回答另一个问题:数学中的函数是什么?数学中的一个函数可以写成这样:
f ( X ) = Y
The statement can be read as “A function f, which takes X as its argument, and returns the output Y.” X and Y can be any number, for instance. That’s a very simple definition. There are key takeaways in the definition, though:
-
函数必须总是带一个参数。
-
函数必须总是返回值。
-
一个函数应该只作用于它的接收参数(即 X ),而不是外界。
-
对于给定的 X,只会有一个 Y.
你可能想知道为什么我们用数学而不是用 JavaScript 给出函数的定义。是吗?这是一个很好的问题。答案非常简单:函数式编程技术在很大程度上基于数学函数及其思想。不过,请屏住呼吸;我们不会用数学来教你函数式编程,而是用 JavaScript。然而,在整本书中,我们将看到数学函数的概念,以及它们是如何被用来帮助理解函数式编程的。
With that definition in place, we are going to see the examples of functions in JavaScript. Imagine we have to write a function that does tax calculations. How are you going to do this in JavaScript? We can implement such a function as shown in Listing 1-1.var percentValue = 5; var calculateTax = (value) => { return value/100 * (100 + percentValue) } Listing 1-1
计算税收功能
calculateTax 函数正是我们想要做的。您可以使用值调用此函数,这将在控制台中返回计算出的税收值。看起来很整洁,不是吗?让我们暂停一下,用我们的数学定义来分析这个函数。我们的数学函数项的一个关键点是函数逻辑不应该依赖于外界。在我们的 calculateTax 函数中,我们让函数依赖于全局变量 percentValue。因此我们创造的这个函数在数学意义上不能被称为真正的函数。让我们解决这个问题。
The fix is very straightforward: We have to just move the percentValue as our function argument, as shown in Listing 1-2.var calculateTax = (value, percentValue) => { return value/100 * (100 + percentValue) } Listing 1-2
重写的计税函数
现在我们的 calculateTax 函数可以作为实函数来调用了。然而,我们得到了什么?我们刚刚在 calculateTax 函数中取消了全局变量访问。移除函数内部的全局变量访问使得测试变得容易。(我们将在本章后面讨论函数式编程的好处。)
现在我们已经展示了数学函数和 JavaScript 函数之间的关系。通过这个简单的练习,我们可以用简单的技术术语定义函数式编程。函数式编程是一种范例,在这种范例中,我们将创建一些函数,这些函数将只依赖于它们的输入来计算出它们的逻辑。这确保了一个函数在被多次调用时会返回相同的结果。该函数也不会改变外部世界的任何数据,从而产生一个可缓存和可测试的代码库。
JAVASCRIPT 中的函数与方法
在这篇课文中,我们已经谈了很多关于功能这个词。在我们继续之前,我们希望确保您理解 JavaScript 中函数和方法之间的区别。
简单来说,函数就是一段可以通过名字调用的代码。它可以用来传递它可以操作的参数,并有选择地返回值。
一个方法是一段代码,它必须通过与一个对象相关联的名字来调用。
Listing 1-3 and Listing 1-4 provide quick examples of a function and a method.var simple = (a) => {return a} // A simple function simple(5) //called by its name Listing 1-3
简单的功能
var obj = {simple : (a) => {return a} } obj.simple(5) //called by its name along with its associated object Listing 1-4
简单的方法
函数式编程还有两个更重要的特征在定义中缺失了。在深入了解函数式编程的好处之前,我们将在接下来的章节中详细讨论它们。
对透明性有关的
With our definition of function, we have made a statement that all the functions are going to return the same value for the same input. This property of a function is called a referential transparency . A simple example is shown in Listing 1-5.var identity = (i) => { return i } Listing 1-5
参考透明度示例
In Listing 1-5, we have defined a simple function called identity. This function is going to return whatever you’re passing as its input; that is, if you’re passing 5, it’s going to return the value 5 (i.e., the function just acts as a mirror or identity). Note that our function operates only on the incoming argument i, and there is no global reference inside our function (remember in Listing 1-2, we removed percentValue from global access and made it an incoming argument). This function satisfies the conditions of a referential transparency. Now imagine this function is used between other function calls like this:sum(4,5) + identity(1) With our referential transparency definition, we can convert that statement into this:sum(4,5) + 1
现在这个过程被称为替代模型,因为你可以直接用它的值替代函数的结果(主要是因为函数的逻辑不依赖于其他全局变量)。这导致了并行代码和缓存。想象一下,有了这个模型,你可以轻松地用多线程运行给定的函数,甚至不需要同步。为什么呢?同步的原因来自于线程在并行运行时不应该作用于全局数据这一事实。遵循参照透明性的函数将只依赖于它们参数的输入;因此,线程可以自由运行,无需任何锁定机制。
因为对于给定的输入,函数将返回相同的值,我们实际上可以缓存它。比如,假设有一个函数叫做阶乘,它计算给定数字的阶乘。Factorial 将输入作为需要计算阶乘的参数。我们知道 5 的阶乘是 120。如果用户第二次调用 5 的阶乘呢?如果阶乘函数遵循引用透明性,我们知道结果将是 120(它只取决于输入参数)。记住这个特征,我们可以缓存阶乘函数的值。因此,如果第二次调用 factorial,输入为 5,我们可以返回缓存的值,而不是再次计算它。
这里你可以看到一个简单的想法是如何帮助并行代码和可缓存代码的。在本章的后面,我们将在我们的库中编写一个函数来缓存函数结果。
参照透明是一种哲学
参照透明是一个来自分析哲学的术语(en.wikipedia.org/wiki/Analytical_philosophy
)。这一哲学分支研究自然语言语义及其含义。在这里,所指的或所指的就是表达所指的事物。如果用指代相同实体的另一个术语替换上下文中的一个术语不会改变其含义,那么句子中的上下文就是指称透明的。
这正是我们在这里定义参照透明性的方式。我们在不影响上下文的情况下替换了函数值。
命令式、声明式、抽象式
函数式编程也是关于声明性和编写抽象代码。在我们继续之前,我们需要理解这两个术语。我们都知道并致力于一个必要的范例。我们将解决一个问题,看看如何以命令和声明的方式解决它。
Suppose you have a list or array and want to iterate through the array and print it to the console. The code might look like Listing 1-6.var array = [1,2,3] for(i=0;i<array.length;i++) console.log(array[i]) //prints 1, 2, 3 Listing 1-6
迭代数组命令式方法
它工作正常。然而,在这种解决问题的方法中,我们确切地告诉了我们需要“如何”去做。例如,我们已经编写了一个隐式 For 循环,它包含数组长度的索引计算和打印项。我们就此打住。这里的任务是什么?打印数组元素,对吗?然而,看起来我们是在告诉编译器该做什么。在这种情况下,我们告诉编译器,“获取数组长度,循环我们的数组,使用索引获取数组的每个元素,等等。”我们称之为强制性解决方案。命令式编程就是告诉编译器如何做事。
We will now switch to the other side of the coin, declarative programming. In declarative programming, we are going to tell what the compiler needs to do rather than how. The “how” parts are abstracted into common functions (these functions are called higher order functions, which we cover in the upcoming chapters). Now we can use the built-in forEach function to iterate the array and print it, as shown in Listing 1-7.var array = [1,2,3] array.forEach((element) => console.log(element)) //prints 1, 2, 3 Listing 1-7
迭代数组声明方法
清单 1-7 确实打印出与清单 1-5 完全相同的输出。不过,在这里,我们已经删除了“如何”部分,如“获取数组长度,循环数组,使用索引获取数组的每个元素,等等。”我们使用了一个抽象的函数,它负责“如何做”的部分,让我们这些开发人员去担心手头的问题(“做什么”的部分)。我们将在整本书中创建这些内置函数。
函数式编程是以抽象的方式创建函数,这些函数可以被代码的其他部分重用。现在我们对什么是函数式编程有了一个坚实的理解;考虑到这一点,我们可以探索函数式编程的好处。
函数式编程的好处
我们已经看到了函数式编程的定义和 JavaScript 中一个非常简单的函数示例。我们现在必须回答一个简单的问题:函数式编程的好处是什么?本节帮助您了解函数式编程给我们带来的巨大好处。函数式编程的大部分好处来自于编写纯函数。所以在我们看到函数式编程的好处之前,我们需要知道什么是纯函数。
纯函数
With our definition in place, we can define what is meant by pure functions. Pure functions are the functions that return the same output for the given input. Take the example in Listing 1-8.var double = (value) => value * 2; Listing 1-8
一个简单的纯函数
这个函数 double 是一个纯函数,因为给定一个输入,它总是返回相同的输出。你可以自己试试。用输入 5 调用 double 函数总是得到结果 10。纯函数服从引用透明性。因此,我们可以毫不犹豫地用 10 代替 double(5)。
那么纯函数有什么大不了的?它们提供了许多好处,我们将在下面讨论。
纯函数导致可测试的代码
Functions that are not pure have side effects. Take our previous tax calculation example from Listing 1-1:var percentValue = 5; var calculateTax = (value) => { return value/100 * (100 + percentValue) } //depends on external environment percentValue variable
函数 calculateTax 不是一个纯函数,主要是因为计算它的逻辑依赖于外部环境。该功能可以工作,但是很难测试。我们来看看这其中的原因。
Imagine we are planning to run a test for our calculateTax function three times for three different tax calculations. We set up the environment like this:calculateTax(5) === 5.25 calculateTax(6) === 6.3 calculateTax(7) === 7.3500000000000005 The entire test passed. However, because our original calculateTax function depends on the external environment variable percentValue, things can go wrong. Imagine the external environment is changing the percentValue variable while you are running the same test cases:calculateTax(5) === 5.25 // percentValue is changed by other function to 2 calculateTax(6) === 6.3 //will the test pass? // percentValue is changed by other function to 0 calculateTax(7) === 7.3500000000000005 //will the test pass or throw exception? As you can see here, the function is very hard to test. We can easily fix the issue, though, by removing the external environment dependency from our function, leading the code to this:var calculateTax = (value, percentValue) => { return value/100 * (100 + percentValue) } Now you can test this function without any pain. Before we close this section, we need to mention an important property about pure functions: Pure functions also shouldn’t mutate any external environment variables. In other words, the pure function shouldn’t depend on any external variables (as shown in the example) and also change any external variables. We’ll now take a quick look what we mean by changing any external variables. For example, consider the code in Listing 1-9.var global = "globalValue" var badFunction = (value) => { global = "changed"; return value * 2 } Listing 1-9
不良功能示例
调用 badFunction 函数时,它会将全局变量 global 更改为已更改的值。这值得担心吗?是的。设想另一个函数,它的业务逻辑依赖于全局变量。因此,调用 badFunction 会影响其他函数的行为。这种性质的函数(即具有副作用的函数)使得代码库难以测试。除了测试之外,这些副作用会使系统行为在调试时很难预测。
我们已经通过一个简单的例子看到了一个纯函数如何帮助我们轻松地测试代码。现在我们来看看我们从纯函数中得到的其他好处:合理的代码。
合理代码
As developers we should be good at reasoning about the code or a function. By creating and using pure functions we can achieve that very simply. To make this point clearer, we are going to use a simple example of function double (from Listing 1-8):var double = (value) => value * 2
看这个函数名,我们很容易推理出这个函数是给定数字的两倍,除此之外没有别的。事实上,使用我们的引用透明概念,我们可以很容易地用相应的结果替换 double 函数调用。开发人员大部分时间都在阅读别人的代码。在你的代码库中有一个带有副作用的函数会让你团队中的其他开发人员很难读懂。具有纯功能的代码库易于阅读、理解和测试。记住一个函数(不管它是否是一个纯函数)必须有一个有意义的名字。例如,考虑到函数的作用,不能将它命名为 double。
小心思游戏
我们只是用一个值来代替函数,好像知道结果却看不到它的实现。这对于你思考函数的过程是一个很大的进步。我们代入函数值,就好像这是它将返回的结果。
为了快速锻炼你的大脑,看看我们内置的 Math.max 函数的推理能力。
Given the function call:Math.max(3,4,5,6)
结果会怎样?
看到 max 给出结果的实现了吗?不是吧?为什么呢?这个问题的答案是数学。max 是一个纯函数。现在喝杯咖啡;你做得很好!
并行代码
纯函数允许我们并行运行代码。因为一个纯粹的函数不会改变它的任何环境,这意味着我们根本不需要担心同步。当然,JavaScript 没有真正的线程来并行运行这些函数,但是如果您的项目使用 WebWorkers 来并行运行多个东西会怎么样呢?还是并行运行函数的节点环境中的服务器端代码?
For example, imagine we have the code given in Listing 1-10.let global = "something" let function1 = (input) => { // works on input //changes global global = "somethingElse" } let function2 = () => { if(global === "something") { //business logic } } Listing 1-10
不纯函数
What if we need to run both function1 and function2 in parallel? Imagine thread one (T-1) picks function1 to run and thread two (T-2) picks function2 to run. Now both threads are ready to run and here comes the problem. What if T-1 runs before T-2? Because both function1 and function2 depend on the global variable global, running these functions in parallel causes undesirable effects. Now change these functions into a pure function as explained in Listing 1-11.let function1 = (input,global) => { // works on input //changes global global = "somethingElse" } let function2 = (global) => { if(global === "something") { //business logic } } Listing 1-11
纯函数
这里,我们将全局变量作为两个函数的参数,使它们变得纯净。现在,我们可以毫无问题地并行运行这两个功能。因为函数不依赖于外部环境(全局变量),所以我们不像清单 1-10 那样担心线程的执行顺序。
这一节向我们展示了纯函数如何帮助我们的代码并行运行而没有任何问题。
可缓存
Because the pure function is going to always return the same output for the given input, we can cache the function outputs. To make this more concrete, we provide a simple example. Imagine we have a function that does time-consuming calculations. We name this function longRunningFunction:var longRunningFunction = (ip) => { //do long running tasks and return } If the longRunningFunction function is a pure function, then we know that for the given input, it is going to return the same output. With that point in mind, why do we need to call the function again with its input multiple times? Can’t we just replace the function call with the function’s previous result? (Again note here how we are using the referential transparency concept, thus replacing the function with the previous result value and leaving the context unchanged.) Imagine we have a bookkeeping object that keeps all the function call results of longRunningFunction like this:var longRunningFnBookKeeper = { 2 : 3, 4 : 5 . . . } The longRunningFnBookKeeper is a simple JavaScript object, which is going to hold all the input (as keys) and outputs (as values) in it as a result of invoking longRunningFunction functions. Now with our pure function definition in place, we can check if the key is present in longRunningFnBookKeeper before invoking our original function, as shown in Listing 1-12.var longRunningFnBookKeeper = { 2 : 3, 4 : 5 } //check if the key present in longRunningFnBookKeeper //if get back the result else update the bookkeeping object longRunningFnBookKeeper.hasOwnProperty(ip) ? longRunningFnBookKeeper[ip] : longRunningFnBookKeeper[ip] = longRunningFunction(ip) Listing 1-12
通过纯函数实现缓存
清单 1-12 中的代码相对简单。在调用我们真正的函数之前,我们正在检查具有相应 ip 的那个函数的结果是否在簿记对象中。如果是,我们将返回它,否则我们将调用我们的原始函数并更新我们的簿记对象中的结果。你看到我们用更少的代码使函数调用变得可缓存有多容易了吗?这就是纯函数的力量。
我们将会写一个函数库,在本书的后面,它会对我们的纯函数调用进行缓存,或者技术上的记忆。
管道和可组合
With pure functions, we are going to do only one thing in that function. We have seen already how the pure function is going to act as a self-understanding of what that function does by seeing its name. Pure functions should be designed in such a way that they should do only one thing. Doing only one thing and doing it perfectly is a UNIX philosophy; we will be following the same while implementing our pure functions. There are many commands in UNIX and LINUX platforms that we are using for day-to-day tasks. For example, we use cat to print the contents of the file, grep to search the files, wc to count the lines, and so on. These commands do solve one problem at a time, but we can compose or pipeline to do the complex tasks. Imagine we want to find a specific name in a text file and count its occurrences. How will we be doing that in our command prompt? The command looks like this:cat jsBook | grep –i "composing" | wc
这个命令通过组合许多函数解决了我们的问题。编写不仅仅是 UNIX/LINUX 命令行独有的;它是函数式编程范式的核心。在我们的世界里,我们称之为功能组合。假设这些相同的命令行已经在 JavaScript 函数中实现。我们可以用同样的原则来解决我们的问题。
现在换一种方式思考另一个问题。你想计算文本中的行数。你会怎么解决?啊哈!你得到了答案。根据我们的定义,命令实际上是一个纯粹的函数。它接受一个参数并将输出返回给调用者,而不影响任何外部环境。
遵循一个简单的定义,我们会得到很多好处。在我们结束本章之前,我们想说明一个纯函数和一个数学函数之间的关系。我们接下来处理这个问题。
纯函数是一个数学函数
Earlier we saw this code snippet in Listing 1-12:var longRunningFunction = (ip) => { //do long running tasks and return } var longRunningFnBookKeeper = { 2 : 3, 4 : 5 } //check if the key present in longRunningFnBookKeeper //if get back the result else update the bookkeeping object longRunningFnBookKeeper.hasOwnProperty(ip) ? longRunningFnBookKeeper[ip] : longRunningFnBookKeeper[ip] = longRunningFunction(ip) The primary aim was to cache the function calls. We did so using the bookkeeping object. Imagine we have called the longRunningFunction many times so that our longRunningFnBookKeeper grows into the object, which looks like this:longRunningFnBookKeeper = { 1 : 32, 2 : 4, 3 : 5, 5 : 6, 8 : 9, 9 : 10, 10 : 23, 11 : 44 }
例如,现在假设 longRunningFunction 的输入范围只有 1 到 11 个整数。因为我们已经为这个特定的范围构建了簿记对象,所以我们可以只引用 longRunningFnBookKeeper 来说出给定输入的输出 longRunningFunction。
我们来分析一下这个记账对象。这个对象让我们清楚地看到,我们的函数 longRunningFunction 接受一个输入,将映射到给定范围的输出上(在本例中是 1–11)。这里需要注意的重要一点是,输入(在本例中是键)在对象中必须有相应的输出(在本例中是结果)。此外,key 部分中没有映射到两个输出的输入。
With this analysis we can revisit the mathematical function definition, this time providing a more concrete definition from Wikipedia ( en.wikipedia.org/wiki/Function_(mathematics)
:
在数学中,函数是一组输入和一组允许的输出之间的关系,其性质是每个输入与一个输出正好相关。函数的输入称为自变量,输出称为值。给定函数的所有允许输入的集合称为该函数的定义域,而允许输出的集合称为共定义域。
这个定义和我们的纯函数完全一样。看看我们的 longRunningFnBookKeeper 对象。你能找到我们函数的域和共域吗?通过这个非常简单的例子,你可以很容易地看到数学函数思想是如何在函数范式世界中被借用的(如本章开头所述)。
我们将要建造的东西
在本章中,我们已经谈了很多关于函数和函数式编程的内容。有了这些基础知识,我们将构建名为 ES8-Functional 的函数库。这个库将在全文中一章一章地建立。通过构建函数库,您将探索如何使用 JavaScript 函数(以函数方式)以及如何在日常活动中应用函数式编程(使用我们创建的函数来解决代码库中的问题)。
JavaScript 是函数式编程语言吗?
Before we close this chapter, we have to take a step back and answer a fundamental question: Is JavaScript a functional programming language? The answer is yes and no. We said in the beginning of the chapter that functional programming is all about functions, which have to take at least an argument and return a value. To be frank, though, we can create a function in JavaScript that can take no argument and in fact return nothing. For example, the following code is a valid code in the JavaScript engine:var useless = () => {}
这段代码将在 JavaScript 世界中正确执行。原因是 JavaScript 不是一种纯粹的函数式语言(像 Haskell ),而是一种多参数语言。然而,这种语言非常适合本章讨论的函数式编程范例。到目前为止,我们已经讨论过的技术和好处可以在纯 JavaScript 中应用。这就是这本书的标题的原因。
JavaScript 是一种支持函数作为参数、将函数传递给其他函数等等的语言,这主要是因为 JavaScript 将函数视为其一等公民(我们将在接下来的章节中更多地讨论这一点)。由于术语函数定义的约束,我们作为开发人员在 JavaScript 世界中创建它们时需要考虑它们。通过这样做,我们将从本章讨论的函数式范例中获得许多好处。
摘要
在这一章中,我们已经看到了数学和编程世界中的函数。我们从数学中函数的简单定义开始,回顾了函数的小而实在的例子以及 JavaScript 中的函数式编程范例。我们还定义了什么是纯函数,并详细讨论了它们的好处。在本章的最后我们还展示了纯函数和数学函数之间的关系。我们还讨论了如何将 JavaScript 视为函数式编程语言。这一章取得了很多进展。
在下一章中,我们将学习在 ES8 环境中创建和执行函数。现在有了 ES8,我们有几种方法来创建函数;这正是我们将在下一章读到的内容。
二、JavaScript 函数的基础
在前一章中,我们看到了函数式编程是怎么一回事。我们看到了软件世界中的函数只不过是数学函数。我们花了很多时间讨论纯函数如何给我们带来巨大的优势,比如并行代码执行、可缓存等等。我们现在确信函数式编程完全是关于函数的。
在这一章中,我们将会看到 JavaScript 中的函数是如何使用的。我们将会看到最新的 JavaScript 版本 ES7/8。本章回顾了如何创建函数、调用函数以及传递 ES6 和更高版本中定义的参数。然而,这不是本书的目标。我们强烈建议您尝试书中的所有代码片段,以获得如何使用函数的要点(更准确地说,我们将研究箭头函数)。
一旦我们对如何使用函数有了坚实的理解,我们将把注意力转向如何在我们的系统中运行 ES8 代码。截至目前,浏览器并不支持 ES8 的所有功能。为了解决这个问题,我们将使用一个叫做 Babel 的工具。在本章的最后,我们将开始创建一个函数库的基础工作。为此,我们将使用一个节点项目,该项目将使用 Babel-Node 工具进行设置,以便在您的系统中运行我们的代码。
注意
章节示例和库源代码在第二章分支中。回购的网址是github.com/antsmartian/functional-es8.git
一旦你检查出代码,请检查分支第二章:
...
git checkout -b 第二章来源/第二章
...
对于运行代码,与之前一样,运行:
...
npm 跑步游乐场
...
ECMAScript:一点历史
ECMAScript is a specification of JavaScript, which is maintained by ECMA International in ECMA-262 and ISO/IEC 16262. Here are the versions of ECMAScript:
-
ECMAScript 1 是 JavaScript 语言的第一个版本,发布于 1997 年。
-
ECMAScript 2 是 JavaScript 语言的第二个版本,相对于前一个版本,它包含了非常小的变化。这个版本发布于 1998 年。
-
ECMAScript 3 引入了几个特性,并于 1999 年发布。
-
ECMAScript 5 现在几乎被所有的浏览器所支持。这是将严格模式引入语言的版本。它于 2009 年发布。ECMAScript 5.1 于 2011 年 6 月发布,并做了一些小的修改。
-
ECMAScript 6 引入了许多变化,包括类、符号、箭头函数、生成器等等。
-
ECMAScript 7 和 8 有一些新概念,如 async await、SharedArrayBuffer、尾随逗号、Object.entries 等等。
在本书中,我们将 ECMAScript 称为 ES7,因此这些术语可以互换。
创建和执行函数
在这一节中,我们将看到如何在 JavaScript 中以多种方式创建和执行函数。这一部分将会很长,也很有趣。因为许多浏览器还不支持 ES6 或更高版本,所以我们想找到一种方法来流畅地运行我们的代码。认识一下 Babel ,一个 transpiler ,它可以将最新的代码转换成有效的 ES5 代码(注意在我们的历史部分,我们提到了 ES5 代码可以在今天所有的浏览器中运行)。将代码转换成 ES5 让开发人员可以毫无问题地查看和使用最新版本 ECMAScript 的特性。使用 Babel,我们可以运行本书中介绍的所有代码示例。
在你安装了 Babel 之后,我们可以通过看到我们的第一个简单函数来弄脏我们的手。
第一功能
We define our first simple function here. The simplest function one can write in ES6 or higher versions is given in Listing 2-1.() => "Simple Function" Listing 2-1
简单的功能
If you try to run this function in babel-repl, you will see this result:[Function]
注意
没有必要在巴别塔世界中运行代码示例。如果您使用的是最新的浏览器,并且您确定它支持最新版本的 ECMAScript,那么您可以使用浏览器控制台来运行代码片段。毕竟这是一个选择的问题。如果您正在运行代码,比如说在 Chrome 中,清单 2-1 应该会给出这样的结果:
function () = > "简单函数"
这里要注意的一点是,根据您运行代码片段的位置,显示函数表示的结果可能会有所不同。
That’s it: We have a function. Take a moment to analyze this function. Let’s split them:() => "Simple Function" //where () represents function arguments //=> starts the function body/definition //content after => are the function body/definition.
我们可以跳过 function 关键字来定义函数。您可以看到,我们使用了= >操作符来定义函数体。这样创建的函数被称为箭头函数。我们在整本书中都使用了箭头函数。
现在函数已经定义好了,我们可以执行它来查看结果。哦,等等,我们创建的函数没有名字。我们怎么称呼它?
注意
没有名字的函数叫做匿名函数。当在第三章中看到高阶函数时,我们将理解匿名函数在函数式编程范例中的用法。
Let’s assign a name for it as shown in Listing 2-2.var simpleFn = () => "Simple Function" Listing 2-2
一个有名字的简单函数
Because we now have access to the function simpleFn we can use this reference to execute the function:simpleFn() //returns "Simple Function" in the console Now we have created a function and also executed it. We can see how the same function looks alike in ES5. We can use babel to convert our code into ES5, using the following command:babel simpleFn.js --presets babel-preset-es2015 --out-file script-compiled.js This will generate the file called script-compiled.js in your current directory. Now open the generated file in your favorite editor:"use strict"; var simpleFn = function simpleFn() { return "Simple Function"; };
这是我们在 ES5 中的等价代码。您可以感觉到在最新版本中编写函数变得更加容易和简洁。在转换后的代码片段中有两点需要注意。我们一个接一个地讨论它们。
严格模式
在这一节中,我们将讨论 JavaScript 中的严格模式。我们将看到它的好处以及为什么应该选择严格模式。
You can see that the converted code runs in strict mode, as shown here: "use strict"; var simpleFn = function simpleFn() { return "Simple Function"; };
严格模式与最新版本无关,但在此讨论是合适的。正如我们已经讨论过的,ES5 将严格模式引入了 JavaScript 语言。
简单地说,严格模式是 JavaScript 的受限变体。在严格模式下运行的相同 JavaScript 代码在语义上可能与不使用严格模式的代码不同。所有在 JavaScript 文件中不使用 strict 的代码片段都将处于非 strict 模式。
Why should we use strict mode? What are the advantages? There are many advantages of using strict mode style in the world of JavaScript. One simple advantage occurs if you are defining a variable in global state (i.e., without specifying var command) like this: "use strict"; globalVar = "evil"
在严格模式下,这将是一个错误!这对我们的开发人员来说是个好消息,因为在 JavaScript 中全局变量是非常邪恶的。然而,如果相同的代码在非严格模式下运行,那么它就不会报错。
现在您可以猜到,无论您是在严格模式还是非严格模式下运行,JavaScript 中的相同代码都会产生不同的结果。因为严格模式对我们很有帮助,我们将让 Babel 在传输我们的 ES8 代码时使用严格模式。
注意
我们可以将 use stricts 放在 JavaScript 文件的开头,在这种情况下,它将对特定文件中定义的所有函数进行检查。否则,只能对特定函数使用严格模式。在这种情况下,严格模式将仅应用于该特定函数,而其他函数行为则处于非严格模式。有关这方面的更多信息,请参见developer . Mozilla . org/en-US/docs/Web/JavaScript/Reference/Strict _ mode
。
Return 语句是可选的
In the ES5 converted code snippet, we saw that Babel adds the return statement in our simpleFn."use strict"; var simpleFn = function simpleFn() { return "Simple Function"; }; In our real code, though, we didn’t specify any return statement :var simpleFn = () => "Simple Function"
因此,在这里,如果你有一个只有一个语句的函数,那么它隐含地意味着它返回值。多语句函数呢?我们将如何创造它们?
多语句函数
Now we are going to see how to write multiple statement functions . Let’s make our simpleFn a bit more complicated, as shown in Listing 2-3.var simpleFn = () => { let value = "Simple Function" return value; } //for multiple statement wrap with { } Listing 2-3
多语句函数
运行这个函数,你会得到和以前一样的结果。不过,在这里,我们使用了多个参数来实现相同的行为。除此之外,请注意我们使用了 let 关键字来定义值变量。let 关键字是 JavaScript 关键字家族中的新成员。它允许您声明限制在特定块范围内的变量,这与 var 关键字不同,var 关键字将变量全局定义到一个函数中,而不管它是在哪个块中定义的。
To make the point concrete, we can write the same function with var and the let keyword, inside an if block as shown in Listing 2-4.var simpleFn = () => { //function scope if(true) { let a = 1; var b = 2; console.log(a) console.log(b) } //if block scope console.log(b) //function scope console.log(a) //function scope } Listing 2-4
带有 var 和 let 关键字的 SimpleFn
Running this function gives the following output:1 2 2 Uncaught ReferenceError: a is not defined(...)
从输出中可以看出,通过 let 关键字声明的变量只能在 if 块内访问,而不能在块外访问。当我们访问块外的变量时,JavaScript 抛出错误,而用 var 声明的变量不会那样做。相反,它声明了整个函数的变量范围。这就是变量 b 可以在 if 块之外被访问的原因。
因为块范围是非常需要的,所以我们将在整本书中使用 let 关键字来定义变量。现在让我们看看如何创建一个带有参数的函数作为最后一节。
函数参数
Creating functions with arguments is the same as in ES5. Look at a quick example as follows (Listing 2-5).let identity = (value) => value Listing 2-5
带自变量的函数
这里我们创建一个名为 identity 的函数,它以 value 作为参数并返回相同的值。如你所见,创建带参数的函数和在 ES5 中是一样的;只有创建函数的语法发生了变化。
ES5 函数在 ES6 和更高版本中有效
在我们结束这一部分之前,我们需要把重要的一点说清楚。ES5 中编写的函数在最新版本中仍然有效。新版本引入了箭头函数只是一件小事,但这并不能取代旧的函数语法或其他任何东西。然而,我们将在本书中使用箭头函数来展示函数式编程方法。
设置我们的项目
现在我们已经了解了如何创建箭头函数,我们将把重点转移到本节的项目设置。我们将把我们的项目设置为一个节点项目,在本节的最后,我们将编写我们的第一个函数。
初始设置
In this section, we follow a simple step-by-step guide to set up our environment. The steps are as follows.
-
第一步是创建一个存放源代码的目录。创建一个目录,并随意命名。
-
进入该特定目录,从终端运行以下命令:
npm init
-
运行步骤 2 后,将会询问您一组问题;你可以提供你想要的价值。一旦完成,它将在您的当前目录中创建一个名为 package.json 的文件。
The project package.json that we have created looks like Listing 2-6.{ "name": "learning-functional", "version": "1.0.0", "description": "Functional lib and examples in ES8", "main": "index.js", "scripts": { "test": "echo "Error: no test specified" && exit 1" }, "author": "Anto Aravinth @antoaravinth", "license": "ISC" } Listing 2-6
Package.json 内容
Now we need to add a few libraries, which will allow us to write ES8 code and execute them. Run the following command in the current directory:npm install --save-dev babel-preset-es2017-node7
注意
本书使用巴别塔版本“巴别塔-预设-es 2017-节点 7。”当你读到这篇文章的时候,这个版本可能已经过时了。你可以自由安装最新的版本,一切都应该很顺利。然而,在本书的上下文中,我们将使用指定的版本。
该命令下载名为 ES2017 的 babel 包;这个包的主要目的是允许最新的 ECMAScript 代码在 Node Js 平台上运行。原因是 Node Js,在写这本书的时候,还没有完全兼容最新的特性。
一旦运行这个命令,您将能够看到在目录中创建了一个名为 node_modules 的文件夹,其中包含 babel-preset-es2017 文件夹。
Because we have used --save-dev while installing, npm does add the corresponding babel dependencies to our package.json. Now if you open your package.json, it looks like Listing 2-7.{ "name": "learning-functional", "version": "1.0.0", "description": "Functional lib and examples", "main": "index.js", "scripts": { "test": "echo "Error: no test specified" && exit 1" }, "author": "Anto Aravinth @antoaravinth>", "license": "ISC", "devDependencies": { "babel-preset-es2017-node7": "⁰.5.2", "babel-cli": "⁶.23.0" } } Listing 2-7
添加 devDependencies 后
Now that this is in place, we can go ahead and create two directories called lib and functional-playground. So now your directory looks like this:learning-functional - functional-playground - lib - node_modules - babel-preset-es2017-node7/* - package.json
现在我们将把所有的函数库代码放入 lib,并使用 functional-playground 来探索和理解我们的函数技术。
我们解决循环问题的第一个函数方法
Imagine we have to iterate through the array and print the data to the console. How do we achieve this in JavaScript?var array = [1,2,3] for(i=0;i<array.length;i++) console.log(array[i]) Listing 2-8
循环数组
正如我们在第一章已经讨论过的,将操作抽象成函数是函数式编程的支柱之一。让我们将这个操作抽象成函数,这样我们就可以在任何需要的时候重用它,而不是重复告诉它如何迭代循环。
Create a file called es8-functional.js in the lib directory . Our directory structure looks like this:learning-functional - functional-playground - lib - es8-functional.js - node_modules - babel-preset-es2017-node7/* - package.json Now with that file in place, go ahead and place the content of Listing 2-9 into that file.const forEach = (array,fn) => { let i; for(i=0;i<array.length;i++) fn(array[i]) } Listing 2-9
forEach 函数
注意
现在不要担心这个函数是如何工作的。我们将在下一章看到高阶函数如何在 JavaScript 中工作,并提供大量的例子。
You might notice that we have started with a keyword const for our function definition. This keyword is part of the latest version, which makes the declaration constant. For example, if someone tries to reassign the variable with the same name like this:forEach = "" //making your function as string! The preceding code will throw an error like this:TypeError: Assignment to constant variable. This will prevent it from being accidentally reassigned. Now we’ll go and use the created function to print all the data of the array to the console. To do that, create a file called play.js function in the functional-playground directory. So now the current file looks like this:learning-functional - functional-playground - play.js - lib - es8-functional.js - node_modules - babel-preset-es2017-node7/* - package.json
我们将在 play.js 文件中调用 forEach。我们如何调用这个函数,它驻留在一个不同的文件中?
出口要点
ES6 also introduced the concept called modules. ES6 modules are stored in files. In our case we can think of the es8-functional.js file itself as a module. Along with the concept of modules came import and export statements. In our running example, we have to export the forEach function so that others can use it. We can add the code shown in Listing 2-10 to our es8-functional.js file.const forEach = (array,fn) => { let i; for(i=0;i<array.length;i++) fn(array[i]) } export default forEach Listing 2-10
导出 forEach 函数
进口要点
Now that we have exported our function as you can see in Listing 2-10, let’s go and consume it via import. Open the file play.js and add the code shown in Listing 2-11.import forEach from '../lib/es8-functional.js' Listing 2-11
导入 forEach 函数
This line tells JavaScript to import the function called forEach from es8-functional.js. Now the function is available to the whole file with the name forEach. Now add the code into play.js as shown in Listing 2-12.import forEach from '../lib/es8-functional.js' var array = [1,2,3] forEach(array,(data) => console.log(data)) //refereing to imported forEach Listing 2-12
使用导入的 forEach 函数
使用巴别塔节点运行代码
让我们运行 play.js 文件。因为我们在文件中使用的是最新版本,所以我们必须使用 Babel-Node 来运行我们的代码。Babel-Node 用于传输我们的代码并在节点 js 上运行。Babel-Node 应该与 babel-cli 一起安装。
So, from our project root directory, we can call the babel-node like this:babel-node functional-playground/play.js --presets es2017 This command tells us that our play.js file should be transpiled with es2017 and run into node js. This should give the output as follows:1 2 3 Hurray! Now we have abstracted out for logic into a function. Imagine you want to iterate and print the array contents with multiples of 2. How will we do it? Simply reuse our forEach, which will print the output as expected:forEach(array,(data) => console.log(2 * data))
注意
我们将在整本书中使用这种模式。我们用命令式方法讨论问题,然后继续实施我们的函数技术,并在 es8-functional.js 的函数中捕获它们。然后我们用它在 play.js 文件中播放!
在 Npm 中创建脚本
We have seen how to run our play.js file, but it’s a lot to type . Each time we need to run the following:babel-node functional-playground/play.js --presets es2015-node5 Rather than entering this, we can bind the command shown in Listing 2-13 to our npm script. We will change the package.json accordingly:{ "name": "learning-functional", "version": "1.0.0", "description": "Functional lib and examples", "main": "index.js", "scripts": { "playground" : "babel-node functional-playground/play.js --presets es2017-node7" }, "author": "Anto Aravinth @antoaravinth", "license": "ISC", "devDependencies": { "babel-preset-es2017-node7": "⁰.5.2" } } Listing 2-13
向 package.json 添加 npm 脚本
Now we have added the babel-node command to scripts , so we can run our playground file (node functional-playground/play.js) as follows:npm run playground
这将和以前一样运行。
从 Git 运行源代码
Whatever we are discussing in the chapter will go into a git repository ( github.com/antoaravinth/functional-es8
). You can clone them into your system using git like this:git clone https://github.com/antsmartian/functional-es8.git Once you clone the repo, you can move into a specific chapter source code branch. Each chapter has its own branch in the repo. For example, to see the code samples used in Chapter 2, you need to enter this:git checkout -b chap02 origin/chap02
一旦您签出了分支,您就可以像以前一样运行操场文件。
摘要
在这一章中,我们花了很多时间学习如何使用函数。我们利用 Babel 工具在我们的节点平台上无缝运行我们的代码。我们还将项目创建为节点项目。在我们的节点项目中,我们看到了如何使用 Babel-node 来转换代码,并使用预置在节点环境中运行它们。我们还看到了如何下载该书的源代码并运行它。有了所有这些技术,在下一章,我们将集中讨论高阶函数的含义。我们将在后面的章节中解释 ES7 的异步/等待特性。
三、高阶函数
在前一章中,我们看到了如何在 ES8 中创建简单的函数。我们还设置了我们的环境,使用节点生态系统来运行功能程序。事实上,我们在前一章中创建了我们的第一个函数式程序应用编程接口(API ),称为 forEach。我们在第二章开发的 forEach 函数有一些特殊之处。我们将函数本身作为参数传递给了 forEach 函数。这里不涉及任何技巧;JavaScript 规范的一部分是函数可以作为参数传递。作为一种语言,JavaScript 将函数视为数据。这是一个非常强大的概念,它允许我们传递函数来代替数据。以另一个函数为自变量的函数称为高阶函数。
我们将在本章深入探讨高阶函数(简称 HOC)。我们从一个简单的例子和 HOC 的定义开始这一章。稍后,我们将提供更多真实世界的例子,说明 HOC 如何帮助程序员轻松解决复杂的问题。像以前一样,我们将在本章中创建的特殊函数添加到我们的库中。我们开始吧!
我们将创建一些高阶函数,并将它们添加到我们的库中。我们这样做是为了展示事情是如何在幕后运作的。该库有利于学习当前的资源,但是它们还没有为该库做好生产准备,所以请记住这一点。
注意
章节示例和库源代码在第三章分支中。回购的网址是:github.com/antsmartian/functional-es8.git
一旦你检查出代码,请检查分支第三章:
...
git checkout -b 第三章来源/第三章
...
对于运行代码,与之前一样,运行:
...
npm 跑步游乐场
...
理解数据
作为程序员,我们知道我们的程序作用于数据。数据对于我们编写的要执行的程序来说是非常重要的。因此,几乎所有的编程语言都给程序员提供了一些数据。例如,我们可以在字符串数据类型中存储一个人的姓名。JavaScript 提供了几种数据类型,我们将在下一小节中介绍。在这一节的最后,我们用简单和简明的例子介绍高阶函数的一个坚实的定义。
理解 JavaScript 数据类型
每种编程语言都有数据类型。这些数据类型可以保存数据,并允许我们的程序对其进行操作。在这个简短的部分中,我们将介绍 JavaScript 数据类型。
In a nutshell, JavaScript as a language supports the following data types:
-
民数记
-
用线串
-
布尔运算
-
目标
-
空
-
不明确的
重要的是,在 JavaScript 语言中,我们还有作为数据类型的 friend 函数。因为函数是像字符串一样的数据类型,所以我们可以传递它们,将它们存储在变量中,等等。当语言允许函数作为任何其他数据类型使用时,函数就是一等公民;也就是说,函数可以被赋给变量,作为参数传递,并从其他函数返回,就像我们对字符串和数字数据所做的那样。在下一节中,我们将提供一个简单的例子来说明存储和传递函数的含义。
存储功能
As previously mentioned, functions are nothing but data. Because they are data, we can hold them in a variable! The code in Listing 3-1 is valid code in a JavaScript context.let fn = () => {} Listing 3-1
将函数存储在变量中
In this code snippet, fn is nothing but a variable that is pointing to a data type function. We can quickly check that fn is of type function by running the following code:typeof fn => "function" Because fn is just a reference to our function, we can call it like this:fn()
这将执行 fn 指向的函数。
传递函数
As day-to-day JavaScript programmers, we know how to pass data to a function. Consider the following function (Listing 3-2), which takes an argument and logs to console the type of the argument:let tellType = (arg) => { console.log(typeof arg) } Listing 3-2
电传打字功能
One can pass the argument to the tellType function to see it in action:let data = 1 tellType(data) => number There is nothing fancy here. As seen in the previous section, we can store even functions in our variable (as functions in JavaScript are data). So how about passing a variable that has reference to a function? Let’s quickly check it:let dataFn = () => { console.log("I'm a function") } tellType(dataFn) => function That’s great! Now we will make our tellType execute the passed argument as shown in Listing 3-3 if it is of type function:var tellType = (arg) => { if(typeof arg === "function") arg() else console.log("The passed data is " + arg) } Listing 3-3
如果是函数,tellType 将执行 arg
这里我们检查传递的 arg 是否属于 function 类型;如果有,那就打电话。记住,如果一个变量是 function 类型的,这意味着它有一个可以执行的函数的引用。这就是我们调用 arg()的原因,如果它在清单 3-3 的代码中输入 if 语句。
Let’s execute our tellType function by passing our dataFn variable to it:tellType(dataFn) => I'm a function
我们已经成功地将一个函数 dataFn 传递给另一个函数 tellType,后者已经执行了传递的函数。就这么简单。
返回一个函数
我们已经看到了如何将一个函数传递给另一个函数。因为函数在 JavaScript 中是简单的数据,所以我们也可以从其他函数中返回它们(像其他数据类型一样)。
We’ll take a simple example of a function that returns another function as shown in Listing 3-4.let crazy = () => { return String } Listing 3-4
疯狂函数返回字符串
注意
JavaScript 有一个名为 String 的内置函数。我们可以使用这个函数在 JavaScript 中创建新的字符串值,如下所示:
字符串(“特设”)
= >特设
Note that our crazy function returns a function reference that is pointing to String function. Let’s call our crazy function:crazy() => String() { [native code] } As you can see, calling the crazy function returns a String function. Note that it just returns the function reference and does not execute the function. We can hold back the returned function reference and call them like this:let fn = crazy() fn("HOC") => HOC or even better like this:crazy()("HOC") => HOC
注意
我们在所有返回另一个函数的函数上使用简单的文档。这将非常有帮助,因为它使阅读源代码变得容易。例如,疯狂函数将被记录为这样:
//Fn = >字符串
let crazy = () => { return String }
Fn => String 注释帮助读者理解这个疯狂的函数,它执行并返回另一个指向 String 的函数。
我们在本书中使用了这些可读的注释。
在这些部分中,我们看到了以其他函数作为参数的函数,也看到了不返回其他函数的函数示例。现在是时候给你一个高阶函数的定义了:一个函数接收函数作为它的参数,返回它作为输出,或者两者都有。
抽象和高阶函数
我们已经看到了如何创建和执行高阶函数。一般来说,高阶函数通常是为了抽象常见问题而编写的。换句话说,高阶函数无非是定义抽象。
在本节中,我们将讨论高阶函数与术语抽象之间的关系。
抽象定义
Wikipedia helps us by providing this definition of abstraction:
- 在软件工程和计算机科学中,抽象是一种管理计算机系统复杂性的技术。它的工作原理是建立一个人与系统交互的复杂级别,将更复杂的细节抑制在当前级别之下。程序员使用一个理想化的界面(通常是定义良好的),并且可以添加额外的功能级别,否则这些功能会太复杂而难以处理。
It also includes the following text, which is what we are interested in:
- 例如,编写涉及数字运算的代码的程序员可能对数字在底层硬件中的表示方式不感兴趣(例如,它们是 16 位还是 32 位整数),在那些细节被隐藏的地方,可以说它们被抽象掉了,只留下程序员可以使用的数字。
这段文字清楚地给出了抽象的概念。抽象允许我们致力于期望的目标,而不用担心底层的系统概念。
经由高阶函数的抽象
In this section we will see how higher order functions help us to achieve the abstraction concept we discussed in the previous section. Here is the code snippet of our forEach function defined in Chapter 2 (Listing 2-9):const forEach = (array,fn) => { for(let i=0;array.length;i++) fn(array[i]) }
前面的 forEach 函数抽象出了遍历数组的问题。forEach API 的用户不需要理解 forEach 是如何实现遍历部分的,这样就抽象出了这个问题。
注意
在 forEach 函数中,调用传递的函数 fn 时使用一个参数作为数组的当前迭代内容,如下所示:
。。。
联合国(阵列[和]
。。。
所以当 forEach 函数的用户这样调用它时:
forEach([1,2,3],(data) => {
//数据从 forEach 函数传递
//作为参数传递给当前函数
})
forEach essentially traverses the array. What about traversing a JavaScript object? Traversing a JavaScript object has steps like this:
-
迭代给定对象的所有键。
-
确定该键属于它自己的对象。
-
如果步骤 2 为真,则获取该项的值。
Let’s abstract these steps into a higher order function named forEachObject , as shown in Listing 3-5.const forEachObject = (obj,fn) => { for (var property in obj) { if (obj.hasOwnProperty(property)) { //calls the fn with key and value as its argument fn(property, obj[property]) } } } Listing 3-5
forEachObject 函数定义
注意
forEachObject 将第一个参数作为 JavaScript 对象(作为 obj ),第二个参数是函数 fn。它使用 precedng 算法遍历对象,并分别以 key 和值作为参数调用 fn。
Here they are in action:let object = {a:1,b:2} forEachObject(object, (k,v) => console.log(k + ":" + v)) => a:1 => b:1
酷!需要注意的重要一点是,forEach 和 forEachObject 函数都是高阶函数,它允许开发人员处理任务(通过传递相应的函数),抽象掉遍历部分!因为这些遍历函数被抽象掉了,所以我们可以彻底测试它们,从而得到一个简洁的代码库。让我们实现一种抽象的方法来处理控制流。
For that, let us create a function called unless. Unless is a simple function that takes a predicate (which should be either true or false); if the predicate is false, call the fn as shown in Listing 3-6.const unless = (predicate,fn) => { if(!predicate) fn() } Listing 3-6
除非函数定义
With the unless function in place, we can write a concise piece of code to find the list of even numbers. The code for it looks like this:forEach([1,2,3,4,5,6,7],(number) => { unless((number % 2), () => { console.log(number, " is even") }) }) This code, when executed, is going to print the following:2 ' is even' 4 ' is even' 6 ' is even' In this case we are getting the even numbers from the array list. What if we want to get the list of even numbers from, say, 0 to 100? We cannot use forEach here (of course we can, if we have the array that has [0,1,2.....,100] content). Let’s meet another higher order function called times . Times is yet another simple higher order function that takes the number and calls the passed function as many times as the caller indicates. The times function is shown in Listing 3-7.const times = (times, fn) => { for (var i = 0; i < times; i++) fn(i); } Listing 3-7
时间函数定义
The times function looks very similar to the forEach function; it’s just that we are operating on a Number rather than an Array. Now with the times function in place, we can go ahead and solve our problem at hand like this:times(100, function(n) { unless(n % 2, function() { console.log(n, "is even"); }); }); That’s going to print our expected answer:0 'is even' 2 'is even' 4 'is even' 6 'is even' 8 'is even' 10 'is even' . . . . . . 94 'is even' 96 'is even' 98 'is even'
有了这段代码,我们就抽象出了循环,条件检查变成了一个简单明了的高阶函数!
已经看了几个高阶函数的例子,是时候更进一步了。在下一节中,我们将讨论现实世界中的高阶函数以及如何创建它们。
注意
我们在本章中创建的所有高阶函数都将出现在第三章分支中。
现实世界中的高阶函数
在这一节中,我们将介绍高阶函数的实际例子。我们将从简单的高阶函数开始,慢慢过渡到更复杂的高阶函数,JavaScript 开发人员在日常生活中会用到这些函数。激动吗?那你还在等什么?继续读。
注意
在我们介绍了闭包的概念后,这些例子将在下一章继续。大多数高阶函数在闭包的帮助下工作。
每个功能
Often JavaScript developers need to check if the array of content is a number, custom object, or anything else. We usually use a typical for loop approach to solve these problems, but let’s abstract these away into a function called every. The every function takes two arguments: an array and a function. It checks if all the elements of the array are evaluated to true by the passed function. The implementation looks like Listing 3-8:const every = (arr,fn) => { let result = true; for(let i=0;i<arr.length;i++) result = result && fn(arr[i]) return result } Listing 3-8
每个函数定义
在这里,我们简单地迭代传递的数组,并通过在迭代中传递数组元素的当前内容来调用 fn。注意,传递的 fn 应该返回一个布尔值。然后,我们使用&&来确保数组的所有内容都符合 fn 给出的标准。
We need to quickly check that our every function works fine. Then pass on the array of NaN and pass fn as isNaN, which does check if the given number is NaN or not:every([NaN, NaN, NaN], isNaN) => true every([NaN, NaN, 4], isNaN) => false Great. The every is a typical higher order function that is easy to implement and it’s very useful too! Before we go further, we need to make ourselves comfortable with the for..of loop. For..of loops can be used to iterate the array elements. Let’s rewrite our every function with a for loop (Listing 3-9).const every = (arr,fn) => { let result = true; for(const value of arr) result = result && fn(value) return result } Listing 3-9
每个带 for 的函数..循环的
森林..of 循环只是对我们旧的 for 循环的抽象。正如您在这里看到的,for..of 通过隐藏索引变量消除了数组的遍历,等等。我们已经离开了..与每一个。这完全是抽象的。如果 JavaScript 的下一个版本改变了..什么样的?我们只需要在每个函数中改变它。这是抽象最重要的优点之一。
一些功能
Similar to the every function, we also have a function called some . The some works quite the opposite way of the every function such that the some function returns true if either one of the elements in the array returns true for the passed function. The some function is also called as any function . To implement the some function we use || rather than &&, as shown in Listing 3-10.const some = (arr,fn) => { let result = false; for(const value of arr) result = result || fn(value) return result } Listing 3-10
一些函数定义
注意
对于大型数组来说,every 和 some 函数都是低效的实现,因为 every 函数应该遍历数组直到第一个不匹配标准的元素,而 some 函数应该只遍历数组直到第一个匹配。请记住,我们试图理解本章中高阶函数的概念,而不是为了效率和准确性而编写代码。
With the some function in place, we can check its result by passing the arrays like this:some([NaN,NaN, 4], isNaN) =>true some([3,4, 4], isNaN) =>false
已经看到了一些和每一个函数,让我们看看排序函数,以及一个高阶函数是如何发挥重要作用的。
排序功能
The sort is a built-in function that is available in the Array prototype of JavaScript. Suppose we need to sort a list of fruits:var fruit = ['cherries', 'apples', 'bananas']; You can simply call the sort function that is available on the Array prototype:fruit.sort() => ["apples", "bananas", "cherries"] That’s so simple. The sort function is a higher order function that takes up a function as its argument, which will help the sort function to decide the sorting logic. Simply put, the signature of the sort function looks like this:arr.sort([compareFunction])
这里 compareFunction 是可选的。如果未提供 compareFunction,则通过将元素转换为字符串并按 Unicode 码位顺序比较字符串来对元素进行排序。在本节中,您不需要担心 Unicode 转换,因为我们更关注高阶函数。这里需要注意的重要一点是,为了在执行排序时将元素与我们自己的逻辑进行比较,我们需要传递 compareFunction。我们可以感觉到 sort 函数是如何被设计得如此灵活,以至于它可以对 JavaScript 世界中的任何数据进行排序,只要我们传递一个 compareFunction。由于高阶函数的性质,排序函数是灵活的!
Before writing our compareFunction , let’s see what it should really implement. The compareFunction should implement the logic shown in Listing 3-11 as mentioned at developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort
.function compare(a, b) { if (a is less than b by some ordering criterion) { return -1; } if (a is greater than b by the ordering criterion) { return 1; } // a must be equal to b return 0; } Listing 3-11
比较函数的框架
As a simple example, imagine we have a list of people:var people = [ {firstname: "aaFirstName", lastname: "cclastName"}, {firstname: "ccFirstName", lastname: "aalastName"}, {firstname:"bbFirstName", lastname:"bblastName"} ]; Now we need to sort people using the firstname key in the object, then we need to pass on our own compareFunction like this:people.sort((a,b) => { return (a.firstname < b.firstname) ? -1 : (a.firstname > b.firstname) ? 1 : 0 }) which is going to return the following data: [ { firstname: 'aaFirstName', lastname: 'cclastName' }, { firstname: 'bbFirstName', lastname: 'bblastName' }, { firstname: 'ccFirstName', lastname: 'aalastName' } ] Sorting with respect to lastname looks like this:people.sort((a,b) => { return (a.lastname < b.lastname) ? -1 : (a.lastname > b.lastname) ? 1 : 0 }) will return:[ { firstname: 'ccFirstName', lastname: 'aalastName' }, { firstname: 'bbFirstName', lastname: 'bblastName' }, { firstname: 'aaFirstName', lastname: 'cclastName' } ] Hooking again into the logic of compareFunction:function compare(a, b) { if (a is less than b by some ordering criterion) { return -1; } if (a is greater than b by the ordering criterion) { return 1; } // a must be equal to b return 0; }
知道了比较函数的算法,我们能做得更好吗?与其每次都写 compareFunction,我们能不能把这个逻辑抽象成一个函数?正如您在前面的示例中所看到的,我们编写了两个函数,分别用于比较具有几乎相同重复代码的 firstName 和 lastName。让我们用高阶函数来解决这个问题。现在我们要设计的函数不会把函数作为参数,而是返回一个函数。(记住 HOC 也可以返回一个函数。)
Let’s call this function sortBy, which allows the user to sort the array of objects based on the passed property as shown in Listing 3-12.const sortBy = (property) => { return (a,b) => { var result = (a[property] < b[property]) ? -1 : (a[property] > b[property]) ? 1 : 0; return result; } } Listing 3-12
sortBy 函数定义
The sortBy function takes an argument named property and returns a new function that takes two arguments:. . . return (a,b) => { } . . . The returned function has a very simple function body that clearly shows the compareFunction logic:. . . (a[property] < b[property]) ? -1 : (a[property] > b[property]) ? 1 : 0; . . . Imagine we are going to call the function with the property name firstname, and then the function body with the replaced property argument looks like this:(a,b) => return (a['firstname'] < b['firstname']) ? -1 : (a['firstname'] > b['firstname']) ? 1 : 0; That’s exactly what we did by manually writing a function. Here is our sortBy function in action:people.sort(sortBy("firstname")) will return:[ { firstname: 'aaFirstName', lastname: 'cclastName' }, { firstname: 'bbFirstName', lastname: 'bblastName' }, { firstname: 'ccFirstName', lastname: 'aalastName' } ] Sorting with respect to lastname looks like this:people.sort(sortBy("lastname")) returns:[ { firstname: 'ccFirstName', lastname: 'aalastName' }, { firstname: 'bbFirstName', lastname: 'bblastName' }, { firstname: 'aaFirstName', lastname: 'cclastName' } ]
和以前一样。哇,这真是太神奇了!sort 函数接受由 sortBy 函数返回的 compareFunction!有很多高阶函数在浮动!我们再次抽象出了 compareFunction 背后的逻辑,让用户专注于他或她真正需要的东西。毕竟,高阶函数是关于抽象的。
不过,在这里暂停一下,考虑一下 sortBy 函数。请记住,我们的 sortBy 函数接受一个属性并返回另一个函数。返回的函数是作为 compareFunction 传递给排序函数的。这里的问题是为什么返回的函数带有我们传递的属性参数值。
欢迎来到闭包的世界!sortBy 函数工作只是因为 JavaScript 支持闭包。在我们继续写高阶函数之前,我们需要清楚地理解什么是闭包。闭包是下一章的主题。
但是请记住,在下一章解释闭包之后,我们将编写真实世界的高阶函数!
摘要
我们从 JavaScript 支持的简单数据类型开始。我们发现函数在 JavaScript 中也是一种数据类型。因此,我们可以在所有可以保存数据的地方保存函数。换句话说,函数可以像 JavaScript 中的其他数据类型一样被存储、传递和重新分配。JavaScript 的这个极端特性允许将函数传递给另一个函数,我们称之为高阶函数。请记住,高阶函数是以另一个函数作为参数或返回一个函数的函数。我们在本章中看到了一些例子,展示了这些高阶函数概念如何帮助开发人员编写抽象出困难部分的代码!我们已经在自己的库中创建并添加了一些这样的函数。我们通过提到高阶函数与 JavaScript 中另一个叫做闭包的重要概念一起工作来结束这一章,这是第四章的主题。
四、闭包和高阶函数
在前一章中,我们看到了高阶函数如何帮助开发人员对常见问题进行抽象。正如我们所知,这是一个非常强大的概念。我们已经创建了 sortBy 高阶函数来展示用例的有效且相关的示例。尽管 sortBy 函数是在高阶函数的基础上工作的(这也是将函数作为参数传递给其他函数的概念),但它与 JavaScript 中另一个叫做闭包的概念有关。
在我们继续函数式编程技术的旅程之前,我们需要理解 JavaScript 世界中的闭包。这就是这一章的意义所在。在这一章中,我们将详细讨论闭包的含义,同时继续我们编写有用的和真实世界中的高阶函数的旅程。闭包的概念与 JavaScript 中的作用域有关,所以让我们在下一节从闭包开始。
注意
章节示例和库源代码在第四章分支中。回购的网址是github.com/antoaravinth/functional-es8.git
一旦你检查出代码,请检查分支第四章:
...
git checkout -b 第四章来源/第四章
...
对于运行代码,与之前一样,运行:
...
npm 跑步游乐场
...
理解闭包
在这一节中,我们将通过一个简单的例子来解释闭包的含义,然后通过解开它如何与闭包一起工作来继续我们的 sortBy 函数。
什么是闭包?
Simply put, a closure is an inner function. So what is an inner function? It is just a function within another function, something like the following:function outer() { function inner() { } }
是的,这就是终结。这个函数内部被称为闭包 函数 。闭包是强大的,因为它可以访问作用域链(或作用域级别)。我们将在这一节讨论作用域链。
注意
作用域链和作用域级别含义相同,因此它们在本章中可以互换使用。
Technically the closure has access to three scopes:
-
在自己的声明中声明的变量。
-
对全局变量的访问。
-
访问外部函数的变量(有趣)。
Let’s talk about these three points separately with a simple example. Consider the following code snippet:function outer() { function inner() { let a = 5; console.log(a) } inner() //call the inner function. }
当内部函数被调用时,控制台会输出什么?该值将为 5。这主要是由于第一点。一个闭包函数可以访问在它自己的声明中声明的所有变量(见第 1 点)。这里没有火箭科学!
注意
从前面的代码片段中可以看出,内部函数在外部函数之外是不可见的!继续测试它。
Now modify the preceding code snippet to the following:let global = "global" function outer() { function inner() { let a = 5; console.log(global) } inner() //call the inner function. }
现在,当内部函数被执行时,它打印出值 global。因此闭包可以访问全局变量(见第 2 点)。
Points 1 and 2 are now clear with the example. The third point is very interesting, and the claim can be seen in the following code:let global = "global" function outer() { let outer = "outer" function inner() { let a = 5; console.log(outer) } inner() //call the inner function. }
现在,当内部函数执行时,它打印外部的值。这看起来很合理,但是这是闭包的一个非常重要的属性。闭包可以访问外部函数的变量。这里的外部函数是指封闭闭包函数的函数。这个属性使得闭包如此强大!
注意
闭包也可以访问封闭的函数参数。尝试给我们的外部函数添加一个参数,并尝试从内部函数访问它。我们会在这里等你做完这个小练习。
记住它出生的地方
在上一节中,我们看到了什么是闭包。现在我们将看到一个稍微复杂的例子,它解释了闭包中的另一个重要概念:记住上下文的闭包。
Take a look at the following code:var fn = (arg) => { let outer = "Visible" let innerFn = () => { console.log(outer) console.log(arg) } return innerFn }
代码很简单。innerFn 是 Fn 的闭包函数,调用时 fn 返回 innerFn。这里没有什么花哨的东西。
Let’s play around with fn:var closureFn = fn(5); closureFn() will print the following:Visible 5
调用 closureFn 如何打印 Visible 和 5 到控制台?幕后发生了什么?我们来分解一下。
There are two steps happening in this case:
-
当该行被调用时:
var closureFn = fn(5); our fn gets called with argument 5. As per our fn definition, it returns the innerFn.
-
有趣的事情在这里发生。当返回 innerFn 时,JavaScript 执行引擎将 innerFn 视为一个闭包,并相应地设置其作用域。正如我们在上一节中看到的,闭包可以访问三个作用域级别。当 innerFn 返回时,所有这三个作用域级别都被设置(arg,outer 值将在 innerFn 的作用域级别中设置)。返回的函数引用存储在 closureFn 中。因此,当通过作用域链调用时,closureFn 将有 arg,outer 值。
-
当我们最终宣布结束时 Fn:
closureFn() it prints:Visible 5
现在您可以猜到,closureFn 记得它的上下文(作用域;即 outer 和 arg)。因此,对 console.log 的调用会相应地打印出来。
您可能想知道闭包的用例是什么?。我们已经在 sortBy 函数中看到了它的作用。让我们快速重温一下。
重新审视 sortBy 函数
Recall the sortBy function that we defined and used in the previous chapter:const sortBy = (property) => { return (a,b) => { var result = (a[property] < b[property]) ? -1 : (a[property] > b[property]) ? 1 : 0; return result; } }
当我们像这样调用 sortBy 函数时:
sortBy("firstname")sortBy returned a new function that takes two arguments, like this:(a,b) => { /* implementation / } Now we are comfortable with closures and we are aware that the returned function will have access to the sortBy function argument property. Because this function will be returned only when sortBy is called, the property argument is linked with a value; hence the returned function will carry this context throughout its life://scope it carries via closure property = "passedValue" (a,b) => { / implementation */ }
现在,因为返回的函数在其上下文中携带属性值,所以它将在适当的地方和需要的时候使用返回值。有了这样的解释,我们就可以完全理解闭包和高阶函数,它们允许我们编写一个像 sortBy 这样的函数来抽象出内部细节。前进到我们的功能世界。
这一部分要理解的东西太多了;在下一节中,我们将继续我们的旅程,使用闭包和高阶函数编写更抽象的函数。
现实世界中的高阶函数(续)
有了对闭包的理解,我们可以继续实现一些在现实世界中使用的有用的高阶函数。
抽头函数
因为我们将在编程世界中处理大量的函数,所以我们需要一种方法来调试它们之间发生的事情。正如我们在前面的章节中看到的,我们正在设计函数,它接受参数并返回另一个函数,这个函数也接受一些参数,等等。
Let’s design a simple function called tap:const tap = (value) => (fn) => ( typeof(fn) === 'function' && fn(value), console.log(value) )
这里 tap 函数接受一个值并返回一个具有闭包值的函数,它将被执行。
注意
在 JavaScript 中,(exp1,exp2)意味着它将执行两个参数并返回第二个表达式的结果,即 exp2。在前面的示例中,语法将调用函数 fn,并将值打印到控制台。
Let’s play around with the tap function:tap("fun")((it) => console.log("value is ",it)) =>value is fun =>fun
正如你在这个例子中看到的,值被打印出来,然后值被打印出来。这看起来简单明了。****
So where can the tap function be used? Imagine you are iterating an array that has data come from a server. You feel that the data are wrong, so you want to debug and see what the array really contains, while iterating. How will you do that? This is where the tap function comes into the picture. For the current scenario, we can do this:forEach([1,2,3], (a) => tap(a)(() => { console.log(a) } ) )
这按预期打印了值,在我们的工具包中提供了一个简单而强大的函数。
一元函数
数组原型中有一个默认的方法叫做 map。不用担心;我们将在下一章发现数组的许多函数,在那里我们也将看到如何创建我们自己的地图。目前,map 是一个函数,它与我们已经定义的 forEach 函数非常相似。唯一的区别是 map 返回回调函数的结果。
To get the gist of it, let’s say we want to double the array and get back the result; using the map function, we can do that like this:[1, 2, 3].map((a) => { return a * a }) =>[1, 4, 9] The interesting point to note here is that map calls the function with three arguments, which are element, index, and arr. Imagine we want to parse the array of strings to the array of int; we have a built-in function called parseInt that takes two argument parses and radixes and converts the passed parse into a number if possible. If we pass the parseInt to our map function, map will pass the index value to the radix argument of parseInt, which will result in unexpected behavior.['1', '2', '3'].map(parseInt) =>[1, NaN, NaN]
哎呀!在这个结果中可以看到,数组[1,NaN,NaN]并不是我们所期望的。这里我们需要将 parseInt 函数转换成另一个只需要一个参数的函数。我们如何实现这一目标?见见我们的下一个朋友,一元函数。一元函数的任务是获取带有 n 个参数的给定函数,并将其转换为单个参数。
Our unary function looks like the following:const unary = (fn) => fn.length === 1 ? fn : (arg) => fn(arg)
我们正在检查传递的 fn 是否有一个大小为 1 的参数列表(可以通过 length 属性找到);如果是这样,我们什么都不会做。如果没有,我们返回一个新函数,它只接受一个参数 arg,并使用该参数调用函数。
To see our unary function in action, we can rerun our problem with unary:['1', '2', '3'].map(unary(parseInt)) =>[1, 2, 3]
这里我们的一元函数返回一个新函数(parseInt 的克隆),它将只接受一个参数。因此,传递 index,arr 参数的 map 函数不受影响,因为我们得到了预期的结果。
注意
也有像 binary 和其他函数将转换函数以接受相应的参数。
接下来我们将要看到的两个函数是特殊的高阶函数,它允许开发者控制函数被调用的次数。他们在现实世界中有很多用例。
一次函数
在很多情况下,我们只需要运行一次给定的函数。JavaScript 开发人员在日常生活中会遇到这种情况,因为他们只想建立一次第三方库,只需启动一次支付设置,只需进行一次银行支付请求,等等。这些是开发人员面临的常见情况。
In this section we are going to write a higher order function called once, which will allow the developer to run the given function only once. Again the point to note here is that we have to keep on abstracting away our day-to-day activities into our functional toolkits.const once = (fn) => { let done = false; return function () { return done ? undefined : ((done = true), fn.apply(this, arguments)) } }
这个 once 函数接受一个参数 fn,并通过用 apply 方法调用它来返回它的结果(稍后给出 apply 方法的注释)。这里需要注意的重要一点是,我们已经声明了一个名为 done 的变量,并在最初将其设置为 false。返回的函数将有一个闭包作用域;因此,它将访问它来检查 done 是否为真,如果 return undefined else 将 done 设置为真(从而阻止下一次执行),并使用必要的参数调用函数。
注意
apply 函数将允许我们设置函数的上下文,并传递给定函数的参数。你可以在developer . Mozilla . org/en-US/docs/Web/JavaScript/Reference/Global _ Objects/Function/apply
找到更多关于它的信息。
With the once function in place, we can do a quick check of it.var doPayment = once(() => { console.log("Payment is done") }) doPayment() =>Payment is done //oops bad, we are doing second time! doPayment() =>undefined!
这个代码片段展示了包装一次的 doPayment 函数将只执行一次,不管我们调用它们多少次。once 函数是我们工具箱中一个简单但有效的函数。
记忆功能
Before we close this section, let’s take a look at the function called memoize . We know that the pure function is all about working on its argument and nothing else. It does not depend on the outside world for anything. The results of the pure function are purely based on its argument. Imagine that we have a pure function called factorial, which calculates the factorial for a given number. The function looks like this:var factorial = (n) => { if (n === 0) { return 1; } // This is it! Recursion!! return n * factorial(n - 1); } You can quickly check that factorial function with a few inputs :factorial(2) =>2 factorial(3) =>6
这里没什么特别的。但是,我们知道值 2 的阶乘是 2,3 是 6,以此类推,主要是因为我们知道阶乘函数确实有效,但只是基于它的参数,而不是其他。这里出现了一个问题:如果输入已经存在于对象中,为什么我们不能存储每个输入(某种对象)的结果并返回输出呢?此外,为了计算 3 的阶乘,我们需要计算 2 的阶乘,那么为什么我们不能在函数中重用这些计算呢?这正是 memoize 函数要做的。memoize 函数是一种特殊的高阶函数,它允许函数记住或记忆其结果。
Let’s see how we can implement such a function in JavaScript. It is as simple as it looks here:const memoized = (fn) => { const lookupTable = {}; return (arg) => lookupTable[arg] || (lookupTable[arg] = fn(arg)); } Here we have a local variable called lookupTable that will be in the closure context for the returned function. This will take the argument and check if that argument is in the lookupTable:. . lookupTable[arg] . . If so, return the value; otherwise update the object with new input as a key and the result from fn as its value:(lookupTable[arg] = fn(arg)) Perfect. Now we can go and wrap our factorial function into a memoize function to keep remembering its output:let fastFactorial = memoized((n) => { if (n === 0) { return 1; } // This is it! Recursion!! return n * fastFactorial(n - 1); }) Now go and call fastFactorial:fastFactorial(5) =>120 =>lookupTable will be like: Object {0: 1, 1: 1, 2: 2, 3: 6, 4: 24, 5: 120} fastFactorial(3) =>6 //returned from lookupTable fastFactorial(7) => 5040 =>lookupTable will be like: Object {0: 1, 1: 1, 2: 2, 3: 6, 4: 24, 5: 120, 6: 720, 7: 5040}
它将以同样的方式工作,但现在比以前快得多。在运行 fastFactorial 时,我希望您检查 lookupTable 对象,以及它如何帮助加快速度,如前面的代码片段所示。这就是高阶函数的美妙之处:闭包和纯函数在起作用!
注意
我们的记忆函数是为只有一个参数的函数编写的。你能为所有有 n 个参数的函数想出一个解决方案吗?
我们已经将许多常见的问题抽象成高阶函数,这使得我们能够轻松优雅地编写解决方案。
分配功能
JavaScript (JS) objects are mutable, which means the state of the object can be changed after it is created. Often, you will come across a scenario in which you have to merge objects to form a new object. Consider the following objects:var a = { name: "srikanth" }; var b = { age: 30 }; var c = { sex: 'M' }; What if I want to merge all objects to create a new object? Let us go ahead and write the relevant function.function objectAssign(target, source) { var to = {}; for (var i = 0; i < arguments.length; i += 1) { var from = arguments[i]; var keys = Object.keys(from); for (var j = 0; j < keys.length; j += 1) { to[keys[j]] = from[keys[j]]; } } return to; } arguments is a special variable available to every JS function. JS functions allow you to send any number of arguments to a function, which means that if a function is declared with two arguments, JS allows you to send more than two arguments. Object.keys is an inbuilt method that gives you the property names of every object, in our case, the name, age, and sex. The following usage shows how we abstracted the functionality to merge any number of JS objects into one object.var customObjectAssign = objectAssign(a, b, c); //prints { name: 'srikanth', age: 30, sex: 'M' } However, if you’re following ES6 standards, you may not have to write a new function. The following function also does the same.// ES6 Object.Assign var nativeObjectAssign = Object.assign(a, b, c); //prints { name: 'srikanth', age: 30, sex: 'M' } Note that when we use Object.assign to merge objects a, b, and c, even object a is changed. This does not occur with our custom implementation. That is because object a is considered to be the target object we merge into. Because the objects are mutable, a is now updated accordingly. If you require the preceding behavior, you can do this:var nativeObjectAssign = Object.assign({}, a, b, c);
对象 a 在前面的用法中保持不变,因为所有的对象都合并到一个空对象中。
Let me show you another new addition to ES6, Object.entries. Suppose you have an object such as the following:var book = { "id": 111, "title": "C# 6.0", "author": "ANDREW TROELSEN", "rating": [4.7], "reviews": [{good : 4 , excellent : 12}] }; If you’re only interested in the title property, the following function can help you convert that property into an array of strings.console.log(Object.entries(book)[1]); //prints Array ["title", "C# 6.0"]
如果您不想升级到 ES6,但又对获取对象条目感兴趣,该怎么办?唯一的方法是实现一个功能性的方法来做同样的事情,就像我们之前做的那样。你准备好迎接挑战了吗?如果是,我将把它作为一个练习留给你。
我们现在已经将许多常见问题抽象成高阶函数,这使得我们可以轻松地编写一个优雅的解决方案。
摘要
我们从一系列关于函数能看到什么的问题开始了这一章。通过从小处着手并构建示例,我们展示了闭包如何让函数记住它诞生的上下文。有了这样的理解,我们实现了一些 JavaScript 程序员日常生活中使用的高阶函数。我们已经看到了如何将常见问题抽象成一个特定的函数并重用它。现在我们理解了闭包、高阶函数、抽象和纯函数的重要性。在下一章中,我们将继续构建高阶函数,但是是关于数组的。
五、利用数组
欢迎学习关于数组和对象的章节。在这一章中,我们继续探索对数组有用的高阶函数。
在我们的 JavaScript 编程世界中,数组随处可见。我们使用它们来存储数据、操作数据、查找数据以及将数据转换(投影)为另一种格式。在这一章中,我们将看到如何使用我们到目前为止所学的函数式编程技术来改进所有这些活动。
我们在 array 上创建了几个函数,并从功能上而不是强制性地解决了常见的问题。我们在本章中创建的函数可能已经在数组或对象原型中定义了,也可能没有。请注意,这些是为了理解真正的函数本身是如何工作的,而不是覆盖它们。
注意
章节示例和库源代码在第五章分支。回购的网址是github.com/antoaravinth/functional-es8.git
一旦你检查出代码,请检查分支第五章:
...
git checkout -b 第五章来源/第五章
...
对于运行代码,与之前一样,运行:
...
npm 跑步游乐场
...
函数式处理数组
在本节中,我们将创建一组有用的函数,并使用这些函数解决数组的常见问题。
注意
我们在本节中创建的所有函数都被称为投影函数。将一个函数应用于一个数组并创建一个新数组或一组新值被称为投影。当我们看到第一张投影函数图时,这个术语就有意义了。
地图
我们已经看到了如何使用 forEach 迭代数组。forEach 是一个高阶函数,它将遍历给定的数组,并调用传递的函数,将当前索引作为其参数。forEach 隐藏了迭代的常见问题,但是我们不能在所有情况下都使用 forEach。
假设我们想对数组中的所有内容求平方,并在一个新的数组中返回结果。我们如何使用 forEach 实现这一点?使用 forEach 我们无法返回数据;相反,它只是执行传递的函数。这就是我们的第一个投影功能,它被称为地图。
Implementing map is an easy and straightforward task given that we have already seen how to implement forEach itself. The implementation of forEach looks like Listing 5-1.const forEach = (array,fn) => { for(const value of arr) fn(value) } Listing 5-1
forEach 函数定义
The map function implementation looks like Listing 5-2.const map = (array,fn) => { let results = [] for(const value of array) results.push(fn(value)) return results; } Listing 5-2
地图功能定义
The map implementation looks very similar to forEach; it’s just that we are capturing the results in a new array as:. . . let results = [] . . .
并返回函数的结果。现在是谈论术语投影函数的好时机。我们之前提到过地图功能是一个投影功能。为什么我们这样称呼地图函数?原因非常简单明了:因为 map 返回给定函数的经过转换的值,所以我们称之为投影函数。有些人确实称地图为变换函数,但是我们将坚持使用术语投影。
Now let’s solve the problem of squaring the contents of the array using our map function defined in Listing 5-2.map([1,2,3], (x) => x * x) =>[1,4,9] As you can see in this code snippet, we have achieved our task with simple elegance. Because we are going to create many functions that are specific to the Array type, we are going to wrap all the functions into a const called arrayUtils and then export arrayUtils. It typically looks like Listing 5-3.//map function from Listing 5-2 const map = (array,fn) => { let results = [] for(const value of array) results.push(fn(value)) return results; } const arrayUtils = { map : map } export {arrayUtils} //another file import arrayUtils from 'lib' arrayUtils.map //use map //or const map = arrayUtils.map //so that we can call them map Listing 5-3
将函数包装到 arrayUtils 对象中
注意
为了清楚起见,在本文中我们称它们为 map 而不是 arrayUtils.map。
Perfect. To make the chapter examples more realistic, we are going to build an array of objects, which looks like Listing 5-4.let apressBooks = [ { "id": 111, "title": "C# 6.0", "author": "ANDREW TROELSEN", "rating": [4.7], "reviews": [{good : 4 , excellent : 12}] }, { "id": 222, "title": "Efficient Learning Machines", "author": "Rahul Khanna", "rating": [4.5], "reviews": [] }, { "id": 333, "title": "Pro AngularJS", "author": "Adam Freeman", "rating": [4.0], "reviews": [] }, { "id": 444, "title": "Pro ASP.NET", "author": "Adam Freeman", "rating": [4.2], "reviews": [{good : 14 , excellent : 12}] } ]; Listing 5-4
描述书籍详细信息的 pressBooks 对象
注意
这个数组确实包含了由出版社出版的真实书目,但是评论键值是我自己的解释。
我们将在本章中创建的所有函数都将为给定的对象数组运行。现在假设我们需要得到一个只有标题和作者姓名的对象的数组。我们如何使用地图功能达到同样的效果呢?你想到解决办法了吗?
The solution is simple using the map function, which looks like this:map(apressBooks,(book) => { return {title: book.title,author:book.author} }) That code is going to return the result as you would expect. The object in the returned array will have only two properties: One is title and the other one is author, as you specified in your function:[ { title: 'C# 6.0', author: 'ANDREW TROELSEN' }, { title: 'Efficient Learning Machines', author: 'Rahul Khanna' }, { title: 'Pro AngularJS', author: 'Adam Freeman' }, { title: 'Pro ASP.NET', author: 'Adam Freeman' } ]
我们并不总是想把所有的数组内容转换成一个新的。相反,我们希望过滤数组的内容,然后执行转换。现在是时候来看看队列中的下一个函数了,过滤器。
过滤器
假设我们想要获得评分高于 4.5 的书籍列表。我们将如何实现这一目标?这肯定不是 map 要解决的问题,但是我们需要一个类似于 map 的函数,它只是在将结果推入结果数组之前检查一个条件。
Let’s first take another look at the map function (from Listing 5-2):const map = (array,fn) => { let results = [] for(const value of array) results.push(fn(value)) return results; } Here we need to check a condition or predicate before we do this:. . . results.push(fn(value)) . . . Let’s add that into a separate function called filter as shown in Listing 5-5.const filter = (array,fn) => { let results = [] for(const value of array) (fn(value)) ? results.push(value) : undefined return results; } Listing 5-5
过滤函数定义
With the filter function in place, we can solve our problem at hand in the following way:filter(apressBooks, (book) => book.rating[0] > 4.5) which is going to return the expected result:[ { id: 111, title: 'C# 6.0', author: 'ANDREW TROELSEN', rating: [ 4.7 ], reviews: [ [Object] ] } ]
我们一直在改进使用这些高阶函数处理数组的方法。在我们进一步研究数组中的下一个函数之前,我们将看看如何链接投影函数(map,filter)以在复杂的情况下获得所需的结果。
链接操作
It’s always the case that we need to chain several functions to achieve our goal. For example, imagine the problem of getting the title and author objects out of our apressBooks array for which the review value is greater than 4.5. The initial step to tackle this problem is to solve it via map and filter. In that case, the code might look like this:let goodRatingBooks = filter(apressBooks, (book) => book.rating[0] > 4.5) map(goodRatingBooks,(book) => { return {title: book.title,author:book.author} }) which is going to return the result as expected:[ { title: 'C# 6.0', author: 'ANDREW TROELSEN' } ] An important point to note here is that both map and filter are projection functions, so they always return data after applying the transformation (via the passed higher order function) on the array. We can therefore chain both filter and map (the order is very important) to get the task done without the need for additional variables (i.e., goodRatingBooks):map(filter(apressBooks, (book) => book.rating[0] > 4.5),(book) => { return {title: book.title,author:book.author} })
这段代码实际上告诉了我们正在解决的问题:“映射评级为 4.5 的过滤后的数组,并在一个对象中返回它们的标题和作者键。”由于 map 和 filter 的性质,我们已经抽象出了数组本身的细节,并开始关注手头的问题。
我们将在接下来的章节中展示链接方法的例子。
注意
稍后,我们将看到通过函数组合实现相同功能的另一种方法。
concatAll
Let’s now tweak the apressBooks array a bit, so that we have a data structure that looks like the one shown in Listing 5-6.let apressBooks = [ { name : "beginners", bookDetails : [ { "id": 111, "title": "C# 6.0", "author": "ANDREW TROELSEN", "rating": [4.7], "reviews": [{good : 4 , excellent : 12}] }, { "id": 222, "title": "Efficient Learning Machines", "author": "Rahul Khanna", "rating": [4.5], "reviews": [] } ] }, { name : "pro", bookDetails : [ { "id": 333, "title": "Pro AngularJS", "author": "Adam Freeman", "rating": [4.0], "reviews": [] }, { "id": 444, "title": "Pro ASP.NET", "author": "Adam Freeman", "rating": [4.2], "reviews": [{good : 14 , excellent : 12}] } ] } ]; Listing 5-6
用书籍详细信息更新了 pressbooks 对象
Now let’s take up the same problem that we saw in the previous section: getting the title and author for the books with ratings above 4.5. We can start solving the problem by first mapping over data:map(apressBooks,(book) => { return book.bookDetails }) That is going to return us this value:[ [ { id: 111, title: 'C# 6.0', author: 'ANDREW TROELSEN', rating: [Object], reviews: [Object] }, { id: 222, title: 'Efficient Learning Machines', author: 'Rahul Khanna', rating: [Object], reviews: [] } ], [ { id: 333, title: 'Pro AngularJS', author: 'Adam Freeman', rating: [Object], reviews: [] }, { id: 444, title: 'Pro ASP.NET', author: 'Adam Freeman', rating: [Object], reviews: [Object] } ] ]
如您所见,我们的 map 函数返回的数据包含 Array inside Array,因为我们的 bookDetails 本身就是一个数组。现在,如果我们将这些数据传递给我们的过滤器,我们将有问题,因为过滤器不能在嵌套数组上工作。
That’s where the concatAll function comes in. The job of concatAll is simple enough: It needs to concatenate all the nested arrays into a single array. You can also call concatAll as a flatten method. The implementation of concatAll looks like Listing 5-7.const concatAll = (array,fn) => { let results = [] for(const value of array) results.push.apply(results, value); return results; } Listing 5-7
concatAll 函数定义
在这里,我们只是在迭代结果数组时向上推内部数组。
注意
我们已经使用 JavaScript 函数的 apply 方法将推送上下文设置为结果本身,并将参数作为迭代值的当前索引进行传递。
The main goal of concatAll is to unnest the nested arrays into a single array. The following code explains the concept in action:concatAll( map(apressBooks,(book) => { return book.bookDetails }) ) That is going to return us the result we expected:[ { id: 111, title: 'C# 6.0', author: 'ANDREW TROELSEN', rating: [ 4.7 ], reviews: [ [Object] ] }, { id: 222, title: 'Efficient Learning Machines', author: 'Rahul Khanna', rating: [ 4.5 ], reviews: [] }, { id: 333, title: 'Pro AngularJS', author: 'Adam Freeman', rating: [ 4 ], reviews: [] }, { id: 444, title: 'Pro ASP.NET', author: 'Adam Freeman', rating: [ 4.2 ], reviews: [ [Object] ] } ] Now we can go ahead and easily do a filter with our condition like this:let goodRatingCriteria = (book) => book.rating[0] > 4.5; filter( concatAll( map(apressBooks,(book) => { return book.bookDetails }) ) ,goodRatingCriteria) That is going to return the expected value:[ { id: 111, title: 'C# 6.0', author: 'ANDREW TROELSEN', rating: [ 4.7 ], reviews: [ [Object] ] } ]
我们已经看到了在数组世界中设计一个高阶函数是如何优雅地解决许多问题的。到目前为止,我们做得非常好。在接下来的章节中,我们还会看到更多关于数组的函数。
缩减功能
如果你在任何地方谈论函数式编程,你经常会听到术语 reduce functions 。它们是什么?为什么它们如此有用?reduce 是一个漂亮的函数,旨在展示 JavaScript 中闭包的强大功能。在本节中,我们将探讨减少数组的用处。
减少功能
To give a solid example of the reduce function and where it’s been used, let’s look at the problem of finding the summation of the given array. To start, suppose we have an array called“:let useless = [2,5,6,1,10] We need to find the sum of the given array, but how we can achieve that? A simple solution would be the following:let result = 0; forEach(useless,(value) => { result = result + value; }) console.log(result) => 24
对于这个问题,我们将数组(包含几个数据)缩减为一个值。我们从简单的累加器开始;在这种情况下,我们调用它作为结果来存储我们的求和结果,同时遍历数组本身。请注意,在求和的情况下,我们将结果值设置为默认值 0。如果我们需要找到给定数组中所有元素的乘积呢?在这种情况下,我们将把结果值设置为 1。设置累加器并遍历数组(记住累加器的前一个值)以产生单个元素的整个过程称为缩减数组。
Because we are going to repeat this process for all array-reducing operations, can’t we abstract these into a function? You can, and that’s where the reduce function comes in. The implementation of the reduce function looks like Listing 5-8.const reduce = (array,fn) => { let accumlator = 0; for(const value of array) accumlator = fn(accumlator,value) return [accumlator] } Listing 5-8
缩减功能优先实现
Now with the reduce function in place, we can solve our summation problem using it like this:reduce(useless,(acc,val) => acc + val) =>[24] That is great, but what if we want to find a product of the given array? The reduce function is going to fail, mainly because we are using an accumulator value to 0. So, our product result will be 0, too:reduce(useless,(acc,val) => acc * val) =>[0] We can solve this by rewriting the reduce function from Listing 5-8 such that it takes an argument for setting up the initial value for the accumulator. Let’s do this right away in Listing 5-9.const reduce = (array,fn,initialValue) => { let accumlator; if(initialValue != undefined) accumlator = initialValue; else accumlator = array[0]; if(initialValue === undefined) for(let i=1;i<array.length;i++) accumlator = fn(accumlator,array[i]) else for(const value of array) accumlator = fn(accumlator,value) return [accumlator] } Listing 5-9
缩减功能最终实现
我们对 reduce 函数进行了修改,现在如果 initialValue 没有被传递,reduce 函数将把数组中的第一个元素作为其累加器值。
注意
看看这两个 for 循环语句。当 initialValue 未定义时,我们需要从第二个元素开始循环数组,因为累加器的第一个值将被用作初始值。如果调用方传递了 initialValue,那么我们需要迭代整个数组。
Now let’s try our product problem using the reduce function:reduce(useless,(acc,val) => acc * val,1) =>[600] Next we’ll use reduce in our running example, apressBooks. Bringing apressBooks (updated in Listing 5-6) in here, for easy reference, we have this:let apressBooks = [ { name : "beginners", bookDetails : [ { "id": 111, "title": "C# 6.0", "author": "ANDREW TROELSEN", "rating": [4.7], "reviews": [{good : 4 , excellent : 12}] }, { "id": 222, "title": "Efficient Learning Machines", "author": "Rahul Khanna", "rating": [4.5], "reviews": [] } ] }, { name : "pro", bookDetails : [ { "id": 333, "title": "Pro AngularJS", "author": "Adam Freeman", "rating": [4.0], "reviews": [] }, { "id": 444, "title": "Pro ASP.NET", "author": "Adam Freeman", "rating": [4.2], "reviews": [{good : 14 , excellent : 12}] } ] } ]; On a good day, your boss comes to your desk and asks you to implement the logic of finding the number of good and excellent reviews from our apressBooks. You think this is a perfect problem that can be solved easily via the reduce function. Remember that apressBooks contains an array inside an array (as we saw in the previous section), so we need to concatAll to make it a flat array. Because reviews are a part of bookDetails, we don’t name a key, so we can just map bookDetails and concatAll in the following way:concatAll( map(apressBooks,(book) => { return book.bookDetails }) ) Now let’s solve our problem using reduce:let bookDetails = concatAll( map(apressBooks,(book) => { return book.bookDetails }) ) reduce(bookDetails,(acc,bookDetail) => { let goodReviews = bookDetail.reviews[0] != undefined ? bookDetail.reviews[0].good : 0 let excellentReviews = bookDetail.reviews[0] != undefined ? bookDetail.reviews[0].excellent : 0 return {good: acc.good + goodReviews,excellent : acc.excellent + excellentReviews} },{good:0,excellent:0}) That is going to return the following result:[ { good: 18, excellent: 24 } ] Now let’s walk through the reduce function to see how this magic happened. The first point to note here is that we are passing an accumulator to an initialValue, which is nothing but:{good:0,excellent:0} In the reduce function body, we are getting the good and excellent review details (from our bookDetail object) and storing them in the corresponing variables, namely goodReviews and excellentReviews:let goodReviews = bookDetail.reviews[0] != undefined ? bookDetail.reviews[0].good : 0 let excellentReviews = bookDetail.reviews[0] != undefined ? bookDetail.reviews[0].excellent : 0 With that in place, we can walk through the reduce function call trace to understand better what’s happening. For the first iteration, goodReviews and excellentReviews will be the following:goodReviews = 4 excellentReviews = 12 and our accumulator will be the following:{good:0,excellent:0} as we have passed the initial line. Once the reduce function executes the line: return {good: acc.good + goodReviews,excellent : acc.excellent + excellentReviews} our internal accumulator value gets changed to:{good:4,excellent:12} We are now done with the first iteration of our array. In the second and third iterations, we don’t have reviews; hence, both goodReviews and excellentReviews will be 0, but not affecting our accumulator value, which remains the same:{good:4,excellent:12} In our fourth and final iteration, we will be having goodReviews and excellentReviews as:goodReviews = 14 excellentReviews = 12 and the accumulator value being:{good:4,excellent:12} Now when we execute the line:return {good: acc.good + goodReviews,excellent : acc.excellent + excellentReviews} our accumulator value changes to:{good:18,excellent:28}
因为我们已经完成了所有数组内容的迭代,所以将返回最新的累加器值,这就是结果。
正如您在这里看到的,在这个过程中,我们将内部细节抽象成了高阶函数,从而产生了优雅的代码。在我们结束本章之前,让我们实现 zip 函数,这是另一个有用的函数。
拉链阵列
Life is not always as easy as you think. We had reviews within our bookDetails in our apressBooks details such that we could easily work with it. However, if data like apressBooks does come from the server, they do return data like reviews as a separate array, rather than the embedded data, which will look like Listing 5-10.let apressBooks = [ { name : "beginners", bookDetails : [ { "id": 111, "title": "C# 6.0", "author": "ANDREW TROELSEN", "rating": [4.7] }, { "id": 222, "title": "Efficient Learning Machines", "author": "Rahul Khanna", "rating": [4.5], "reviews": [] } ] }, { name : "pro", bookDetails : [ { "id": 333, "title": "Pro AngularJS", "author": "Adam Freeman", "rating": [4.0], "reviews": [] }, { "id": 444, "title": "Pro ASP.NET", "author": "Adam Freeman", "rating": [4.2] } ] } ]; Listing 5-10
拆分 apressBooks 对象
let reviewDetails = [ { "id": 111, "reviews": [{good : 4 , excellent : 12}] }, { "id" : 222, "reviews" : [] }, { "id" : 333, "reviews" : [] }, { "id" : 444, "reviews": [{good : 14 , excellent : 12}] } ] Listing 5-11
reviewDetails 对象包含图书的评论详细信息
在清单 5-11 中,评论被填充到一个单独的数组中;它们与图书 id 相匹配。这是数据如何被分成不同部分的典型例子。我们如何处理这些分裂的数据?
zip 功能
zip 函数的任务是合并两个给定的数组。在我们的示例中,我们需要将 apressBooks 和 reviewDetails 合并到一个数组中,这样我们在一棵树下就有了所有必要的数据。
The implementation of zip looks like Listing 5-12.const zip = (leftArr,rightArr,fn) => { let index, results = []; for(index = 0;index < Math.min(leftArr.length, rightArr.length);index++) results.push(fn(leftArr[index],rightArr[index])); return results; } Listing 5-12
zip 函数定义
zip is a very simple function; we just iterate over the two given arrays. Because here we are dealing with two array details, we get the minimum length of the given two arrays using Math.min:. . . Math.min(leftArr.length, rightArr.length) . . .
一旦获得了最小长度,我们就用当前的 leftArr 值和 rightArr 值调用我们传递的高阶函数 fn。
Suppose we want to add the two contents of the array; we can do so via zip like the following:zip([1,2,3],[4,5,6],(x,y) => x+y) => [5,7,9] Now let’s solve the same problem that we have solved in the previous section: Find the total count of good and excellent reviews for the Apress collection. Because the data are split into two different structures, we are going to use zip to solve our current problem://same as before get the //bookDetails let bookDetails = concatAll( map(apressBooks,(book) => { return book.bookDetails }) ) //zip the results let mergedBookDetails = zip(bookDetails,reviewDetails,(book,review) => { if(book.id === review.id) { let clone = Object.assign({},book) clone.ratings = review return clone } }) Let’s break down what’s happening in the zip function. The result of the zip function is nothing but the same old data structure we had, precisely, mergedBookDetails:[ { id: 111, title: 'C# 6.0', author: 'ANDREW TROELSEN', rating: [ 4.7 ], ratings: { id: 111, reviews: [Object] } }, { id: 222, title: 'Efficient Learning Machines', author: 'Rahul Khanna', rating: [ 4.5 ], reviews: [], ratings: { id: 222, reviews: [] } }, { id: 333, title: 'Pro AngularJS', author: 'Adam Freeman', rating: [ 4 ], reviews: [], ratings: { id: 333, reviews: [] } }, { id: 444, title: 'Pro ASP.NET', author: 'Adam Freeman', rating: [ 4.2 ], ratings: { id: 444, reviews: [Object] } } ] The way we have arrived at this result is very simple; while doing the zip operation we are taking the bookDetails array and reviewDetails array. We are checking if both the ids match, and if so we clone a new object out of the book and call it clone:. . . let clone = Object.assign({},book) . . .
现在 clone 得到了 book 对象中内容的副本。然而,需要注意的重要一点是,clone 指向一个单独的引用。添加或操作克隆不会改变真正的图书参考本身。在 JavaScript 中,对象是通过引用来使用的,所以在我们的 zip 函数中默认更改 book 对象会影响 bookDetails 本身的内容,这是我们不想做的。
Once we took up the clone, we added to it a ratings key with the review object as its value:clone.ratings = review
最后,我们把它退回去。现在,您可以像以前一样应用 reduce 函数来解决这个问题。zip 是另一个小而简单的函数,但是它的用途非常强大。
摘要
在这一章中,我们已经取得了很大的进步。我们创建了几个有用的函数,比如 map、filter、concatAll、reduce 和 zip,以便更容易地使用数组。我们将这些函数称为投影函数,因为这些函数总是在应用变换(通过高阶函数传递)后返回数组。需要记住的重要一点是,这些只是高阶函数,我们将在日常任务中使用它们。理解这些功能是如何工作的有助于我们从更多的功能角度来思考,但是我们的功能之旅还没有结束。
本章已经创建了许多有用的数组函数,下一章我们将讨论 currying 和部分应用的概念。这些术语没什么可怕的;它们是简单的概念,但在付诸行动时会变得非常强大。第六章见。
六、柯里化和局部应用
在这一章中,我们将了解术语curry的含义。一旦我们理解了这意味着什么以及它可以用在什么地方,我们将转向函数式编程中的另一个概念,叫做局部应用。当我们在功能组合中使用 currying 和 partial application 时,理解它们非常重要。和前几章一样,我们将看一个样例问题,并解释如何应用像 currying 和局部应用这样的函数式编程技术。
注意
章节示例和库源代码在第六章。回购的网址是github.com/antoaravinth/functional-es8.git
。
一旦你检查出代码,请检查分支第六章:
...
git checkout -b 第六章来源/第六章
...
对于运行代码,与之前一样,运行:
...
npm 跑步游乐场
...
关于术语的几点说明
在解释 currying 和 partial application 的意思之前,我们需要理解本章将要用到的几个术语。
一元函数
A function is called unary if it takes a single function argument. For example, the identity function, shown in Listing 6-1, is a unary function.const identity = (x) => x; Listing 6-1
一元恒等函数
这个函数只有一个参数 x,所以我们可以称它为一元函数。
二元函数
A function is called binary if it takes two arguments. For example, in Listing 6-2, the add function is a binary function.const add = (x,y) => x + y; Listing 6-2
二进制加法函数
add 函数有两个参数,x,y;因此我们称它为二元函数。
你可以猜到,有三个参数的三元函数,等等。JavaScript 还允许一种特殊类型的函数,我们称之为可变函数,它接受可变数量的参数。
可变函数
A variadic function is a function that takes a variable number of arguments. Remember that we had arguments in older versions of JavaScript, which we can use to capture the variable number of arguments.function variadic(a){ console.log(a); console.log(arguments) } Listing 6-3
可变函数
We call the variadic function like this:variadic(1,2,3) => 1 => [1,2,3]
注意
正如您在输出中看到的,参数确实捕获了传递给函数的所有参数。
As you can see in Listing 6-3, using arguments we are able to capture the additional arguments one could call on a function. Using this technique, we used to achieve the variadic functions in ES5 versions. However, starting with ES6, we have an operator called Spread Operator that we can use to achieve the same result.const variadic = (a,...variadic) => { console.log(a) console.log(variadic) } Listing 6-4
使用扩展算子的变量函数
Now if we call this function we get exactly what we would expect:variadic(1,2,3) => 1 => [2,3]
正如您在结果中看到的,我们被指向第一个传递的参数 1 和所有其他由我们的变量捕获的参数,该变量使用...休息论点!ES6 风格更简洁,因为它清楚地提到函数确实接受变量参数进行处理。
现在我们已经记住了一些关于函数的常用术语,是时候把注意力转向这个有趣的术语 curry 了。
携带
你是否已经从博客上听过无数次“阿谀奉承”这个词,仍然想知道它是什么意思?不用担心;我们将把 currying 定义分解成更小的定义,这对你来说更有意义。
我们从一个简单的问题开始:什么是 currying?这个问题的一个简单答案是:curry 是一个将带有 n 个参数的函数转换成一个嵌套一元函数的过程。如果你还不明白,不要担心。让我们用一个简单的例子来看看这意味着什么。
Imagine we have a function called add:const add = (x,y) => x + y; It’s a simple function. We can call this function like add(1,1), which is going to give the result 2. Nothing fancy there. Now here is the curried version of the add function:const addCurried = x => y => x + y; The addCurried function is now a curried version of add. If we call addCurried with a single argument like this:addCurried(4) it returns a function where x value is captured via the closure concept as we saw in earlier chapters:=> fn = y => 4 + y We can call the addCurried function like this to get the proper result:addCurried(4)(4) => 8 Here we have manually converted the add function, which takes the two arguments into an addCurried function, which has nested unary functions. The process of converting a function from two arguments to a function that takes one argument (unary function) is called currying, as shown in Listing 6-5.const curry = (binaryFn) => { return function (firstArg) { return function (secondArg) { return binaryFn(firstArg, secondArg); }; }; }; Listing 6-5
库里函数定义
注意
我们已经用 ES5 格式编写了 curry 函数,这样我们可以可视化返回一个嵌套的一元函数的过程。
Now we can use our curry function to convert the add function to a curried version like this:let autoCurriedAdd = curry(add) autoCurriedAdd(2)(2) => 4
输出正是我们想要的。现在是时候修改 currying 的定义了:Currying 是将一个有 n 个参数的函数转换成一个嵌套的一元函数的过程。
正如你在我们的 curry 函数定义中看到的,我们正在将二元函数转换成嵌套函数,每个函数只需要一个参数;也就是说,我们返回的是嵌套的一元函数。现在我们已经在你的头脑中澄清了奉承这个术语,但是你仍然有一些明显的问题:我们为什么需要奉承?它有什么用?
携带用例
We’ll start simple. Imagine we have to create a function for creating tables. For example, we need to create tableOf2, tableOf3, tableOf4, and so on. We can achieve this via Listing 6-6.const tableOf2 = (y) => 2 * y const tableOf3 = (y) => 3 * y const tableOf4 = (y) => 4 * y Listing 6-6
表格功能无需奉承
With that in place, the functions can be called this:tableOf2(4) => 8 tableOf3(4) => 12 tableOf4(4) => 16 Now you see that you can generalize the tables concept into a single function like this:const genericTable = (x,y) => x * y and then you can use genericTable to get tableOf2 like the following:genericTable(2,2) genericTable(2,3) genericTable(2,4) and the same for tableOf3 and tableOf4. If you notice the pattern, we are filling up 2 in the first argument for tableOf2, 3 for tableOf3, and so on! Perhaps you are thinking that we can solve this problem via curry? Let’s build tables from genericTable using curry:const tableOf2 = curry(genericTable)(2) const tableOf3 = curry(genericTable)(3) const tableOf4 = curry(genericTable)(4) Listing 6-7
使用 Currying 的表格功能
Now you can do your testing with these curried versions of the tables:console.log("Tables via currying") console.log("2 * 2 =",tableOf2(2)) console.log("2 * 3 =",tableOf2(3)) console.log("2 * 4 =",tableOf2(4)) console.log("3 * 2 =",tableOf3(2)) console.log("3 * 3 =",tableOf3(3)) console.log("3 * 4 =",tableOf3(4)) console.log("4 * 2 =",tableOf4(2)) console.log("4 * 3 =",tableOf4(3)) console.log("4 * 4 =",tableOf4(4)) This is going to print the value we expect:Table via currying 2 * 2 = 4 2 * 3 = 6 2 * 4 = 8 3 * 2 = 6 3 * 3 = 9 3 * 4 = 12 4 * 2 = 8 4 * 3 = 12 4 * 4 = 16
一个日志功能:使用 Currying
The example in the previous section helped us understand what currying does, but let’s use a more complicated example in this section. As developers when we write code, we do a lot of logging at several stages of the application. We could write a helper logger function that looks like Listing 6-8.const loggerHelper = (mode,initialMessage,errorMessage,lineNo) => { if(mode === "DEBUG") console.debug(initialMessage,errorMessage + "at line: " + lineNo) else if(mode === "ERROR") console.error(initialMessage,errorMessage + "at line: " + lineNo) else if(mode === "WARN") console.warn(initialMessage,errorMessage + "at line: " + lineNo) else throw "Wrong mode" } Listing 6-8
简单的 loggerHelper 函数
When any developer needs to print an error to the console from the Stats.js file, he or she can use the function like the following:loggerHelper("ERROR","Error At Stats.js","Invalid argument passed",23) loggerHelper("ERROR","Error At Stats.js","undefined argument",223) loggerHelper("ERROR","Error At Stats.js","curry function is not defined",3) loggerHelper("ERROR","Error At Stats.js","slice is not defined",31)
类似地,我们可以将 loggerHelper 函数用于调试和警告消息。正如你所看到的,我们在重复所有调用的参数,主要是 mode 和 initialMessage。我们能做得更好吗?是的,通过奉承,我们可以更好地打这些电话。我们可以使用前面定义的 curry 函数吗?不幸的是,不能,因为我们设计的 curry 函数只能处理二进制函数,而不能像 loggerHelper 那样处理四个参数的函数。
让我们来解决这个问题,实现全功能的 curry 函数,它可以处理任何带有 n 个参数的函数。
重温咖喱
We all know that we can curry (Listing 6-5) only a function. How about many functions? It’s simple but important to have it in our implementation of curry. Let’s add the rule first, as shown in Listing 6-9.let curry =(fn) => { if(typeof fn!=='function'){ throw Error('No function provided'); } }; Listing 6-9
修改 curry 函数定义
With that check in place, if others call our curry function with an integer like 2, and so on, they get back the error. That’s perfect! The next requirement to our curried function is that if anyone provided all arguments to a curried function, we need to execute the real function by passing the arguments. Let’s add that using Listing 6-10.let curry =(fn) => { if(typeof fn!=='function'){ throw Error('No function provided'); } return function curriedFn(...args){ return fn.apply(null, args); }; }; Listing 6-10
处理参数的 curry 函数
Now if we have a function called multiply:const multiply = (x,y,z) => x * y * z; We can use our new curry function like the following:curry(multiply)(1,2,3) => 6 curry(multiply)(1,2,0) => 0 Let’s look at how it really works. We have added the logic in our curry function like this:return function curriedFn(...args){ return fn.apply(null, args); }; The returned function is a variadic function , which returns the function result by calling the function via apply along by passing the args:. . . fn.apply(null, args); . . . With our curry(multiply)(1,2,3) example, args will be pointing to [1,2,3] and because we are calling apply on fn, it’s equivalent to:multiply(1,2,3)
这正是我们想要的!因此,我们从函数中得到预期的结果。
Now let us get back to the problem of converting the n argument function into a nested unary function (that’s the definition of curry itself)!let curry =(fn) => { if(typeof fn!=='function'){ throw Error('No function provided'); } return function curriedFn(...args){ if(args.length < fn.length){ return function(){ return curriedFn.apply(null, args.concat( [].slice.call(arguments) )); }; } return fn.apply(null, args); }; }; Listing 6-11
将 n 参数函数转换为一元函数的 curry 函数
We have added the part:if(args.length < fn.length){ return function(){ return curriedFn.apply(null, args.concat( [].slice.call(arguments) )); }; } Let’s understand what’s happening in this piece of code, one element at a time.args.length < fn.length
这一行检查通过...args 长度和函数参数列表长度小于或等于。如果是这样,我们就进入 If 块,否则我们就像以前一样调用完整的函数。
Once we enter the if block, we use the apply function to call curriedFn recursively like this:curriedFn.apply(null, args.concat( [].slice.call(arguments) )); The snippetargs.concat( [].slice.call(arguments) ) is important. Using the concat function , we are concatenating the arguments that are passed one at a time and calling the curriedFn recursively. Because we are combining all the passed arguments and calling it recursively, we will meet a point in which the line if (args.length < fn.length) condition fails. The argument list length (args) and function argument length (fn.length) will be equal, thus skipping the if block and callingreturn fn.apply(null, args);
这将产生函数的完整结果!
With that understanding in place, we can use our curry function to invoke the multiply function:curry(multiply)(3)(2)(1) => 6
完美!我们创建了自己的咖喱功能。
注意
您也可以像下面这样调用前面的代码片段:
let curriedMul3 = curry(乘)(3)
let curriedMul2 = curriedMul3(2)
let curriedMul1 = curriedMul2(1)
其中 curriedMul1 将等于 6。不过,我们使用 curry(multiply)(3)(2)(1),因为它可读性更好。
需要注意的重要一点是,我们的 curry 函数现在将一个有 n 个参数的函数转换为一个可以作为一元函数调用的函数,如示例所示。
返回记录器功能
Now let’s solve our logger function using the defined curry function. Bringing up the function here for easy reference (Listing 6-8):const loggerHelper = (mode,initialMessage,errorMessage,lineNo) => { if(mode === "DEBUG") console.debug(initialMessage,errorMessage + "at line: " + lineNo) else if(mode === "ERROR") console.error(initialMessage,errorMessage + "at line: " + lineNo) else if(mode === "WARN") console.warn(initialMessage,errorMessage + "at line: " + lineNo) else throw "Wrong mode" } The developer used to call the function:loggerHelper("ERROR","Error At Stats.js","Invalid argument passed",23) Now let’s solve the repeating first two arguments problem via curry:let errorLogger = curry(loggerHelper)("ERROR")("Error At Stats.js"); let debugLogger = curry(loggerHelper)("DEBUG")("Debug At Stats.js"); let warnLogger = curry(loggerHelper)("WARN")("Warn At Stats.js"); Now we can easily refer to the earlier curried functions and use them under the respective context://for error errorLogger("Error message",21) => Error At Stats.js Error messageat line: 21 //for debug debugLogger("Debug message",233) => Debug At Stats.js Debug messageat line: 233 //for warn warnLogger("Warn message",34) => Warn At Stats.js Warn messageat line: 34
太棒了!我们已经看到了 curry 函数如何在现实世界中帮助删除函数调用中的大量样板文件。不要忘记感谢闭包概念,它支持了 curry 函数。节点的调试模块在其 API 中使用了库里概念(见github.com/visionmedia/debug
)。
进行中
在上一节中,我们创建了自己的 curry 函数。我们还看到了一个使用这个 curry 函数的简单例子。
在这一节中,我们将看到一些小而紧凑的例子,其中使用了涂抹技巧。本节展示的例子将帮助你更好地理解如何在日常活动中使用奉承。
在数组内容中查找数字
Imagine we want to find the array content that has a number. We can solve the problem via the following code snippet:let match = curry(function(expr, str) { return str.match(expr); }); The returned match function is a curried function. We can give the first argument expr a regular expression /[0-9]+/ that will indicate whether the content has a number in it.let hasNumber = match(/[0-9]+/) Now we will create a curried filter function :let filter = curry(function(f, ary) { return ary.filter(f); }); With hasNumber and filter in place, we can create a new function called findNumbersInArray:let findNumbersInArray = filter(hasNumber) Now you can test it:findNumbersInArray(["js","number1"]) => ["number1"]
对数组求平方
We know how to square contents of an array. We have also seen the same problem in previous chapters. We use the map function and pass on the square function to achieve the solution to our problem. Here we can use the curry function to solve the same problem in another way:let map = curry(function(f, ary) { return ary.map(f); }); let squareAll = map((x) => x * x) squareAll([1,2,3]) => [1,4,9]
正如你在这个例子中看到的,我们已经创建了一个新的函数 squareAll,现在我们可以在代码库中的其他地方使用它。类似地,您也可以对 findEvenOfArray、findPrimeOfArray 等执行此操作。
数据流
在使用 currying 的两节中,我们已经设计了 curried 函数,使得它们总是在最后使用数组。这是一种有意创建定制函数的方式。如前几章所讨论的,我们作为程序员经常处理像 array 这样的数据结构,所以把 array 作为最后一个参数允许我们创建许多可重用的函数,比如 squareAll 和 findNumbersInArray,我们可以在整个代码库中使用它们。
注意
在我们的源代码中,我们调用了 curry 函数 curryN。这只是为了保持原来的 curry,它应该对二元函数进行 curry。
部分应用
在这一节中,我们将看到另一个名为 partial 的函数,它允许开发人员部分地应用函数参数。
Imagine we want to perform a set of operations every 10 milliseconds. Using the setTimeout function , we can do this:setTimeout(() => console.log("Do X task"),10); setTimeout(() => console.log("Do Y task"),10); As you can see, we are passing on 10 for every one of our setTimeout function calls. Can we hide that from the code? Can we use a curry function to solve this problem? The answer is no, because the curry function applies the argument from the leftmost to rightmost lists. Because we want to pass on the functions as needed and keep 10 as a constant (which is most of the argument list), we cannot use curry as such. One workaround is that we can wrap our setTimeout function so that the function argument becomes the rightmost one:const setTimeoutWrapper = (time,fn) => { setTimeout(fn,time); } Then we can use our curry function to wrap our setTimeout to a 10-millisecond delay:const delayTenMs = curry(setTimeoutWrapper)(10) delayTenMs(() => console.log("Do X task")) delayTenMs(() => console.log("Do Y task"))
我们需要它的时候它就会工作。但问题是,我们必须创建像 setTimeoutWrapper 这样的包装器,这将是一个开销。这就是我们可以使用部分应用技术的地方。
实现部分功能
为了充分理解分部应用技术是如何工作的,我们将在这一部分创建我们自己的分部函数。一旦实现完成,我们将通过一个简单的例子来学习如何使用我们的部分函数。
The implementation of the partial function looks like Listing 6-12.const partial = function (fn,...partialArgs){ let args = partialArgs; return function(...fullArguments) { let arg = 0; for (let i = 0; i < args.length && arg < fullArguments.length; i++) { if (args[i] === undefined) { args[i] = fullArguments[arg++]; } } return fn.apply(null, args); }; }; Listing 6-12
部分函数定义
Let’s quickly use the partial function with our current problem:let delayTenMs = partial(setTimeout,undefined,10); delayTenMs(() => console.log("Do Y task")) which will print to the console as you expect. Now let’s walk through the implementation details of the partial function. Using closures, we are capturing the arguments that are passed to the function for the first time:partial(setTimeout,undefined,10) //will lead to let args = partialArgs => args = [undefined,10] We return a function that will remember the args value (yes, we are using closures again). The returned function is very easy. It takes an argument called fullArguments, so we call functions like delayTenMs by passing this argument:delayTenMs(() => console.log("Do Y task")) //fullArguments points to //[() => console.log("Do Y task")] //args using closures will have //args = [undefined,10] Now in the for loop we iterate and create the necessary arguments array for our function:if (args[i] === undefined) { args[i] = fullArguments[arg++]; } } Now let’s start with value i as 0://args = [undefined,10] //fullArguments = [() => console.log("Do Y task")] args[0] => undefined === undefined //true //inside if loop args[0] = fullArguments[0] => args[0] = () => console.log("Do Y task") //thus args will become => [() => console.log("Do Y task"),10]
正如您在这些代码片段示例中看到的,我们的参数指向数组,正如我们对 setTimeout 函数调用的预期。一旦我们在 args 中有了必要的参数,我们就通过 fn.apply(null,args)调用函数。
Remember that we can apply partial for any function that has n arguments. To make the point concrete, let’s look at an example. In JavaScript we use the following function call to do JSON pretty print:let obj = {foo: "bar", bar: "foo"} JSON.stringify(obj, null, 2); As you can see, the last two arguments for the function called stringify are always going to be the same: null,2. We can use partial to remove the boilerplate:let prettyPrintJson = partial(JSON.stringify,undefined,null,2) You can then use prettyPrintJson to print the JSON:prettyPrintJson({foo: "bar", bar: "foo"}) which will give you this output:"{ "foo": "bar", "bar": "foo" }"
注意
在部分函数的实现中有一个小错误。如果用不同的参数再次调用 prettyPrintJson 呢?有用吗?
它总是给出第一个调用的参数的结果,但是为什么呢?你能看出我们哪里出错了吗?
提示:请记住,我们是通过用我们的参数替换未定义的值来修改参数的,数组用于引用。
Currying 与部分应用
我们已经看到了这两种技术,所以问题是何时使用哪一种。答案取决于你的 API 是如何定义的。如果你的 API 被定义为 map,filter,那么我们可以很容易地使用 curry 函数来解决我们的问题。正如上一节所讨论的,生活并不总是一帆风顺的。可能有些函数不是为 curry 设计的,比如我们例子中的 setTimeout。在这些情况下,最好的选择是使用部分函数。毕竟,我们使用 curry 或 partial 是为了让函数参数和函数设置变得简单和更强大。
还需要注意的是,currying 将返回嵌套的一元函数;我们已经实现了 curry,为了方便起见,它需要 n 个参数。开发人员需要 curry 或 partial,但不是两者都需要,这也是一个被证明的事实。
摘要
Currying 和局部应用始终是函数式编程中的一个工具。我们从解释 currying 的定义开始这一章,currying 只不过是将一个有 n 个参数的函数转换成嵌套的一元函数。我们已经看到了 currying 的例子以及它非常有用的地方,但是有些情况下,您希望填充函数的前两个参数和最后一个参数,而让中间的参数在一段时间内未知。这就是局部应用发挥作用的地方。为了充分理解这两个概念,我们实现了自己的 curry 和部分函数。我们已经取得了很大的进步,但我们还没有完成。
函数式编程就是组合函数,即组合几个小函数来构建一个新函数。构成和管道是下一章的主题。
七、组件和管道
在前一章中,我们看到了函数式编程的两种重要技术:currying 和局部应用。我们讨论了这两种技术是如何工作的,以及作为 JavaScript 程序员,我们在代码库中选择 currying 或 partial application。在这一章中,我们将看到功能组合的含义及其实际使用案例。
函数式组合在函数式编程界简称为组合。我们将会看到一些关于合成的理论和一些例子,然后我们将会写我们自己的合成函数。理解如何使用 compose 函数来编写更干净的 JavaScript 是一项有趣的任务。
注意
章节示例和库源代码在第七章分支。回购的网址是github.com/antsmartian/functional-es8.git
。
一旦你检查出代码,请检查分支第七章:
...
git checkout -b 第七章来源/第七章
...
对于运行代码,与之前一样,运行:
...
npm 跑步游乐场
...
一般意义上的构成
在我们了解功能组合是怎么一回事之前,让我们退一步理解组合背后的思想。在这一节中,我们将通过使用一种在 Unix 世界中更为流行的哲学来探索组合的思想。
Unix 哲学
Unix philosophy is a set of ideas that were originated by Ken Thompson. One part of the Unix philosophy is this:
- 让每个程序做好一件事。要做一项新的工作,就要重新构建,而不是通过添加新的“特性”使旧的程序变得复杂
这正是我们在创建函数时所做的事情。正如我们在本书中看到的,函数应该接受一个参数并返回数据。是的,函数式编程确实遵循 Unix 哲学。
The second part of the philosophy is this:
- 期望每个程序的输出成为另一个未知程序的输入。
这是一个有趣的报价。“期望每个程序的输出成为另一个程序的输入”是什么意思?为了说明这一点,让我们看看 Unix 平台上遵循这些原则构建的几个命令。
For example, cat is a command (or you can think of it as a function) that is used to display the contents of a text file to a console. Here the cat command takes an argument (as similar to a function), that is, the file location, and so on, and returns the output (again as similar to a function) to the console. So we can do the following:cat test.txt which will print to the consoleHello world
注意
这里 test.txt 的内容将是 Hello world。
就这么简单。另一个名为 grep 的命令允许我们在给定的文本中搜索内容。需要注意的重要一点是,grep 函数接受输入并给出输出(同样非常类似于函数)。
We can do the following with the grep command:grep 'world' test.txt which will return the matching content, in this case:Hello world We have seen two quite simple functions—grep and cat—that are built by following the Unix philosophy. Now we can take some time to understand this quote:
- 期望每个程序的输出成为另一个未知程序的输入。
Imagine you to want to send the data from the cat command as an input to the grep command to do a search. We know that the cat command will return the data; we also know that the grep command takes the data for processing the search operation. Thus, using the Unix | (pipe symbol), we can achieve our task:cat test.txt | grep 'world' which will return the data as expected:Hello world
注意
符号|称为管道符号。这使得我们可以将几个函数组合起来,创建一个新的函数来帮助我们解决问题。基本上|把左边一个函数的输出作为右边一个函数的输入发送!这个过程,从技术上来说,叫做 s 流水线。
This example might be trivial, but it conveys the idea behind the quote:
- 期望每个程序的输出成为另一个未知程序的输入。
如我们的示例所示,grep 命令或函数接收 cat 命令或函数的输出。这里,我们通过组合两个现有的基本函数,毫不费力地创建了一个新函数。当然,这里的|管道充当了一个桥来连接给定的两个命令。
让我们稍微改变一下我们的问题陈述。如果我们想计算单词 world 在一个给定的文本文件中出现的次数呢?我们如何实现它?
This is how we are going to solve it:cat test.txt | grep 'world' | wc
注意
命令 wc 用于计算给定文本中的单词数。该命令在所有 Unix 和 Linux 平台上都可用。
This is going to return the data as we expected. As the preceding examples show, we are creating a new function as per our need on the fly from our base functions! In other words, we are composing a new function from our base function(s). Note that the base function needs to obey this rule :
- 每个基本函数都需要一个参数和返回值。
我们将能够在|的帮助下构造一个新的函数。如本章所示,我们将在 JavaScript 中构建我们自己的 compose 函数,它在 Unix 和 Linux 世界中做与|相同的工作。
现在我们有了从基本函数合成函数的想法。组合函数的真正好处是,我们可以组合我们的基本函数来解决手头的问题,而无需重新创建一个新函数。
操作组合
在这一节中,我们将讨论一个用例,在这个用例中,函数组合在 JavaScript 世界中非常有用。和我们在一起;你一定会喜欢作曲功能的想法。
重访地图,过滤器
在第五章中,我们看到了如何从地图和过滤器中链接数据来解决手头的问题。让我们快速回顾一下问题和解决方案。
We had an array of objects, the structure of which looks like Listing 7-1. { "id": 111, "title": "C# 6.0", "author": "ANDREW TROELSEN", "rating": [4.7], "reviews": [{good : 4 , excellent : 12}] }, { "id": 222, "title": "Efficient Learning Machines", "author": "Rahul Khanna", "rating": [4.5], "reviews": [] }, { "id": 333, "title": "Pro AngularJS", "author": "Adam Freeman", "rating": [4.0], "reviews": [] }, { "id": 444, "title": "Pro ASP.NET", "author": "Adam Freeman", "rating": [4.2], "reviews": [{good : 14 , excellent : 12}] } ]; Listing 7-1
Apressbook 对象结构,设 apressBooks =
The problem was to get the title and author objects out of apressBooks for which the review value is greater than 4.5. Our solution to the problem was Listing [7-2.map(filter(apressBooks, (book) => book.rating[0] > 4.5),(book) => { return {title: book.title,author:book.author} }) Listing 7-2
使用地图获取作者详细信息
For this, the result is the following:[ { title: 'C# 6.0', author: 'ANDREW TROELSEN' } ]
实现解决方案的代码说明了重要的一点。来自过滤器函数的数据作为输入参数传递给 map 函数。是的,你猜对了:这听起来是不是和我们在上一节中在 Unix 世界中使用|解决的问题一样?我们能在 JavaScript 世界做同样的事情吗?我们能否通过将一个函数的输出作为另一个函数的输入来创建一个将两个函数结合起来的函数?是的,我们可以!满足作曲功能。
撰写功能
In this section, let’s create our first compose function. Creating a new compose function is easy and straightforward. The compose function needs to take the output of one function and provide it as input to another function. Let’s write a simple compose function in Listing 7-3.const compose = (a, b) => (c) => a(b(c)) Listing 7-3
撰写函数定义
compose 函数很简单,完成了我们需要它做的事情。它接受两个函数 a 和 b,并返回一个接受一个参数 c 的函数。当我们通过提供 c 的值来调用 compose 函数时,它将调用输入为 c 的函数 b,函数 b 的输出作为输入进入函数 a。这正是组合函数的定义。
现在,让我们先用一个简单的例子快速测试一下我们的 compose 函数,然后再深入研究上一节中的运行例子。
注意
compose 函数首先执行 b,并将 b 的返回值作为参数传递给函数 a。在 compose 中调用函数的方向是从右到左(即 b 先执行,然后是 a)。
使用撰写功能
有了我们的 compose 函数,让我们构建一些例子。
假设我们想对一个给定的数字进行四舍五入。该数字将是一个浮点数,所以我们必须将该数字转换为浮点数,然后调用 Math.round。
Without compose, we can do the following:let data = parseFloat("3.56") let number = Math.round(data)
如我们所料,输出将是 4。在这个例子中可以看到,数据(parseFloat 函数的输出)作为输入被传递给 Math.round 以获得一个解;这正是我们的组合函数要解决的问题。
Let’s solve this via our compose function:let number = compose(Math.round,parseFloat) This statement will return a new function that is stored as a number and looks like this:number = (c) => Math.round(parseFloat(c)) Now if we pass the input c to our number function, we will get what we expect:number("3.56") => 4
我们刚才做的是功能构图!是的,我们已经编写了两个函数来动态构建一个新函数!这里要注意的一个关键点是,在我们调用我们的数字函数之前,函数 Math.round 和 parseFloat 不会被执行或运行。
Now imagine we have two functions:let splitIntoSpaces = (str) => str.split(" "); let count = (array) => array.length; Now if you want to build a new function to count the number of words in a string, we can easily do this:const countWords = compose(count,splitIntoSpaces); Now we can call that:countWords("hello your reading about composition") => 5
使用 compose 新创建的函数 countWords 是通过组合多个基本函数创作简单函数的一种优雅而简单的方式。
库里又偏去救援了
我们知道,只有当这个函数接受一个输入参数时,我们才能组合两个函数。不过,情况并不总是这样,因为函数可能有多个参数。我们该如何组合这些函数呢?我们能做些什么吗?
Yes, we can do it using either the curry or partial functions that we defined in the previous chapter. Earlier in this chapter we used the following code to solve one of the problems (Listing 7-2):map(filter(apressBooks, (book) => book.rating[0] > 4.5),(book) => { return {title: book.title,author:book.author} })
现在,我们可以使用 compose 函数根据我们的示例来组合 map 和 filter 了吗?记住,map 和 filter 函数都有两个参数:第一个参数是数组,第二个参数是对数组进行操作的函数。因此我们不能直接组合这两个函数。
We can, however, take help from partial functions . Remember that the preceding code snippet does work on the apressBooks object. We pull it out here again for easy reference:let apressBooks = [ { "id": 111, "title": "C# 6.0", "author": "ANDREW TROELSEN", "rating": [4.7], "reviews": [{good : 4 , excellent : 12}] }, { "id": 222, "title": "Efficient Learning Machines", "author": "Rahul Khanna", "rating": [4.5], "reviews": [] }, { "id": 333, "title": "Pro AngularJS", "author": "Adam Freeman", "rating": [4.0], "reviews": [] }, { "id": 444, "title": "Pro ASP.NET", "author": "Adam Freeman", "rating": [4.2], "reviews": [{good : 14 , excellent : 12}] } ]; Now let’s say we have many small functions in our code base for filtering the books based on different ratings like the following:let filterOutStandingBooks = (book) => book.rating[0] === 5; let filterGoodBooks = (book) => book.rating[0] > 4.5; let filterBadBooks = (book) => book.rating[0] < 3.5; and we do have many projection functions like this:let projectTitleAndAuthor = (book) => { return {title: book.title,author:book.author} } let projectAuthor = (book) => { return {author:book.author} } let projectTitle = (book) => { return {title: book.title} }
注意
你可能想知道为什么我们有小函数,即使是简单的东西。记住,组合就是将小功能组合成一个更大的功能。简单的功能易于阅读、测试和维护;使用 compose 我们可以从它构建任何东西,正如我们将在本节中看到的。
Now to solve our problem—to get book titles and authors with ratings higher than 4.5—we can use compose and partial as in the following:let queryGoodBooks = partial(filter,undefined,filterGoodBooks); let mapTitleAndAuthor = partial(map,undefined,projectTitleAndAuthor) let titleAndAuthorForGoodBooks = compose(mapTitleAndAuthor,queryGoodBooks)
让我们花一些时间来理解部分函数在当前问题域中的位置。如上所述,compose 函数只能构造一个带有一个参数的函数。然而,filter 和 map 都有两个参数,所以我们不能直接组合它们。
That’s the reason we have used the partial function to partially apply the second argument for both map and filter, as you can see here:partial(filter,undefined,filterGoodBooks); partial(map,undefined,projectTitleAndAuthor) Here we have passed the filterGoodBooks function to query the books that have ratings over 4.5 and the projectTitleAndAuthor function to take the title and author properties from the apressBooks object. Now the returned partial application will expect only one argument, which is nothing but the array itself. With these two partial functions in place, we can compose them via compose as we already have done, as shown in Listing 7-4.let titleAndAuthorForGoodBooks = compose(mapTitleAndAuthor,queryGoodBooks) Listing 7-4
使用合成功能
Now the function titleAndAuthorForGoodBooks expects one argument, in our case apressBooks; let’s pass the object array to it:titleAndAuthorForGoodBooks(apressBooks) => [ { title: 'C# 6.0', author: 'ANDREW TROELSEN' } ]
没有 compose,我们得到了我们想要的东西,但是在我们看来,最新的 composed 版本 titleAndAuthorForGoodBooks 可读性更好,也更优雅。您可以感觉到创建小功能单元的重要性,这些小功能单元可以根据我们的需要使用 compose 进行重建。
In the same example, what if we want to get only the titles of the books with a rating higher than 4.5? It’s simple:let mapTitle = partial(map,undefined,projectTitle) let titleForGoodBooks = compose(mapTitle,queryGoodBooks) //call it titleForGoodBooks(apressBooks) => [ { title: 'C# 6.0' } ]
只获取评分等于 5 的书籍的作者姓名怎么样?那应该很容易,对吗?我们让您使用已经定义的函数和 compose 函数来解决这个问题。
注意
在本节中,我们使用了 partial 来填充函数的参数。然而,你可以用咖喱做同样的事情。只是选择的问题。你能想出一个在我们的例子中使用咖喱的解决方案吗?(提示:颠倒映射、过滤器的参数顺序)。
组合许多功能
目前我们的 compose 函数版本只包含两个给定的函数。组合三个、四个或者 n 个函数怎么样?遗憾的是,我们当前的实现不能处理这个问题。让我们重写我们的 compose 函数,以便它可以动态地组合多个函数。
Remember that we need to send the output of each function as an input to another function (by remembering the last executed function output recursively). We can use the reduce function, which we used in previous chapters to reduce the n of function calls one at a time. The rewritten compose function now looks like Listing 7-5.const compose = (...fns) => (value) => reduce(fns.reverse(),(acc, fn) => fn(acc), value); Listing 7-5
组合多种功能
注意
这个函数在源代码 repo 中称为 composeN。
The important line of the function is this:reduce(fns.reverse(),(acc, fn) => fn(acc), value);
注意
回想一下前一章,我们使用 reduce 函数将数组缩减为单个值(以及一个累加器值;即 reduce 的第三个参数)。例如,要查找给定数组的总和,请使用 reduce:
reduce([1,2,3],(acc,it) => it + acc,0)=> 6
这里数组[1,2,3]被简化为[6];这里的累加器值是 0。
在这里,我们首先通过 fns.reverse()反转函数数组,并将函数作为(acc,fn) => fn(acc)传递,这将通过将 acc 值作为参数传递来逐个调用每个函数。值得注意的是,初始累加器值只是一个值变量,它将是我们函数的第一个输入。
With the new compose function in place, let’s test it with our old example. In the previous section we composed a function to count words given in a string:let splitIntoSpaces = (str) => str.split(" "); let count = (array) => array.length; const countWords = compose(count,splitIntoSpaces); //count the words countWords("hello your reading about composition") => 5 Imagine we want to find out whether the word count in the given string is odd or even. We already have a function for it:let oddOrEven = (ip) => ip % 2 == 0 ? "even" : "odd" Now with our compose function in place, we can compose these three functions to get what we really want:const oddOrEvenWords = composeN(oddOrEven,count,splitIntoSpaces); oddOrEvenWords("hello your reading about composition") => ["odd"]
我们得到了预期的结果。去玩我们新的撰写功能吧!
现在,我们对如何使用 compose 函数来获取我们需要的内容有了一个很好的理解。在下一节中,我们将看到相同的概念以不同的方式组合,称为管道 。
管道和序列
在上一节中,我们看到 compose 的数据流是从左到右的,因为最左边的函数首先执行,然后将数据传递给下一个函数,依此类推,直到最右边的函数最后执行。
有些人喜欢另一种方式——首先执行最右边的函数,最后执行最左边的函数。正如您所记得的,当我们执行|时,Unix 命令上的数据流是从右到左的。在这一节中,我们将实现一个名为 pipe 的新函数,它的功能与 compose 函数完全相同,只是交换了数据流。
注意
这种数据从右向左流动的过程被称为管道甚至序列。您可以根据自己的喜好将它们称为管道或序列。
实施管道
The pipe function is just a replica of our compose function; the only change is the data flow, as shown in Listing 7-6.const pipe = (...fns) => (value) => reduce(fns,(acc, fn) => fn(acc), value); Listing 7-6
管道功能定义
就这样。注意,不再像在 compose 中那样调用 fns 反向函数,这意味着我们将按原样执行函数顺序(从左到右)。
Let’s quickly check our implementation of the pipe function by rerunning the same example as in the previous section:const oddOrEvenWords = pipe(splitIntoSpaces,count,oddOrEven); oddOrEvenWords("hello your reading about composition"); => ["odd"]
结果是完全一样的。然而,请注意,当我们做管道时,我们已经改变了函数的顺序。首先,我们调用 splitIntoSpaces,然后计数,最后 oddOrEven。
有些人(了解 shell 脚本)更喜欢管道而不是组合。这只是个人喜好,与底层实现无关。要点是 pipe 和 compose 做同样的事情,但是使用不同的数据流。您可以在代码库中使用 pipe 或 compose,但不能两者都用,因为这会导致团队成员的混乱。坚持一种作曲风格。
作文赔率
在本节中,我们讨论两个主题。第一个是 compose 最重要的属性之一: Composition 是 associative 。第二个讨论是关于当我们组合许多函数时如何调试。
让我们一个接一个地解决。
构图是联想的
Functional composition is always associative. In general, the associative law states the outcome of the expression remains the same irrespective of the order of the parentheses, for example:x * (y * z) = (x * y) * z = xyz Likewise,compose(f, compose(g, h)) == compose(compose(f, g), h); Let’s quickly check our previous section example://compose(compose(f, g), h) let oddOrEvenWords = compose(compose(oddOrEven,count),splitIntoSpaces); let oddOrEvenWords("hello your reading about composition") => ['odd'] //compose(f, compose(g, h)) let oddOrEvenWords = compose(oddOrEven,compose(count,splitIntoSpaces)); let oddOrEvenWords("hello your reading about composition") => ['odd']
正如你在这些例子中看到的,两种情况下的结果是一样的。从而证明了功能组合是结合的。您可能想知道组合关联的好处是什么?
The real benefit is that it allows us to group functions into their own compose; that is:let countWords = compose(count,splitIntoSpaces) let oddOrEvenWords = compose(oddOrEven,countWords) or let countOddOrEven = compose(oddOrEven,count) let oddOrEvenWords = compose(countOddOrEven,splitIntoSpaces) or ...
这种代码之所以可能,只是因为组合具有关联属性。在本章的前面,我们讨论了创建小函数是合成的关键。因为组合是关联的,所以我们可以通过组合创建小函数,而不用担心,因为结果是一样的。
管道运营商
组合或链接基本函数的另一种方法是使用管道操作符。管道操作符类似于我们前面看到的 Unix 管道操作符。新的管道操作符旨在使链接的 JavaScript 函数的代码更具可读性和可扩展性。
注意
在撰写本文时,管道运营商仍处于 TC39 批准工作流的第一阶段草案(提案)状态,这意味着它还不是 ECMAScript 规范的一部分。该提案的最新状态以及浏览器兼容性将在github.com/tc39/proposals
发布。
让我们看一些管道操作符的例子。
Consider the following mathematical functions that operate on a single string argument.const double = (n) => n * 2; const increment = (n) => n + 1; const ntimes = (n) => n * n; Now, to call these functions on any number, normally we would write the following statement:ntimes(double(increment(double(double(5))))); This statement should return a value of 1764. The problem with this statement is the readability, as the sequence of operations or number of the operations is not readable. Linux-like systems use a pipeline operator like the one we saw at the beginning of the chapter. To make the code more readable a similar operator is being added to the ECMAScript 2017 (ECMA8). The name of the operator is pipeline (or binary infix operator), which looks like ‘|>’. The binary infix operator evaluates its left-hand side (LHS) and applies the right-hand side (RHS) to the LHS’s value as a unary function call. Using this operator, the preceding statement can be written as shown here.5 |> double |> double |> increment |> double |> ntimes // returns 1764.
这样可读性更强,不是吗?当然,它比嵌套表达式更容易阅读,包含的括号更少或没有括号,缩进也更少。请记住,在这一点上,它只适用于一元函数,只有一个参数的函数。
注意
在撰写本文时,我们还没有机会使用 Babel 编译器来执行它,因为该操作符处于提议状态。使用最新的 Babel 编译器,当提案通过阶段 0(已发布)时,您可以尝试前面的示例。你也可以使用在线巴别塔编译器,比如在 https://babeljs.io/的那个。该提案被纳入 ECMAScript 的最新状态可以在 http://tc39.github.io/proposal-pipeline-operator/的查看。
Using the pipeline operator with our earlier example of getting the title and author of highly reviewed books is shown here.let queryGoodBooks = partial(filter,undefined,filterGoodBooks); let mapTitleAndAuthor = partial(map,undefined,projectTitleAndAuthor) let titleAndAuthorForGoodBooks = compose(mapTitleAndAuthor,queryGoodBooks) titleAndAuthorForGoodBooks(apressBooks) This can be rewritten more understandably asapressBooks |> queryGoodBooks |> mapTitleAndAuthor.
同样,这个操作符只是一个语法选择;幕后的代码保持不变,所以这是开发人员的选择问题。然而,这种模式通过消除命名中间变量的工作节省了一些击键次数。这个管道操作者的 GitHub 库是GitHub . com/babel/babel/tree/master/packages/babel-plugin-syntax-pipeline-operator
。
Although the pipeline operator works only on unary functions, there is a way around that to use it for functions with multiple arguments. Say we have these functions:let add = (x, y) => x + y; let double = (x) => x + x; // without pipe operator add(10, double(7)) // with pipe operator 7 |> double |> ( _=> add(10, _ ) // returns 24.
注意
在这里,字符 _ 可以替换为任何有效的变量名。
使用 tap 功能进行调试
在这一章中,我们已经用了很多 compose 函数。组合函数可以组合任意数量的函数。数据将在一个链中从左到右流动,直到完整的函数列表被求值。在这一节中,我们教你一个技巧,它允许你调试 compose 上的错误。
Let’s create a simple function called identity. The aim of this function is to take the argument and return the same argument; hence the name identity.const identity = (it) => { console.log(it); return it } Here we have added a simple console.log to print the value this function receives and also return it as it is. Now imagine we have the following call:compose(oddOrEven,count,splitIntoSpaces)("Test string"); When you execute this code, what if the count function throws an error? How will you know what value the count function receives as its argument? That’s where our little identity function comes into the picture. We can add identity in the flow where we see an error like this:compose(oddOrEven,count,identity,splitIntoSpaces)("Test string");
它将打印 count 函数将要接收的输入参数。这个简单的函数对于调试函数接收到的数据非常有用。
摘要
我们以 Unix 哲学为例开始这一章。我们已经看到,通过遵循 Unix 理念,像 cat、grep 和 wc 这样的 Unix 命令能够根据需要进行组合。我们创建了自己版本的 compose 函数,以在 JavaScript 世界中实现同样的功能。简单的组合函数对开发人员很有用,因为我们可以根据需要从定义良好的小函数中组合复杂的函数。我们还看到了一个例子,通过部分函数,currying 如何帮助功能组合。
我们还讨论了另一个名为 pipe 的函数,它做完全相同的事情,但是与 compose 函数相比,它反转了数据流。在本章的最后,我们讨论了复合的一个重要性质:复合是结合的。我们还介绍了一个新的管道操作符(| >)的用法,也称为二元中缀操作符,它可以用于一元函数。管道操作符是 ECMAScript 2017 的提案,目前处于提案阶段,将很快在 ECMAScript 的下一个版本中提供。我们还展示了一个名为 identity 的小函数,在面临 compose 函数的问题时,我们可以使用它作为调试工具。
在下一章,我们将讨论函子。函子非常简单,但是非常强大。我们将在下一章介绍用例以及更多关于函子的内容。
八、使用仿函数获取乐趣
在前一章中,我们讨论了许多函数式编程技术。在本章中,我们将看到编程中的另一个重要概念,叫做错误处理。错误处理是一种常见的编程技术,用于处理应用程序中的错误。不过,错误处理的函数式编程方法会有所不同,这正是我们在本章将要看到的。
我们将会看到一个叫做函子的新概念。这个新朋友将帮助我们以一种纯粹功能性的方式处理错误。一旦我们掌握了函子的概念,我们将实现两个真实世界的函子:也许和任何一个。我们开始吧。
注意
章节示例和库源代码在第八章分支。回购的网址是github.com/antsmartian/functional-es8.git
。
一旦你检查出代码,请检查分支第八章:
...
git checkout -b 第八章来源/第八章
...
对于运行代码,与之前一样,运行:
...
npm 跑步游乐场
...
什么是函子?
In this section we are going to see what a functor really is. Here is its definition:
- 仿函数是一个普通的对象(或者其他语言中的类型类),它实现了函数映射,当运行对象中的每个值时,生成一个新的对象。
乍一看,理解这个定义并不容易。我们将一步一步地分解它,以便我们清楚地理解它,并(通过编写代码)看到函子是什么。
仿函数是一个容器
Simply put, a functor is a container that holds the value in it. We have seen this in the definition stating that functor is a plain object. Let’s create a simple container that can hold any value we pass into it, and call it a Container (see Listing 8-1).const Container = function(val) { this.value = val; } Listing 8-1
容器定义
注意
您可能想知道为什么我们不使用箭头语法来编写容器函数:
常量容器= (val) => {
this.value = val
}
这段代码没有问题,但是当我们试图在容器上应用新的关键字时,我们会得到一个类似这样的错误:
容器不是构造函数(...)(匿名函数)
这是为什么呢?嗯,从技术上讲,要创建一个新对象,函数应该有内部方法[[Construct]]和属性 prototype。可悲的是,箭头函数不具备这两者!所以这里我们回到了我们的老朋友函数,它有一个内部方法[[Construct]],它也可以访问 prototype 属性。
Now with Container in place, we can create a new object out of it, as shown in Listing 8-2.let testValue = new Container(3) => Container(value:3) let testObj = new Container({a:1}) => Container(value:{a:1}) let testArray = new Container([1,2]) => Container(value:[1,2]) Listing 8-2
玩容器
Container is just holding the value inside it. We can pass any data type in JavaScript to it and Container will hold it. Before we move on, we can create a util method called of in the Container prototype, which will save us in writing the new keyword to create a new Container. The code looks like Listing 8-3.Container.of = function(value) { return new Container(value); } Listing 8-3
方法定义的
With this of method in place, we can rewrite the code in Listing 8-2 as shown in Listing 8-4.testValue = Container.of(3) => Container(value:3) testObj = Container.of({a:1}) => Container(value:{a:1}) testArray = new Container([1,2]) => Container(value:[1,2]) Listing 8-4
正在创建包含的容器
It is worth noting that Container can contain nested Containers, too.Container.of(Container.of(3)); is going to print:Container { value: Container { value: 3 } }
既然我们已经定义了函子只不过是一个可以容纳值的容器,那么让我们再来看看函子的定义。
Functor 是一个普通的对象(或其他语言中的类型类),它在运行对象中的每个值以产生新对象时实现函数映射。
看起来 functor 需要实现一个名为 map 的方法。让我们在下一节实现这个方法。
实施地图
在我们实现 map 函数之前,让我们在这里暂停一下,想一想为什么我们首先需要 map 函数。请记住,我们创建的容器只保存我们传递给它的值。但是,保存这个值几乎没有任何用处,这就是 map 函数发挥作用的地方。map 函数允许我们对容器当前保存的值调用任何函数。
The map function takes the value out of the Container , applies the passed function on that value, and again puts the result back in the Container. Let’s visualize using the image shown in Figure 8-1. Figure 8-1
容器和映射函数的机制
Figure 8-1 shows the way the map function is going to work with our Container object. It takes the value in the Container—in this case the value is 5—and passes on that value to the passed function double (this function just doubles the given number). The result is put back again into the Container. With that understanding in place, we can implement the map function, as shown in Listing 8-5.Container.prototype.map = function(fn){ return Container.of(fn(this.value)); } Listing 8-5
地图功能定义
As shown earlier, the preceding map function simply does what we have discussed in Figure 8-1. It’s simple and elegant. Now to make the point concrete, let’s put our image piece into code action:let double = (x) => x + x; Container.of(3).map(double) => Container { value: 6 } Note that the map returns the result of the passed function again in the container, which allows us to chain the operation:Container.of(3).map(double) .map(double) .map(double) => Container {value: 24} Now implementing Container with our map function, we can make complete sense of the functor definition:
- Functor 是一个普通的对象(或其他语言中的类型类),它实现了函数映射,当运行对象中的每个值时,会产生一个新的对象。
Or in other words:
- 仿函数是一个实现映射契约的对象。
既然我们已经定义了它,你可能想知道函子对什么有用。我们将在下一节回答这个问题。
注意
函子是一个寻找契约的概念。我们看到的契约很简单,实现 map。我们实现 map 函数的方式提供了不同类型的函子,比如 MayBe 和 any,我们将在本章后面讨论。
可能
我们以如何使用函数式编程技术处理错误和异常的争论开始了这一章。在前一节中,我们学习了函子的基本概念。在这一节,我们将看到一个叫做 MayBe 的型函子。MayBe 函子允许我们以更函数化的方式处理代码中的错误。
实施也许
MayBe is a type of functor, which means it’s going to implement a map function but in a different way. Let’s start with a simple MayBe in Listing 8-6, which can hold the data (very similar to a Container implementation):const MayBe = function(val) { this.value = val; } MayBe.of = function(val) { return new MayBe(val); } Listing 8-6
也许函数定义
We just created MayBe, which resembles the Container implementation. As stated earlier, we have to implement a map contract for the MayBe, which looks like Listing 8-7.MayBe.prototype.isNothing = function() { return (this.value === null || this.value === undefined); }; MayBe.prototype.map = function(fn) { return this.isNothing() ? MayBe.of(null) : MayBe.of(fn(this.value)); }; Listing 8-7
也许是地图功能定义
The map function does very similar things to the Container (simple functor) map function. MayBe’s map first checks whether the value in the container is null or undefined before applying the passed function using the isNothing function , which takes care of null and undefined checks:(this.value === null || this.value === undefined); Note that map puts the result of applying the function back in the container:return this.isNothing() ? Maybe.of(null) : Maybe.of(f(this.__value));
现在是时候看看实际情况了。
简单的用例
As we discussed in the previous section, MayBe checks the null, undefined before applying the passed function in map. This is a very powerful abstraction that takes care of error handling. To make this concrete, a simple example is provided in Listing 8-8.MayBe.of("string").map((x) => x.toUpperCase()) Listing 8-8
创造我们的第一个也许
which returnsMayBe { value: 'STRING' } The most important and interesting point to note here is this:(x) => x.toUpperCase() doesn’t care if x is null or undefined or that it has been abstracted by the MayBe functor. What if the value of the string is null? Then the code looks like this:MayBe.of(null).map((x) => x.toUpperCase()) We will be getting back this:MayBe { value: null }
现在我们的代码不会出现空值或未定义的值,因为我们可能已经将我们的值包装在了类型安全容器中。我们现在以声明的方式处理空值。
注意
在 MayBe.of(null)情况下,如果我们调用 map 函数,从我们的实现中我们知道 map 首先通过调用 isNothing 来检查值是否为 null 或未定义:
//映射的实现
maybe . prototype . map = function(fn){
return this.isNothing() ?maybe . of(null):maybe . of(fn(this . value));
};
如果 isNothing 返回 true。我们返回 MayBe.of(null)而不是调用传递的函数。
In a normal imperative way, we would have done this:let value = "string" if(value != null || value != undefined) return value.toUpperCase(); The preceding code does exactly the same thing, but look at the steps required to check if the value is null or undefined, even for a single call. Also using MayBe, we don’t care about those sneaky variables to hold the resulting value. Remember that we can chain our map function as desired, as shown in Listing 8-9.MayBe.of("George") .map((x) => x.toUpperCase()) .map((x) => "Mr. " + x) Listing 8-9
与地图链接
gives back:MayBe { value: 'Mr. GEORGE' } Before we close this section, we need to talk about two more important properties of MayBe. The first one is that even if your passed function to map returns null/undefined , MayBe can take care of it. In other words, in the whole chain of map calls, it is fine if a function returns null or undefined. To illustrate the point, let’s tweak the last example:MayBe.of("George") .map(() => undefined) .map((x) => "Mr. " + x) Note that our second map function returns undefined ; however, running the preceding code will give this result:MayBe { value: null }
不出所料。
The second important point is that all map functions will be called regardless if they receive null/undefined. We’ll pull out the same code snippet (Listing 8-9) that we used in the previous example:MayBe.of("George") .map(() => undefined) .map((x) => "Mr. " + x) The point here is that even though the first map does return undefined:map(() => undefined)
第二个映射将被称为 always (即链接到任何级别的映射将被称为 always);只是链中的下一个映射函数返回 undefined(就像前一个映射返回 undefined/null 一样),而没有应用传递的函数。重复这个过程,直到链中的最后一个 map 函数调用被求值。
真实世界的用例
Because MayBe is a type of container that can hold any values, it can also hold values of type Array. Imagine you have written an API to get the top 10 SubReddit data based on types like top, new, and hot (see Listing 8-10).let getTopTenSubRedditPosts = (type) => { let response try{ response = JSON.parse(request('GET',"https://www.reddit.com/r/subreddits/" + type + ".json?limit=10").getBody('utf8')) }catch(err) { response = { message: "Something went wrong" , errorCode: err['statusCode'] } } return response } Listing 8-10
获取前 10 个子编辑帖子
注意
请求来自包同步请求。这将允许我们以同步的方式发出请求并获得响应。这只是为了说明;我们不建议在生产中使用同步调用。
The getTopTenSubRedditPosts function just hits the URL and gets the response. If there are any issues in hitting the Reddit API, it sends back a custom response of this format:. . . response = { message: "Something went wrong" , errorCode: err['statusCode'] } . . . If we call our API like this:getTopTenSubRedditPosts('new') we will be getting back the response in this format:{"kind": "Listing", "data": {"modhash": "", "children": [], "after": null, "before": null}} where the children property will have an array of JSON objects. It will look something like this:"{ "kind": "Listing", "data": { "modhash": "", "children": [ { "kind": "t3", "data": { . . . "url": "https://twitter.com/malyw/status/780453672153124864", "title": "ES7 async/await landed in Chrome", . . . } } ], "after": "t3_54lnrd", "before": null } }"
从响应中,我们需要返回包含 URL 和标题的 JSON 对象数组。请记住,如果我们将一个无效的 subreddit 类型(比如 test)传递给 getTopTenSubRedditPosts,它将返回一个没有数据或子属性的错误响应。
With MayBe in place, we can go ahead and implement the logic as shown in Listing 8-11.//arrayUtils from our library import {arrayUtils} from '../lib/es8-functional.js' let getTopTenSubRedditData = (type) => { let response = getTopTenSubRedditPosts(type); return MayBe.of(response).map((arr) => arr['data']) .map((arr) => arr['children']) .map((arr) => arrayUtils.map(arr, (x) => { return { title : x['data'].title, url : x['data'].url } } )) } Listing 8-11
使用 MayBe 获取前 10 个子编辑帖子
Let’s break down how getTopTenSubRedditData works. First we are wrapping the result of the Reddit API call within the MayBe context using MayBe.of(response). Then we are running a series of functions using MayBe’s map :. . . .map((arr) => arr['data']) .map((arr) => arr['children']) . . . This will return the children array object from the response structure:{"kind": "Listing", "data": {"modhash": "", "children": [ . . . .], "after": null, "before": null}} In the last map, we are using our own ArrayUtils’s map to iterate over the children property and return only the title and URL as needed:. . . .map((arr) => arrayUtils.map(arr, (x) => { return { title : x['data'].title, url : x['data'].url } } . . . Now if we call our function with a valid Reddit name like new :getTopTenSubRedditData('new') we get back this response:MayBe { value: [ { title: '/r/UpliftingKhabre - The subreddit for uplifting and positive stories from India!', url: 'https://www.reddit.com/r/ }, { title: '/R/JerkOffToCelebs - The Best Place To Off To Your Fave Celebs', url: 'https://www.reddit.com/r/ }, { title: 'Angel Vivaldi channel', url: 'https://qa1web-portal.immerss.com/angel-vivaldi/angel-vivaldi' }, { title: 'r/test12 - Come check us out for INSANE', url: 'https://www.reddit.com/r/' }, { title: 'r/Just - Come check us out for GREAT', url: 'https://www.reddit.com/r/just/' }, { title: 'r/Just - Come check us out for GREAT', url: 'https://www.reddit.com/r/just/' }, { title: 'How to Get Verified Facebook', url: 'http://imgur.com/VffRnGb' }, { title: '/r/TrollyChromosomes - A support group for those of us whose trollies or streetcars suffer from chronic genetic disorders', url: 'https://www.reddit.com/r/trollychromosomes' }, { title: 'Yemek Tarifleri Eskimeyen Tadlarımız', url: 'http://otantiktad.com/' }, { title: '/r/gettoknowyou is the ultimate socializing subreddit!', url: 'https://www.reddit.com/r/subreddits/comments/50wcju/rgettoknowyou_is_the_ultimate_socializing/' } ] }
注意
读者的反应可能不一样,因为反应会随时改变。
The beauty of the getTopTenSubRedditData method is how it handles unexpected input that can cause null/undefined errors in our logic flow. What if someone calls your getTopTenSubRedditData with a wrong Reddit type? Remember that it will return the JSON response from Reddit:{ message: "Something went wrong" , errorCode: 404 } That is, the data—children property—will be empty. Try this by passing the wrong Reddit type and see how it responds:getTopTenSubRedditData('new') which returns:MayBe { value: null }
没有抛出任何错误。尽管我们的 map 函数试图从响应中获取数据(在本例中不存在),但它返回 MayBe.of(null),因此相应的 map 不会应用传递的函数,正如我们前面讨论的那样。
我们可以清楚地感觉到 may 是如何轻松处理所有未定义/空错误的。我们的 getTopTenSubRedditData 看起来非常具有声明性。
这就是可能函子的全部内容。我们将在下一节中遇到另一个函子,叫做“要么”。
任一函子
In this section we are going to create a new functor called Either, which will allow us to solve the branching-out problem. To provide a context, let’s revisit an example from the previous section (Listing 8-9):MayBe.of("George") .map(() => undefined) .map((x) => "Mr. " + x) This code will return the resultMayBe {value: null}
正如我们所料。然而,问题是哪个分支(即,来自两个较早的 map 调用)因未定义或空值而失败。我们不能简单地用也许来回答这个问题。唯一的办法就是手动挖掘 MayBe 的分支,发现罪魁祸首。这并不意味着 MayBe 有缺陷,只是在某些用例中,我们需要一个比 MayBe 更好的函子(通常是在有许多嵌套映射的情况下)。这就是这两种情况出现的原因。
实施任一
We have seen the problem Either is going to solve for us; now let’s see its implementation (Listing 8-12).const Nothing = function(val) { this.value = val; }; Nothing.of = function(val) { return new Nothing(val); }; Nothing.prototype.map = function(f) { return this; }; const Some = function(val) { this.value = val; }; Some.of = function(val) { return new Some(val); }; Some.prototype.map = function(fn) { return Some.of(fn(this.value)); } Listing 8-12
任一仿函数部分定义
The implementation has two functions, Some and Nothing. You can see that Some is just a copy of a Container with a name change. The interesting part is with Nothing. Nothing is also a Container, but its map doesn’t run over a given function but rather just returns this:Nothing.prototype.map = function(f) { return this; }; In other words, you can run your functions on Some but not on Nothing (not a technical statement, right?). Here’s a quick example:Some.of("test").map((x) => x.toUpperCase()) => Some {value: "TEST"} Nothing.of("test").map((x) => x.toUpperCase()) => Nothing {value: "test"} As shown in the preceding code snippet, calling map on Some runs over the passed function. However, in Nothing, it just returns the same value, test. We wrap these two objects into the Either object as shown in Listing 8-13.const Either = { Some : Some, Nothing: Nothing } Listing 8-13
任一定义
你可能想知道有些或没有的用处是什么。为了理解这一点,让我们重温一下我们的 Reddit 示例版本 MayBe。
任一版本的 Reddit 示例
The MayBe version of the Reddit example looks like this (Listing 8-11):let getTopTenSubRedditData = (type) => { let response = getTopTenSubRedditPosts(type); return MayBe.of(response).map((arr) => arr['data']) .map((arr) => arr['children']) .map((arr) => arrayUtils.map(arr, (x) => { return { title : x['data'].title, url : x['data'].url } } )) } On passing a wrong Reddit type, say, for example, unknown:getTopTenSubRedditData('unknown') => MayBe {value : null} we get back MayBe of null value, but we didn’t know why null was returned. We know that getTopTenSubRedditData uses getTopTenSubRedditPosts to get the response. Now that Either is in place, we can create a new version of getTopTenSubRedditPosts using Either, as shown in Listing 8-14.let getTopTenSubRedditPostsEither = (type) => { let response try{ response = Some.of(JSON.parse(request('GET',"https://www.reddit.com/r/subreddits/" + type + ".json?limit=10").getBody('utf8'))) }catch(err) { response = Nothing.of({ message: "Something went wrong" , errorCode: err['statusCode'] }) } return response } Listing 8-14
使用以下任一方法获取前十个子编辑
Note that we have wrapped the proper response with Some and the error response with Nothing. Now with that in place, we can modify our Reddit API to the code shown in Listing 8-15.let getTopTenSubRedditDataEither = (type) => { let response = getTopTenSubRedditPostsEither(type); return response.map((arr) => arr['data']) .map((arr) => arr['children']) .map((arr) => arrayUtils.map(arr, (x) => { return { title : x['data'].title, url : x['data'].url } } )) } Listing 8-15
使用以下任一方法获取前十个子编辑
这段代码只是字面上的可能版本,但它只是没有使用可能;相反,它使用任何一种类型。
Now let’s call our new API with the wrong Reddit data type:getTopTenSubRedditDataEither('new2') This will returnNothing { value: { message: 'Something went wrong', errorCode: 404 } }
这太棒了。现在,有了这两种类型,我们就可以得到分支失败的确切原因了。可以猜到,getTopTenSubRedditPostsEither 在出现错误的情况下(即未知的 Reddit 类型)不返回任何内容;因此 getTopTenSubRedditDataEither 上的映射也不会发生,因为它的类型是 Nothing。您可以感觉到,在保留错误消息和阻止函数映射方面,什么都没有帮到我们。
On a closing note, we can try our new version with a valid Reddit type:getTopTenSubRedditDataEither('new') It will return the expected response in Some:Some { value: [ { title: '/r/UpliftingKhabre - The subreddit for uplifting and positive stories from India!', url: 'https://www.reddit.com/r/ }, { title: '/R/ - The Best Place To Off To Your Fave, url: 'https://www.reddit.com/r/ }, { title: 'Angel Vivaldi channel', url: 'https://qa1web-portal.immerss.com/angel-vivaldi/angel-vivaldi' }, { title: 'r/test12 - Come check us out for INSANE', url: 'https://www.reddit.com/r/ /' }, { title: 'r/Just - Come check us out for', url: 'https://www.reddit.com/r/just/' }, { title: 'r/Just - Come check us out for', url: 'https://www.reddit.com/r/' }, { title: 'How to Get Verified Facebook', url: 'http://imgur.com/VffRnGb' }, { title: '/r/TrollyChromosomes - A support group for those of us whose trollies or streetcars suffer from chronic genetic disorders', url: 'https://www.reddit.com/r/trollychromosomes' }, { title: 'Yemek Tarifleri Eskimeyen Tadlarımız', url: 'http://otantiktad.com/' }, { title: '/r/gettoknowyou is the ultimate socializing subreddit!', url: 'https://www.reddit.com/r/subreddits/comments/50wcju/rgettoknowyou_is_the_ultimate_socializing/' } ] }
两者都差不多。
注意
如果你来自 Java 背景,你可以感觉到这两者都非常类似于 Java 8 中的 Optional。实际上,Optional 是一个函子。
警告:尖函子
在我们结束这一章之前,我们需要明确一点。在这一章的开始,我们开始说我们创建了 of 方法,只是为了在创建容器的地方转义新的关键字。我们做了同样的事情。回想一下,functor 只是一个具有映射契约的接口。指向函子是函子的子集,它有一个接口,该接口有一个 of contract。
到目前为止,我们所设计的叫做点函子。这只是为了使书中的术语正确,但你要看到函子或尖函子在现实世界中为我们解决了什么问题,哪个更重要。
摘要
我们在本章开始时问了一些关于我们将如何在函数式编程世界中处理异常的问题。我们从创建一个简单的函子开始。我们将函子定义为一个实现了映射函数的容器。然后我们实现了一个叫做 MayBe 的函子。我们看到了 MayBe 如何帮助我们避免讨厌的空/未定义检查。也许允许我们以函数和声明的方式编写代码。然后,我们看到这两者是如何帮助我们在分支时保留错误消息的。两者都只是“有”和“无”的超类型。现在我们已经看到了函子的作用。
九、深入了解单子
在前一章中,我们看到了函子是什么以及它们如何对我们有用。在这一章中,我们将继续讨论函子,学习一个叫做单子的新函子。不要害怕条款;这些概念很容易理解。
我们将从检索和显示搜索查询的 Reddit 评论的问题开始。最初我们打算用函子,尤其是可能函子,来解决这个问题。当我们解决这个问题时,我们会遇到一些关于可能函子的问题。然后我们将继续创建一种特殊类型的函子,称为单子。
注意
章节示例和库源代码在第九章分支中。回购的网址是github.com/antsmartian/functional-es8.git
一旦你检查出代码,请检查分支第九章:
...
git checkout -b 第九章来源/第九章
...
对于运行代码,与之前一样,运行:
...
npm 跑步游乐场
...
为我们的搜索查询获取 Reddit 评论
从上一章开始,我们一直在使用 Reddit API。在本节中,我们使用相同的 Reddit API 来搜索带有查询的帖子,并获得每个搜索结果的评论列表。这个问题我们要用 MayBe 正如我们在前一章看到的,MayBe 允许我们专注于问题,而不用担心空值/未定义值。
注意
你可能想知道为什么我们在当前的问题中不使用任一函子,因为正如我们在上一章中看到的,MayBe 有一些缺点,当分支时不能捕获错误。没错,但是我们选择也许主要是为了让事情简单。正如你所看到的,我们也将把同样的想法扩展到这两者中。
问题
Before we begin implementing the solution, let’s look at the problem and its associated Reddit API endpoints. The problem contains two steps:
-
为了搜索特定的帖子和评论,我们需要点击 Reddit API 端点:
www.reddit.com/search.json?q=<搜索 _ 字符串>
并传递 SEARCH_STRING。例如,如果我们像这样搜索字符串函数式编程:
www.reddit.com/search.json?q =功能编程
我们得到的结果如清单 9-1 所示。
{ kind: 'Listing', data: { facets: {}, modhash: ", children: [ [Object], [Object], [Object], [Object], [Object], [Object], . . . [Object], [Object] ], after: 't3_terth', before: null } } Listing 9-1
Reddit 响应的结构
and each children object looks like this:{ kind: 't3', data: { contest_mode: false, banned_by: null, domain: 'self.compsci', . . . downs: 0, mod_reports: [], archived: true, media_embed: {}, is_self: true, hide_score: false, permalink: '/r/compsci/comments/3mecup/eli5_what_is_functional_programming_and_how_is_it/?ref=search_posts', locked: false, stickied: false, . . . visited: false, num_reports: null, ups: 134 } } These objects specify the results that are matching our search query.
-
一旦我们有了搜索结果,我们需要得到每个搜索结果的评论。我们如何做到这一点?正如前面提到的,每个子对象都是我们的搜索结果。这些对象有一个名为 permalink 的字段,如下所示:
permalink: '/r/compsci/comments/3mecup/eli5_what_is_functional_programming_and_how_is_it/?ref=search_posts', We need to navigate to the preceding URL:GET: https://www.reddit.com//r/compsci/comments/3mecup/eli5_what_is_functional_programming_and_how_is_it/.json That is going to return the array of comments like the following:[Object,Object,..,Object]
其中每个对象给出关于注释的信息。
Once we get the comments object, we need to merge the result with title and return a new object:{ title : Functional programming in plain English, comments : [Object,Object,..,Object] }
其中标题是我们从第一步得到的标题。现在有了对问题的理解,让我们来实现逻辑。
第一步的实施
在本节中,我们实现了第一步的解决方案,这涉及到向 Reddit 搜索 API 端点发出一个请求以及我们的搜索查询。因为我们需要触发 HTTP GET 调用,所以我们将需要我们在前一章中使用的 sync-request 模块。
Let’s pull out the module and hold it in a variable for future use:let request = require('sync-request'); Now with the request function, we could fire the HTTP GET call to our Reddit search API endpoint. Let’s wrap the search steps in a specific function, which we call searchReddit (Listing 9-2).let searchReddit = (search) => { let response try{ response = JSON.parse(request('GET',"https://www.reddit.com/search.json?q=" + encodeURI(search)).getBody('utf8')) }catch(err) { response = { message: "Something went wrong" , errorCode: err['statusCode'] } } return response } Listing 9-2
searchReddit 函数定义
Now we’ll walk through the code in steps.
-
1.We are firing the search request to the URL endpoint
www.reddit.com/search.json?q=
as shown here:response = JSON.parse(request('GET',"https://www.reddit.com/search.json?q=" + encodeURI(search)).getBody('utf8'))注意,我们使用 encodeURI 方法对搜索字符串中的特殊字符进行转义。
-
一旦响应成功,我们就返回该值。
-
3.如果出现错误,我们将在 catch 块中捕获它,并获取错误代码,然后像这样返回错误响应:。。。catch(err) { response = { message:“出错”,错误代码:err['statusCode'] } }。。。
With our little function in place, we go ahead and test it:searchReddit("Functional Programming") This will return the following result:{ kind: 'Listing', data: { facets: {}, modhash: ", children: [ [Object], [Object], [Object], [Object], [Object], [Object], [Object], [Object], . . . after: 't3_terth', before: null } }
太完美了。我们完成了步骤 1。让我们实现第二步。
Implementing the second step for each search children object, we need to get its permalink value to get the list of comments. We can write a separate method for getting a list of comments for the given URL. We call this method getComments . The implementation of getComments is simple, as shown in Listing 9-3.let getComments = (link) => { let response try { response = JSON.parse(request('GET',"https://www.reddit.com/" + link).getBody('utf8')) } catch(err) { response = { message: "Something went wrong" , errorCode: err['statusCode'] } } return response } Listing 9-3
getComments 函数定义
The getComments implementation is very similar to our searchReddit. Let’s walk through the steps and see what getComments does.
-
1.It fires the HTTP GET call for the given link value. For example, if the link value is passed as:r/IAmA/comments/3wyb3m/we_are_the_team_working_on_react_native_ask_us/.json
然后 getComments 将对 URL 发出一个 HTTP GET 调用:
它将返回注释数组。和以前一样,我们在这里有点防御性,在我们最喜欢的 catch 块中捕捉 getComments 方法中的任何错误。最后,我们返回响应。
Quickly we’ll test our getComments by passing the following link value :r/IAmA/comments/3wyb3m/we_are_the_team_working_on_react_native_ask_us/.json getComments('r/IAmA/comments/3wyb3m/we_are_the_team_working_on_react_native_ask_us/.json') For this call we get back this result:[ { kind: 'Listing', data: { modhash: ", children: [Object], after: null, before: null } }, { kind: 'Listing', data: { modhash: ", children: [Object], after: null, before: null } } ]
现在两个 API 都准备好了,是时候合并这些结果了。
合并 Reddit 呼叫
现在我们已经定义了两个函数,即 searchReddit 和 getComments(分别为清单 9-2 和清单 9-3 ,它们执行各自的任务并返回前面章节中看到的响应。在本节中,让我们编写一个更高级的函数,它占用搜索文本并使用这两个函数来实现我们的最终目标。
We’ll call the function we create mergeViaMayBe and its implementation looks like Listing 9-4.let mergeViaMayBe = (searchText) => { let redditMayBe = MayBe.of(searchReddit(searchText)) let ans = redditMayBe .map((arr) => arr['data']) .map((arr) => arr['children']) .map((arr) => arrayUtils.map(arr, (x) => { return { title : x['data'].title, permalink : x['data'].permalink } } )) .map((obj) => arrayUtils.map(obj, (x) => { return { title: x.title, comments: MayBe.of(getComments(x.permalink.replace("?ref=search_posts",".json"))) } })); return ans; } Listing 9-4
mergeViaMayBe 函数定义
Let’s quickly check our function by passing the search text functional programming :mergeViaMayBe('functional programming') That call will give this result:MayBe { value: [ { title: 'ELI5: what is functional programming and how is it different from OOP', comments: [Object] }, { title: 'ELI5 why functional programming seems to be "on the rise" and how it differs from OOP', comments: [Object] } ] }
注意
为了更加清晰,我们减少了该调用输出中的结果数量。默认调用将返回 25 个结果,这将需要几页来输入 mergeViaMayBe 的输出。从这里开始,我们在书中只显示最小的输出。不过,请注意,源代码示例确实调用并打印了所有 25 个结果。
Now let’s step back and understand in detail what the mergeViaMayBe function does. The function first calls the searchReddit with searchText value. The result of the call is wrapped in MayBe :let redditMayBe = MayBe.of(searchReddit(searchText))
一旦结果被包装在一个 MayBe 类型中,我们就可以自由地映射它,正如您在代码中看到的那样。
To remind us of the search query (which our searchReddit will call), it will send back the result in the following structure:{ kind: 'Listing', data: { facets: {}, modhash: ", children: [ [Object], [Object], [Object], [Object], [Object], [Object], . . . [Object], [Object] ], after: 't3_terth', before: null } } To get the permalink (which is in our children object), we need to navigate to data.children. This is demonstrated in the code:redditMayBe .map((arr) => arr['data']) .map((arr) => arr['children']) Now that we have a handle on a children array , remember that each children has an object with the following structure:{ kind: 't3', data: { contest_mode: false, banned_by: null, domain: 'self.compsci', . . . permalink: '/r/compsci/comments/3mecup/eli5_what_is_functional_programming_and_how_is_it/?ref=search_posts', locked: false, stickied: false, . . . visited: false, num_reports: null, ups: 134 } } We need to get only title and permalink out of it; because it’s an array, we run Array’s map function over it:.map((arr) => arrayUtils.map(arr, (x) => { return { title : x['data'].title, permalink : x['data'].permalink } } )) Now that we have both title and permalink, our last step is to take permalink and pass it to our getComments function , which will fetch the list of comments for the passed value. This is seen here in the code:.map((obj) => arrayUtils.map(obj, (x) => { return { title: x.title, comments: MayBe.of(getComments(x.permalink.replace("?ref=search_posts",".json"))) } })); Because the call of getComments can get an error value, we are wrapping it again inside a MayBe :. . . comments: MayBe.of(getComments(x.permalink.replace("?ref=search_posts",".json"))) . . .
注意
我们正在替换永久链接值?ref=search_posts with。json 作为搜索结果追加值?ref=search_posts,这不是 getComments API 调用的正确格式。
在整个过程中,我们没有超出我们的可能类型。我们在 MayBe 类型上愉快地运行所有的地图函数,而不用太担心它。我们用也许很优雅地解决了问题,不是吗?不过,这样使用的 MayBe 函子有一个小问题。下一节再说吧。
嵌套/多地图问题
如果你在 mergeViaMayBe 函数中计算我们 MayBe 上的 map 调用次数,是 4 次。您可能想知道为什么我们关心地图调用的数量。
Let’s try to understand the problem of many chained map calls like in mergeViaMayBe. Imagine we want to get a comments array that is returned from mergeViaMayBe . We’ll pass our search text functional programming in our mergeViaMayBe function:let answer = mergeViaMayBe("functional programming") after the call answer:MayBe { value: [ { title: 'ELI5: what is functional programming and how is it different from OOP', comments: [Object] }, { title: 'ELI5 why functional programming seems to be "on the rise" and how it differs from OOP', comments: [Object] } ] } Now let’s get the comments object for processing. Because the return value is MayBe, we can map over it:answer.map((result) => { //process result. }) The result (which is the value of MayBe) is an array that has title and comments, so let’s map over it using our Array’s map:answer.map((result) => { arrayUtils.map(result,(mergeResults) => { //mergeResults }) }) Each mergeResults is an object, which has title and comments. Remember that comments are also a MayBe. To get comments, therefore, we need to map over our comments:answer.map((result) => { arrayUtils.map(result,(mergeResults) => { mergeResults.comments.map(comment => { //finally got the comment object }) }) })
看起来我们做了更多的工作来获得评论列表。假设有人正在使用我们的 mergeViaMayBe API 来获取评论列表。使用已经显示的嵌套地图返回结果,他们会非常恼火。我们能让我们的合并更好吗?是的,我们可以:满足单子。
通过连接解决问题
在前面的章节中,我们已经看到了,为了得到我们想要的结果,我们必须深入我们的内心。编写这样的 API 对我们没有帮助,反而会激怒其他开发人员。为了解决这些深层问题,让我们在 MayBe 函子中添加 join。
加入实现
Let’s start implementing the join function. The join function is simple and looks like Listing 9-5.MayBe.prototype.join = function() { return this.isNothing() ? MayBe.of(null) : this.value; } Listing 9-5
连接函数定义
join is very simple and it simply returns the value inside our container (if there are values); if not, it returns MayBe.of(null). join is simple, but it helps us to unwrap the nested MayBes:let joinExample = MayBe.of(MayBe.of(5)) => MayBe { value: MayBe { value: 5 } } joinExample.join() => MayBe { value: 5 } As shown in this example, it unwraps the nested structure into a single level. Imagine we want to add 4 to our value in joinExample MayBe . Let’s give it a try:joinExample.map((outsideMayBe) => { return outsideMayBe.map((value) => value + 4) }) This code returns the following:MayBe { value: MayBe { value: 9 } } Even though the value is correct, we have mapped twice to get the result. Again the result that we got ends up in a nested structure. Now let’s do the same via join:joinExample.join().map((v) => v + 4) => MayBe { value: 9 }
这段代码非常优雅。加入的调用返回内部 MayBe,其值为 5;一旦我们有了它,我们就通过 map 运行它,然后加上值 4。现在结果值在一个 flatten 结构中,可能是{ value: 9 }。
Now with join in place, let’s try to level the nested structure returned by mergeViaMayBe. We’ll change the code to Listing 9-6.let mergeViaJoin = (searchText) => { let redditMayBe = MayBe.of(searchReddit(searchText)) let ans = redditMayBe.map((arr) => arr['data']) .map((arr) => arr['children']) .map((arr) => arrayUtils.map(arr, (x) => { return { title : x['data'].title, permalink : x['data'].permalink } } )) .map((obj) => arrayUtils.map(obj, (x) => { return { title: x.title, comments: MayBe.of(getComments(x.permalink.replace("?ref=search_posts",".json"))).join() } })) .join() return ans; } Listing 9-6
合并可能使用连接
如您所见,我们刚刚在代码中添加了两个连接。一个是在注释部分,在那里我们创建了一个嵌套的 MayBe,另一个是在我们的 all map 操作之后。
Now with mergeViaJoin in place, let’s implement the same logic of getting the comments array out of the result. First let’s quickly look at the response returned by mergeViaJoin:mergeViaJoin("functional programming") That is going to return the following:[ { title: 'ELI5: what is functional programming and how is it different from OOP', comments: [ [Object], [Object] ] }, { title: 'ELI5 why functional programming seems to be "on the rise" and how it differs from OOP', comments: [ [Object], [Object] ] } ] Compare that result with our old mergeViaMayBe:MayBe { value: [ { title: 'ELI5: what is functional programming and how is it different from OOP', comments: [Object] }, { title: 'ELI5 why functional programming seems to be "on the rise" and how it differs from OOP', comments: [Object] } ] } As you can see, join has taken out the MayBe’s value and sent it back. Now let’s see how to use the comments array for our processing task. Because the value returned from mergeViaJoin is an array, we can map over it using our Arrays map :arrayUtils.map(result, mergeResult => { //mergeResult }) Now each mergeResult variable directly points to the object that has title and comments. Note that we have called join in our MayBe call of getComments , so the comments object is just a simple array. With that in mind, to get the list of comments from the iteration, we just need to call mergeResult.comments :arrayUtils.map(result,mergeResult => { //mergeResult.comments has the comments array })
这看起来很有希望,因为我们已经得到了我们的 MayBe 的全部好处,也得到一个很好的数据结构来返回结果,这很容易处理。
链式实现
Have a look at the code in Listing 9-6. As you can guess, we need to call join always after map. Let’s wrap this logic inside a method called chain , as shown in Listing 9-7.MayBe.prototype.chain = function(f){ return this.map(f).join() } Listing 9-7
链式函数定义
Once chain is in place, we can make our merge function logic look like Listing 9-8.let mergeViaChain = (searchText) => { let redditMayBe = MayBe.of(searchReddit(searchText)) let ans = redditMayBe.map((arr) => arr['data']) .map((arr) => arr['children']) .map((arr) => arrayUtils.map(arr, (x) => { return { title : x['data'].title, permalink : x['data'].permalink } } )) .chain((obj) => arrayUtils.map(obj, (x) => { return { title: x.title, comments: MayBe.of(getComments(x.permalink.replace("?ref=search_posts",".json"))).join() } })) return ans; } Listing 9-8
合并可能使用链
The output is going to be exactly the same via chain, too. Play around with this function. In fact, with chain in place, we can move the logic of counting the number of comments to an in-place operation, as shown in Listing 9-9.let mergeViaChain = (searchText) => { let redditMayBe = MayBe.of(searchReddit(searchText)) let ans = redditMayBe.map((arr) => arr['data']) .map((arr) => arr['children']) .map((arr) => arrayUtils.map(arr, (x) => { return { title : x['data'].title, permalink : x['data'].permalink } } )) .chain((obj) => arrayUtils.map(obj, (x) => { return { title: x.title, comments: MayBe.of(getComments(x.permalink.replace("?ref=search_posts",".json"))).chain(x => { return x.length }) } })) return ans; } Listing 9-9
对 mergeViaChain 进行改进
Now calling this code:mergeViaChain("functional programming") will return the following:[ { title: 'ELI5: what is functional programming and how is it different from OOP', comments: 2 }, { title: 'ELI5 why functional programming seems to be "on the rise" and how it differs from OOP', comments: 2 } ]
解决方案看起来如此优雅,但我们仍然没有看到一个单子,不是吗?
什么是单子?
你可能想知道为什么我们在这一章开始承诺教你单子,但仍然没有定义单子是什么。我们很抱歉没有定义单子,但你已经看到了它的作用。(什么?)
是的,单子是一个有链式方法的函子;就是这样,单子就是这样。正如您已经看到的,我们已经扩展了我们最喜欢的 MayBe 函子,添加了一个链(当然还有一个连接函数),使它成为一个单子。
我们从一个用函子来解决一个正在进行的问题的例子开始,最终在没有意识到使用它的情况下,用一个单子解决了这个问题。这是我们有意为之的,因为我们想看到单子背后的直觉(它用函子解决的问题)。我们可以从一个简单的单子定义开始,但是尽管这显示了单子是什么,它不会显示为什么应该使用单子。
注意
你可能会对 MayBe 是单子还是函子感到困惑。不要搞混了:也许 with only of 和 map 是一个函子。带链的函子是单子。
摘要
在这一章中,我们已经看到了一个新的函子类型,称为单子。我们讨论了重复映射如何导致嵌套值的问题,这在以后变得难以处理。我们引入了一个名为 chain 的新函数,它有助于扁平化可能的数据。我们看到有链的尖函子叫做单子。在本章中,我们使用第三方库来创建 Ajax 调用。在下一章,我们将看到一种思考异步调用的新方法。
十、生成器的暂停、恢复和异步
我们从函数的简单定义开始阅读这本书,然后我们看到了如何使用函数通过函数式编程技术来做大事。我们已经看到了如何用纯函数的术语来处理数组、对象和错误处理。对我们来说,这是一个相当长的旅程,但是我们仍然没有谈到每个 JavaScript 开发人员都应该知道的另一个重要技术:异步代码。
您已经在项目中处理了大量的异步代码。您可能想知道函数式编程是否能帮助开发人员编写异步代码。答案是肯定的,也是否定的。我们最初要展示的技术是使用 ES6 生成器,然后使用 Async/Await,这是 ECMAScript 2017/ES8 规范的新内容。这两种模式都试图用自己的方式解决同一个回调问题,所以要密切注意细微的差别。发电机是 ES6 中函数的新规格。生成器并不是真正的函数式编程技术;然而,它们是函数的一部分(函数式编程是关于函数的,对吧?);出于这个原因,我们在这本函数式编程书中专门为它写了一章。
即使你是承诺的忠实粉丝(这是一种解决回调问题的技术),我们仍然建议你看一下这一章。您可能会喜欢生成器以及它们解决异步代码问题的方式。
注意
章节示例和库源代码在第十章分支中。回购的网址是github.com/antsmartian/functional-es8.git
。
一旦你检查出代码,请检查分支第十章:
git checkout -b 第十章来源/第十章
对于运行代码,与之前一样,运行:
...npm 跑步游乐场...
异步代码及其问题
在我们真正了解什么是生成器之前,让我们在本节中讨论一下在 JavaScript 中处理异步代码的问题。我们要讨论一个回调地狱问题。大多数异步代码模式,如 Generators 或 Async/Await,都试图以自己的方式解决回调问题。如果您已经知道它是什么,请随意进入下一部分。对于其他人,请继续阅读。
回调地狱
Imagine you have a function like the one shown in Listing 10-1.let sync = () => { //some operation //return data } let sync2 = () => { //some operation //return data } let sync3 = () => { //some operation //return data } Listing 10-1
同步功能
The functions sync, sync1, and sync2 do some operations synchronously and return the results. As a result, one can call these functions like this:result = sync() result2 = sync2() result3 = sync3() What if the operation is asynchronous? Let’s see it in action in Listing 10-2.let async = (fn) => { //some async operation //call the callback with async operation fn(/* result data /) } let async2 = (fn) => { //some async operation //call the callback with async operation fn(/ result data /) } let async3 = (fn) => { //some async operation //call the callback with async operation fn(/ result data */) } Listing 10-2
异步函数
同步与异步
同步是指函数在执行时阻塞调用者,并在结果可用时返回结果。
异步是指函数在执行时不阻塞调用者,而是返回可用的结果。
当我们在项目中处理 AJAX 请求时,我们会大量处理异步。
Now if someone wants to process these functions at once, how they do it? The only way to do it is shown in Listing 10-3.async(function(x){ async2(function(y){ async3(function(z){ ... }); }); }); Listing 10-3
异步函数调用示例
哎呀!你可以在清单 10-3 中看到,我们将许多回调函数传递给我们的异步函数。这段代码展示了什么是回调地狱。回调地狱让程序更难理解。处理错误和从回调中冒泡错误是很棘手的,并且总是容易出错。
在 ES6 到来之前,JavaScript 开发者用承诺来解决这个问题。承诺是伟大的,但鉴于 ES6 在语言层面引入了生成器,我们不再需要承诺了!
发电机 101
如前所述,生成器是 ES6 规范的一部分,它们在语言级别被捆绑在一起。我们讨论了使用生成器来帮助处理异步代码。不过,在这之前,我们要谈谈发电机的基本原理。本节重点解释生成器背后的核心概念。一旦我们学习了基础知识,我们就可以使用生成器创建一个通用函数来处理我们库中的异步代码。我们开始吧。
创建生成器
Let’s start our journey by seeing how to create generators in the first place. Generators are nothing but a function that comes up with its own syntax. A simple generator looks like Listing 10-4.function* gen() { return 'first generator'; } Listing 10-4
第一个简单生成器
The function gen in Listing 10-4 is a generator. As you might notice, we have used an asterisk before our function name (in this case gen) to denote that it is a generator function. We have seen how to create a generator; now let’s see how to invoke a generator:let generatorResult = gen() What will be the result of generatorResult ? Is it going to be a first generator value? Let’s print it on the console and inspect it:console.log(generatorResult) The result will be:gen {[[GeneratorStatus]]: "suspended", [[GeneratorReceiver]]: Window}
发电机的警告
前面的示例显示了如何创建生成器,如何为它创建实例,以及它如何获取值。然而,当我们使用发电机时,有一些重要的事情需要注意。
The first thing is that we cannot call next as many times as we want to get the value from the generator. To make it clearer, let’s try to fetch a value from our first generator (refer to Listing 10-4 for the first generator definition):let generatorResult = gen() //for the first time generatorResult.next().value => 'first generator' //for the second time generatorResult.next().value => undefined
正如您在这段代码中看到的,第二次调用 next 将返回一个未定义的而不是第一个生成器。原因是生成器就像序列:一旦序列的值被消耗,你就不能再消耗它。在我们的例子中,generatorResult 是一个作为第一个生成器有值的序列。通过对 next 的第一次调用,我们(作为生成器的调用方)消耗了序列中的值。因为序列现在是空的,所以第二次调用它将返回未定义的结果。
To consume the sequence again, you need to create another generator instance:let generatorResult = gen() let generatorResult2 = gen() //first sequence generatorResult.next().value => 'first generator' //second sequence generatorResult2.next().value => 'first generator'
这段代码还显示了生成器的不同实例可以处于不同的状态。这里的要点是,每个生成器的状态取决于我们如何调用它的下一个函数。
yield 关键字
With generator functions, there is a new keyword that we can use called yield. In this section, we are going to see how to use yield within a generator function. Let’s start with the code in Listing 10-5.function* generatorSequence() { yield 'first'; yield 'second'; yield 'third'; } Listing 10-5
简单生成器序列
As usual we can create a generator instance for that code:let generatorSequence = generatorSequence(); Now if we call next for the first time we get back the value first:generatorSequence.next().value => first What happens if we call next again? Do we get first? Or second? Or third? Or an error? Let’s find out:generatorSequence.next().value => second We got back the value second. Why? yield makes the generator function pause the execution and send back the result to the caller. Therefore when we call generatorSequence for the first time, the function sees the yield with value first, so it puts the function to pause mode and returns the value (and it remembers where it exactly paused, too). The next time we call the generatorSequence (using the same instance variable), the generator function resumes from where it left off. Because it paused at the line:yield 'first';
第一次,当我们第二次调用它(使用同一个实例变量)时,我们得到值 second。当我们第三次调用它时会发生什么?是的,我们将得到第三个值。
This is better explained by looking at Figure 10-1. This sequence is explained via the code in Listing 10-6.//get generator instance variable let generatorSequenceResult = generatorSequence(); console.log('First time sequence value',generatorSequenceResult.next().value) console.log('Second time sequence value',generatorSequenceResult.next().value) console.log('third time sequence value',generatorSequenceResult.next().value) Listing 10-6
调用我们的生成器序列
Figure 10-1
清单 10-4 中所列发电机的视觉视图
This prints the following back to the console:First time sequence value first Second time sequence value second third time sequence value third
有了这样的理解,你就能明白为什么我们称一个生成器为一系列值了。需要记住的更重要的一点是,所有具有 yield 的生成器都将按照惰性求值顺序执行。
懒惰评估
什么是懒评?简单地说,懒惰评估意味着代码在我们请求它运行之前不会运行。正如您所猜测的,generatorSequence 函数的例子显示了生成器是惰性的。这些值只有在我们需要时才被执行和返回。对发电机太懒惰了,不是吗?
生成器的完成属性
现在我们已经看到了生成器如何使用 yield 关键字生成一系列值。一个生成器也可以产生 n 个数列;作为生成器函数的用户,您将如何知道下一次何时停止调用?因为对已经使用的生成器序列调用 next 将返回未定义的值。你如何处理这种情况?这就是 done 属性进入画面的地方。
Remember that every call to the next function is going to return an object that looks like this:{value: 'value', done: false}
我们知道这个值是来自我们的生成器的值,但是 done 呢?done 是一个属性,它将告诉我们生成器序列是否已被完全使用。
We rerun the code from previous sections here (Listing 10-4), just to print the object being returned from the next call.//get generator instance variable let generatorSequenceResult = generatorSequence(); console.log('done value for the first time',generatorSequenceResult.next()) console.log('done value for the second time',generatorSequenceResult.next()) console.log('done value for the third time',generatorSequenceResult.next()) Listing 10-7
用于理解 done 属性的代码
Running this code will print the following:done value for the first time { value: 'first', done: false } done value for the second time { value: 'second', done: false } done value for the third time { value: 'third', done: false } As you can see we have consumed all the values from the generator sequence, so calling next again will return the following object:console.log(generatorSequenceResult.next()) => { value: undefined, done: true } Now the done property clearly tells us that the generator sequence is already fully consumed. When the done is true, it’s time for us to stop calling next on that particular generator instance. This can be better visualized with Figure 10-2. Figure 10-2
generatorSequence 的生成器完成属性视图
Because generator became the core part of ES6, we have a for loop that will allow us to iterate a generator (after all it’s a sequence):for(let value of generatorSequence()) console.log("for of value of generatorSequence is",value) This is going to print:for of value of generatorSequence is first for of value of generatorSequence is second for of value of generatorSequence is third
特别是使用生成器的 done 属性来遍历它。
将数据传递给生成器
在这一节中,让我们讨论如何将数据传递给生成器。起初,将数据传递给生成器可能会让人感到困惑,但是正如您将在本章中看到的,这使得异步编程变得容易。
Let’s take a look at the code in Listing 10-8.function* sayFullName() { var firstName = yield; var secondName = yield; console.log(firstName + secondName); } Listing 10-8
传递数据生成器示例
This code now might not be a surprise for you. Let’s use this code to explain the concept of passing data to the generator. As always, we create a generator instance first:let fullName = sayFullName() Once the generator instance is created, let’s call next on it:fullName.next() fullName.next('anto') fullName.next('aravinth') => anto aravinth In this code snippet the last call will print anto aravinth to the console. You might be confused with this result, so let’s walk through the code slowly. When we call next for the first time:fullName.next() the code will return and pause at the linevar firstName = yield; Because here we are not sending any value back via yield , next will return the value undefined. The second call to next is where an interesting thing happens:fullName.next('anto') Here we are passing the value anto to the next call. Now the generator will be resumed from its previous paused state. Remember that the previous paused state is on the linevar firstName = yield; Because we have passed the value anto on this call, yield will be replaced by anto and thus firstName holds the value anto. After the value is set to firstName, the execution will be resumed (from the previous paused state) and again sees the yield and stops the execution atvar secondName = yield; Now for the third time, if we call next:fullName.next('aravinth') When this line gets executed, our generator will resume from where it paused. The previous paused state isvar secondName = yield; As before, the passed value aravinth of our next call will be replaced by yield and aravinth is set to secondName. Then the generator happily resumes the execution and sees this statement:console.log(firstName + secondName); By now, firstName is anto and secondName is aravinth, so the console will print anto aravinth. This full process is illustrated in Figure 10-3. Figure 10-3
解释数据如何传递给 sayFullName 生成器
您可能想知道为什么我们需要这样的方法。事实证明,通过向生成器传递数据来使用生成器使它变得非常强大。我们在下一节中使用相同的技术来处理异步调用。
使用生成器处理异步调用
在这一节中,我们将使用真实世界的生成器。我们将看到向生成器传递数据如何使它们在处理异步调用时变得非常强大。在这一部分,我们会玩得很开心。
异步生成器:一个简单的例子
在这一节中,我们将看到如何使用生成器来处理异步代码。因为我们从使用生成器解决异步问题的不同心态开始,所以我们希望事情简单,所以我们将使用 setTimeout 调用来模拟异步调用!
Imagine you two functions shown in Listing 10-9 (which are async in nature).let getDataOne = (cb) => { setTimeout(function(){ //calling the callback cb('dummy data one') }, 1000); } let getDataTwo = (cb) => { setTimeout(function(){ //calling the callback cb('dummy data two') }, 1000); } Listing 10-9
简单的异步函数
Both these functions mimic the async code with setTimeout . Once the desired time has elapsed, setTimeout will call the passed callback cb with value dummy data one and dummy data two, respectively. Let’s see how we will be calling these two functions without generators in the first place:getDataOne((data) => console.log("data received",data)) getDataTwo((data) => console.log("data received",data)) That code will print the following after 1,000 ms:data received dummy data one data received dummy data two
现在,正如你所注意到的,我们通过回调来获得响应。我们已经讨论了异步代码中的回调有多糟糕。让我们用我们的发电机知识来解决当前的问题。我们现在更改 getDataOne 和 getDataTwo 函数,使用生成器实例而不是回调来传递数据。
First let’s change the function getDataOne (Listing 10-8) to what is shown in Listing 10-10.let generator; let getDataOne = () => { setTimeout(function(){ //call the generator and //pass data via next generator.next('dummy data one') }, 1000); } Listing 10-10
将 getDataOne 更改为使用生成器
We have changed the callback line from. . . cb('dummy data one') . . . togenerator.next('dummy data one') That’s a simple change. Note that we have also removed the cb, which is not required in this case. We will do the same for getDataTwo (Listing 10-8), too, as shown in Listing 10-11.let getDataTwo = () => { setTimeout(function(){ //call the generator and //pass data via next generator.next('dummy data two') }, 1000); } Listing 10-11
将 getDataTwo 更改为使用生成器
Now with that change in place, let’s go and test our new code. We’ll wrap our call to getDataOne and getDataTwo inside a separate generator function, as shown in Listing 10-12.function* main() { let dataOne = yield getDataOne(); let dataTwo = yield getDataTwo(); console.log("data one",dataOne) console.log("data two",dataTwo) } Listing 10-12
主发电机功能
Now the main code looks exactly like the sayFullName function from our previous section. Let’s create a generator instance for main and trigger the next call and see what happens.generator = main() generator.next(); That will print the following to the console:data one dummy data one data two dummy data two
这正是我们想要的。看看我们的主代码;代码看起来像对函数 getDataOne 和 getDataTwo 的同步调用。然而,这两个调用都是异步的。请记住,这些调用永远不会阻塞,它们以异步方式工作。让我们总结一下整个过程是如何运作的。
First we are creating a generator instance for main using the generator variable that we declared earlier. Remember that this generator is used by both getDataOne and getDataTwo to push the data to its call, which we will see soon. After creating the instance, we are firing the whole process with the linegenerator.next() This calls the main function . The main function is put into execution and we see the first line with yield:. . . let dataOne = yield getDataOne(); . . .
现在,生成器将进入暂停模式,因为它已经看到了一个 yield 语句。不过,在进入暂停模式之前,它调用了函数 getDataOne。
注意
这里重要的一点是,即使 yield 使语句暂停,它也不会使调用者等待(即调用者没有被阻塞)。为了更具体地说明这一点,请参见下面的代码。
generator . next()//即使生成器因异步代码而暂停
console.log("将被打印")
= >将被打印
= >打印发电机数据结果
这段代码表明,即使我们的 generator.next 使生成器函数等待下一次调用,调用者(调用生成器的那个)也不会被阻塞!如您所见,console.log 将被打印出来(展示 generator.next 没有被阻塞),然后一旦异步操作完成,我们就从生成器中获取数据。
Now interestingly the getDataOne function has the following line in its body:. . . generator.next('dummy data one') . . . As we discussed earlier, calling next by passing a parameter will resume the paused yield, and that’s exactly what happens here in this case. Remember that this piece of line is inside setTimeout , so it will get executed only when 1,000 ms have elapsed. Until then, the code will be paused at the linelet dataOne = yield getDataOne(); One more important point to note here is that while this line is paused, the timeout will be running down from 1,000 to 0. Once it reaches 0, it is going to execute the line. . . generator.next('dummy data one') . . . That is going to send back dummy data one to our yield statement , so the dataOne variable becomes dummy data one://after 1,000 ms dataOne becomes //'dummy data one' let dataOne = yield getDataOne(); => dataOne = 'dummy data one' That’s a lot of interesting stuff happening. Once dataOne is set to the dummy data one value, the execution will continue to the next line:. . . let dataTwo = yield getDataTwo(); . . . This line is going to run the same way as the line before! So after the execution of this line, we have dataOne and dataTwo :dataOne = dummy data one dataTwo = dummy data two That is what is getting printed to the console at the final statements of the main function :. . . console.log("data one",dataOne) console.log("data two",dataTwo) . . . The full process is shown in Figure 10-4. Figure 10-4
解释主生成器内部工作方式的图像
现在,您已经使异步调用看起来像同步调用,但是它以异步方式工作。
异步的生成器:一个真实的例子
在上一节中,我们看到了如何使用生成器有效地处理异步代码。为了模拟异步工作流,我们使用了 setTimeout。在这一节中,我们将使用一个函数来触发对 Reddit APIs 的真正 AJAX 调用,以展示现实世界中生成器的强大功能。
To make an async call, let’s create a function called httpGetAsync , shown in Listing 10-13.let https = require('https'); function httpGetAsync(url,callback) { return https.get(url, function(response) { var body = "; response.on('data', function(d) { body += d; }); response.on('end', function() { let parsed = JSON.parse(body) callback(parsed) }) } ); } Listing 10-13
httpGetAsync 函数定义
这是一个简单的函数,它使用来自一个节点的 https 模块来触发 AJAX 调用以获得响应。
注意
这里我们不打算详细了解 httpGetAsync 函数是如何工作的。我们试图解决的问题是如何转换像 httpGetAsync 这样的函数,它以异步方式工作,但需要一个回调来获得 AJAX 调用的响应。
Let’s check httpGetAsync by passing a Reddit URL:httpGetAsync('https://www.reddit.com/r/pics/.json',(data)=> { console.log(data) }) It works by printing the data to the console. The URL www.reddit.com/r/pics/.json
prints the list of JSON about the Picture Reddit page. The returned JSON has a data key with a structure that looks like the following:{ modhash: ", children: [ { kind: 't3', data: [Object] }, { kind: 't3', data: [Object] }, { kind: 't3', data: [Object] }, . . . { kind: 't3', data: [Object] } ], after: 't3_5bzyli', before: null }
假设我们想要获得数组的第一个子元素的 URL 我们需要导航到 data.children[0].data.url。这会给我们一个类似www . Reddit . com/r/pics/comments/5bqai 9/introducing _ new _ rpics _ title _ guidelines/
的 URL。因为我们需要获得给定 URL 的 JSON 格式,所以我们需要追加。json 到网址,这样就变成了www . Reddit . com/r/pics/comments/5bqai 9/introducing _ new _ rpics _ title _ guidelines/。json
。
Now let’s see that in action:httpGetAsync('https://www.reddit.com/r/pics/.json',(picJson)=> { httpGetAsync(picJson.data.children[0].data.url+".json",(firstPicRedditData) => { console.log(firstPicRedditData) }) })
该代码将根据需要打印数据。我们最不担心被打印的数据,但我们担心我们的代码结构。正如我们在本章开始时看到的,看起来像这样的代码遭受回调地狱。这里有两个层次的回调,这可能不是一个真正的问题,但如果它到了四个或五个嵌套层次呢?你能容易地阅读这样的代码吗?绝对不是。现在让我们看看如何通过发电机解决这个问题。
Let’s wrap httpGetAsync inside a separate method called request, shown in Listing 10-14.function request(url) { httpGetAsync( url, function(response){ generator.next( response ); } ); } Listing 10-14
请求功能
We have removed the callback with the generator’s next call, very similar to our previous section. Now let’s wrap our requirement inside a generator function; again we call it main, as shown in Listing 10-15.function *main() { let picturesJson = yield request( "https://www.reddit.com/r/pics/.json" ); let firstPictureData = yield request(picturesJson.data.children[0].data.url+".json") console.log(firstPictureData) } Listing 10-15
主发电机功能
这个主函数看起来非常类似于我们在清单 10-11 中定义的主函数(唯一的变化是方法调用细节)。在代码中,我们对两个请求调用让步。正如我们在 setTimeout 示例中看到的,在请求时调用 yield 将使它暂停,直到请求通过发送回 AJAX 响应来调用生成器。第一个 yield 会得到图片的 JSON,第二个 yield 通过调用 request 分别得到第一个图片数据。现在我们已经使代码看起来像同步代码,但实际上,它以异步方式工作。
我们也使用生成器逃离了回调地狱。现在代码看起来很干净,清楚地说明了它在做什么。这对我们来说更有力量!
Try running it:generator = main() generator.next()
它将按要求打印数据。我们已经清楚地看到了如何使用生成器将任何期望回调机制的函数转换成基于生成器的函数。反过来,我们得到处理异步操作的干净代码。
ECMAScript 2017 中的异步函数
到目前为止,我们已经看到了多种异步运行函数的方法。最初,执行后台任务的唯一方式是使用回调,但是我们刚刚了解了它们是如何导致回调地狱的。生成器或序列提供了一种使用 yield 操作符和生成器函数解决回调问题的方法。作为 ECMA8 脚本的一部分,引入了两个新的操作符,称为 async 和 await。这两个新操作符通过引入使用 Promise 创作异步代码的现代设计模式,解决了回调地狱问题。
承诺
If you are already aware of Promises you can skip this section. A Promise in JavaScript world is piece of work that is expected to complete (or fail) at some point in the future. For example, parents might Promise to give their child an XBOX if they get an A+ on an upcoming test, as represented by the following code.let grade = "A+"; let examResults = new Promise( function (resolve, reject) { if (grade == "A+") resolve("You will get an XBOX"); else reject("Better luck next time"); } ); Now, the Promise examResults when consumed can be in any of three states: pending, resolved, or rejected. The following code shows a sample consumption of the preceding Promise.let conductExams = () => { examResults .then(x => console.log(x)) // captures resolve and logs "You will get an XBOX" .catch(x => console.error(x)); // captures rejection and logs "Better luck next time" }; conductExams();
现在,如果你已经成功地重新学习了承诺的哲学,我们就能理解 async 和 wait 做什么了。
等待
await 是一个关键字,如果函数返回一个 Promise 对象,可以将它添加到函数的前面,从而使它在后台运行。通常使用一个函数或另一个承诺来消费一个承诺,而 await 通过允许承诺在后台解析来简化代码。换句话说,await 关键字等待承诺解决或失败。一旦承诺得到解决,由承诺返回的数据——无论是已解决的还是被拒绝的——都可以被使用,但同时应用程序的主要流程可以畅通无阻地执行任何其他重要任务。当承诺完成时,剩下的执行就展开了。
异步ˌ非同步(asynchronous)
使用 await 的函数应该标记为 async。
Let us understand the usage of async and await using the following example.function fetchTextByPromise() { return new Promise(resolve => { setTimeout(() => { resolve("es8"); }, 2000); }); } Before ES8 can consume this Promise, you might have to wrap it in a function as shown in the preceding example or use another Promise as shown here.function sayHello() { return new Promise((resolve, reject) => fetchTextByPromise() .then(x => console.log(x)) .catch(x => console.error(x))); } Now, here is a much simpler and cleaner version using async and await.async function sayHello() { const externalFetchedText = await fetchTextByPromise(); console.log(Response from SayHello: Hello, ${externalFetchedText}
); } We can also write using arrow syntax as shown here.let sayHello = async () => { const externalFetchedText = await fetchTextByPromise(); console.log(Response from SayHello: Hello, ${externalFetchedText}
); // Hello, es8 } You can consume this method by simply callingsayHello()
链接回调
在我们看到远程 API 调用的一些示例使用之前,async 和 await 的优点是很难理解的。下面是一个例子,我们调用一个远程 API 来返回一个 JSON 数组。我们静静地等待数组到达并处理第一个对象,然后进行另一个远程 API 调用。这里要学习的重要一点是,当所有这些发生时,主线程可以处理其他事情,因为远程 API 调用可能需要一些时间;因此,网络调用和相应的处理在后台进行。
Here is the function that invokes a remote URL and returns a Promise.// returns a Promise const getAsync = (url) => { return fetch(url) .then(x => x) .catch(x => console.log("Error in getAsync:" + x) ); } The next function consumes getAsync .// 'async' can only be used in functions where 'await' is used async function getAsyncCaller() { try { // https://jsonplaceholder.typicode.com/users is a sample API which returns a JSON Array of dummy users const response = await getAsync("https://jsonplaceholder.typicode.com/users"); // pause until Promise completes const result = await response.json(); //removing .json here demonstrates the error handling in Promises console.log("GetAsync fetched " + result.length + " results"); return result; } catch (error) { await Promise.reject("Error in getAsyncCaller:" + error.message); } } The following code is used to invoke the flow.getAsyncCaller() .then(async (x) => { console.log("Call to GetAsync function completed"); const website = await getAsync("http://" + x[0].website); console.log("The website (http://" + x[0].website + ") content length is " + website.toString().length + " bytes"); }) .catch(x => console.log("Error: " + x)); // Promise.Reject is caught here, the error message can be used to perform custom error handling Here is the output for the preceding invocation: This message is displayed while waiting for async operation to complete, you can do any compute here... GetAsync fetched 10 results Call to GetAsync function completed The website (http://hildegard.org) content length is 17 bytes As you can see, the code execution continues and prints the following console statement, which is the last statement in the program, while the remote API call is happening in the background. Any code following this also gets executed.console.log("This message is displayed while waiting for async operation to complete, you can do any compute here..."); The following result is available when the first await completes; that is, the first API call is completed, and the results are enumerated. This message is displayed while waiting for async operation to complete, you can do any compute here... GetAsync fetched 10 results Call to GetAsync function completed At this point the control returns to the caller, getAsyncCaller in this case, and the call is again awaited by the async call, which makes another remote call using the website property. Once the final API call is completed, the data are returned to the website object and the following block is executed: const website = await getAsync("http://" + x[0].website); console.log("The website (http://" + x[0].website + ") content length is " + website.toString().length + " bytes");
您可以观察到,我们已经异步地进行了相关的远程 API 调用,但是代码看起来是扁平的和可读的,因此调用层次结构可以增长到任何程度,而不涉及任何回调层次结构。
异步调用中的错误处理
As explained earlier, Promises can be rejected as well (say the Remote API is not available or the JSON format is incorrect). In such cases the consumer’s catch block is invoked, which can be used to perform any custom exception handling, as shown here. await Promise.reject("Error in getAsyncCaller:" + error.message); The error can be bubbled to the caller’s catch block as well, as shown next. To simulate an error, remove the .json function getAsyncCaller (read the comments for more details). Also, observe the async usage in the then handler here. Because the dependent remote call uses await the arrow function can be tagged as async.getAsyncCaller() .then(async (x) => { console.log("Call to GetAsync function completed"); const website = await getAsync("http://" + x[0].website); console.log("The website (http://" + x[0].website + ") content length is " + website.toString().length + " bytes"); }) .catch(x => console.log("Error: " + x)); // Promise.Reject is caught here, the error message can be used to perform custom error handling The new asynchronous pattern is more readable, includes less code, is linear, and is better than the previous ones, making it an instinctive replacement for the previous patterns. Figure 10-5 shows the browser support at the time of writing. For latest information, you can check the browser support from caniuse.com/#feat=async-functions
. Figure 10-5
异步浏览器支持。来源:https://caniuse.com/#feat=async-functions
传输到生成器的异步函数
Async and await have an awfully close relationship with generators. In fact, Babel transpiles async and await to generators in the background, which is quite evident if you look at the transpiled code.let sayHello = async () => { const externalFetchedText = await new Promise(resolve => { setTimeout(() => { resolve("es8"); }, 2000)}); console.log(Response from SayHello: Hello, ${externalFetchedText}
); }
例如,前面的 async 函数将被编译成下面的代码,您可以使用任何在线 Babel transpiler,如 babeljs.io
来观看转换。transpiled 代码的详细解释超出了本书的范围,但是您可能会注意到,关键字 async 被转换成了一个名为 _asyncToGenerator 的包装函数(第 3 行)。_asyncToGenerator 是 Babel 添加的一个例程。对于任何使用 async 关键字的代码,这个函数都将被拉入 transpiled 代码中。我们前面代码的关键被转换成一个 switch case 语句(第 41–59 行),其中每一行代码都被转换成一个 case,如下所示。
然而,async/await 和 generators 是在 JavaScript 中创作线性异步函数的两种最突出的方式。决定使用哪一个纯粹是选择的问题。async/await 模式使异步代码看起来像 sync,因此增加了可读性,而生成器对生成器内的状态变化以及调用者和被调用者之间的双向通信提供了更好的控制。
摘要
这个世界充满了 AJAX 调用。曾经在处理 AJAX 调用时,我们需要传递一个回调来处理结果。回调有其自身的局限性。例如,过多的回调会产生回调地狱问题。我们在本章中已经看到了 JavaScript 中的一种类型,叫做 generator。生成器是可以暂停并使用下一个方法恢复的函数。下一个方法适用于所有生成器实例。我们已经看到了如何使用 next 方法将数据传递给生成器实例。向生成器发送数据的技术有助于我们解决异步代码问题。我们已经看到了如何使用生成器使异步代码看起来同步,这对于任何 JavaScript 开发人员来说都是一项非常强大的技术。生成器是解决回调地狱问题的一种方式,但是 ES8 提供了另一种直观的方式来使用 async 和 await 解决相同的问题。新的异步模式由 Babel 等编译器在后台传输到生成器中,并使用 Promise 对象。Async/await 可用于以简单、优雅的方式编写线性异步函数。Await(相当于 generators 中的 yield)可以与任何返回 Promise 对象的函数一起使用,如果一个函数在函数体中的任何地方使用 await,它应该被标记为 async。新模式还简化了错误处理,因为同步和异步代码引发的异常可以用相同的方式处理。
十一、构建一个类似 React 的库
到目前为止,我们已经学会了编写功能性 JavaScript 代码,并体会到它给应用程序带来的模块化、可重用性和简单性。我们已经看到了诸如组合、过滤器、映射、减少等概念,以及其他诸如异步、等待和管道等特性。尽管如此,我们还没有将这些特性结合起来构建一个可重用的库。这是我们在本章将要学习的内容。在这一章中,我们构建了一个完整的库,它将有助于构建应用程序,就像 React 或 HyperApp(hyperapp.js.org
)。本章致力于构建应用程序,而不仅仅是函数。我们将使用到目前为止学到的函数式 JavaScript 编程概念构建两个 HTML 应用程序。我们将学习如何使用中央存储构建应用程序,使用声明性语法呈现用户界面(UI ),以及使用我们的自定义库连接事件。我们将构建一个微型 JavaScript 库,它将能够呈现带有行为的 HTML 应用程序。在下一章中,我们将学习为我们在本章中构建的库编写单元测试。
在开始构建库之前,我们需要理解 JavaScript 中一个非常重要的概念,叫做不变性。
注意
章节示例和库源代码在第十一章分支中。回购的网址是github.com/antsmartian/functional-es8.git
。
一旦你检查出代码,请检查分支第十一章:
git checkout -b 第十一章来源/第十一章
以管理员身份打开命令提示符,导航到包含 package.json 的文件夹,然后运行
npm 安装
下载代码运行所需的包。
不变
JavaScript functions act on data, which are typically stored in variables like strings, arrays, or objects. The state of data is usually defined as the value of the variable at any given point in time. For example:let x = 5; // the state of x is 5 here let y = x; // the state of y is same as that of x y = x * 2; // we are altering the state of y console.log('x = ' + x); // prints: x=5; x is intact, pretty simple console.log('y = ' + y); // prints: y=10 Now consider string data type:let x = 'Hello'; // the state of x is Hello here let y = x; // the state of y is same as x x = x + ' World'; // altering the state of x console.log('x = ' + x); // prints: x = Hello World console.log('y = ' + y); // prints: y = y = Hello ; Value of y is intact So, to conclude JavaScript numbers and strings are immutable. The state of these variable types cannot be altered after it is created. That is not the case with objects and arrays, however. Consider this example:let x = { foo : 'Hello' }; let y = x; // the state of y should be the same as x x.foo += ' World'; // altering the state of x console.log('x = ' + x.foo); // prints: x = Hello World console.log('y = ' + y.foo); // prints: y = Hello World; y is also impacted
JavaScript 对象和数组是可变的,可变对象的状态可以在创建后修改。
注意
这也意味着等式对于可变对象不是一个可靠的操作符,因为在一个地方改变一个值将会更新所有的引用。
Here is an example for arrays.let x = [ 'Red', 'Blue']; let y = x; x.push('Green'); console.log('x = ' + x); // prints [ 'Red', 'Blue', 'Green' ] console.log('y = ' + y); // prints [ 'Red', 'Blue', 'Green' ] If you would like to enforce immutability onto JavaScript objects, it is possible by using Object.freeze . Freeze makes the object read-only. For example, consider this code:let x = { foo : 'Hello' }; let y = x; Object.freeze(x); // y.foo += ' World'; // uncommenting the above line will throw an error, both x and y are made read-only. console.log('x = ' + x.foo); console.log('y = ' + y.foo); To summarize, Table 11-1 differentiates the mutable and immutable types in JavaScript.Table 11-1
JavaScript 中的数据类型
|不可变类型
|
可变类型
|
| --- | --- |
| 数字,字符串 | 对象,数组 |
对于构建可跨项目重用的模块化 JavaScript 库来说,不变性是一个非常重要的概念。应用程序的生命周期由其状态驱动,JavaScript 应用程序主要将状态存储在可变对象中。预测应用程序在任何给定时间点的状态是至关重要的。
在下一节中,我们将构建一个可用作可预测状态容器的库。在这个库中,我们使用了不变性和我们之前学过的各种函数式编程概念。
构建一个简单的 Redux 库
Redux 是一个库,其灵感来自流行的单一应用程序架构,如 Flux、CQRS 和事件源。Redux 帮助您集中应用程序状态,并帮助您构建可预测的状态模式。在理解 Redux 是什么之前,让我们试着理解在少数流行的 JavaScript 框架中状态是如何处理的。让我们以 Angular 为例。Angular 应用依赖于文档对象模型(DOM)来存储状态,数据被绑定到称为视图(或 DOM)的 UI 组件,视图表示模型,反过来模型的变化可以更新视图。当应用程序随着您添加新功能而水平扩展时,预测状态变化的级联效应变得非常具有挑战性。在任何给定的时间点,状态都可能被应用程序或另一个模型中的任何组件更改,这使得确定应用程序状态更改的时间和原因变得非常不可预测。另一方面,React 使用虚拟化的 DOM 工作。给定任何状态,React 应用程序都会创建一个虚拟 DOM,然后可以呈现这个虚拟 DOM。
Redux is a framework-agnostic state library. It can be used with Angular, React, or any other application. Redux is built to address the common problems with application state and how they are influenced by models and views. Redux is inspired by Flux, an application architecture introduced by Facebook. Redux uses a unidirectional flow of data. The following are the design principles of Redux.
-
单一真值来源:应用有一个中心状态。
-
状态只读:称为动作的特殊事件描述状态变化。
-
变化由纯函数产生:动作由 reducer 消耗,reducer 是纯函数,在识别用户动作时可以调用。一次只发生一个变化。
Redux 的关键特征是有一个单一的真理来源(状态)。状态本来就是只读的,所以改变状态的唯一方法是发出一个描述发生了什么的动作。这个动作被 reducer 使用,并创建了一个新状态,这又触发了一个 DOM 更新。这些动作可以被存储和重放,这允许我们做像时间旅行调试这样的事情。如果你仍然困惑,不要担心;继续读下去,当我们开始使用我们到目前为止所学的知识来实现它时,模式会变得更加简单。
Figure 11-1 shows how Redux implements predictable state container. Figure 11-1
状态容器的 Redux 实现
Figure 11-2
使用 redux 库的例子
Redux 的关键组件是 reducers、动作和状态。有了这个背景,让我们开始构建自己的 Redux 库。
注意
我们在这里构建的 Redux 库还不能用于生产;相反,Redux 示例用于展示函数式 JavaScript 编程的强大功能。
Create a new folder for the Redux library and create a new file called redux.js that will host our library. Copy and paste the code from the following sections into this file. You can use any JavaScript editor of your choice; for example, VS Code. The first and most important part of our Redux library is state. Let’s declare a simple state with one property called counter .let initialState = {counter: 0}; The next key ingredient is reducer, the only function that can alter the state. A reducer takes two inputs: the current state and an action that acts on the current state and creates a new state. The following function acts as reducer in our library:function reducer(state, action) { if (action.type === 'INCREMENT') { state = Object.assign({}, state, {counter: state.counter + 1}) } return state; }
在第四章中,我们讨论了 Object.assign 通过合并旧状态来创建新状态的用法。当您想避开可变性时,这种方法非常有用。reducer 函数负责在不改变当前状态的情况下创建新状态。您可以看到我们如何使用 object.assign 来实现这一点:object.assign 用于通过将两个状态合并为一个状态来创建一个新状态,而不会影响 state 对象。
The action is dispatched by a user interaction; in our example it is a simple button click as shown here.document.getElementById('button').addEventListener('click', function() { incrementCounter(); }); When the user clicks a button with Id button the incrementCounter is invoked. Here is the code for incrementCounter:function incrementCounter() { store.dispatch({ type: 'INCREMENT' }); } What is store? store is the main function that encapsulates behaviors that cause the state to change, invokes listeners for state change like UI, and registers listeners for the actions. A default listener in our case is the view renderer. The following function elaborates how a store looks.function createStore(reducer,preloadedState){ let currentReducer = reducer; let currentState = preloadedState; let currentListeners = []; let nextListeners = currentListeners; function getState() { return currentState; } function dispatch(action) { currentState = currentReducer(currentState, action); const listeners = currentListeners = nextListeners; for (let i = 0; i < listeners.length; i++) { const listener = listeners[i]; listener(); } return action; } function subscribe(listener) { nextListeners.push(listener); } return { getState, dispatch, subscribe }; } The following code is our one and only listener that renders the UI when there is a change in state.function render(state) { document.getElementById('counter').textContent = state.counter; } The following code shows how the listener is subscribed using the subscribe method .store.subscribe(function() { render(store.getState()); }); This code is used to bootstrap the application:let store = createStore(reducer, initialState); function loadRedux(){ // Render the initial state render(store.getState()); } It is time to plug our Redux library into an application, create a new file called index.html under the same folder, and paste in the following code.
Chapter 11 - Redux Sample
-
The function loadRedux is invoked on page load. Let us understand the life cycle of our application.-
加载时 : 创建 Redux store 对象,使用 store.subscribe 注册监听器,同时注册 onclick 事件调用 reducer。
-
点击 : 调度程序被调用,它创建一个新的状态并调用监听器。
-
On render : 监听器(render 函数)获取更新后的状态并呈现新的视图。
This cycle continues until the application is unloaded or destroyed. You can either open index.html in a new file or update package.json with the following code (to see the details of the full package.json, check out the branch mentioned at the beginning of the chapter)."scripts": { "playground" : "babel-node functional-playground/play.js --presets es2015-node5", "start" : "open functional-playground/index.html" } To run the application you can run this command, which opens index.html in the browser:npm run start
注意,在 UI 上执行的每个动作都存储在 Redux store 中,这为我们的项目增加了巨大的价值。如果您想知道应用程序当前状态的原因,只需遍历对初始状态执行的所有操作并重放它们;这个特征也被称为时间旅行。这种模式还可以帮助您在任何时间点撤销或重做状态更改。例如,您可能希望用户在 UI 中进行一些更改,但只基于某些验证提交这些更改。如果验证失败,您可以轻松地撤销状态。Redux 也可以和非 UI 应用一起使用;请记住,它是一个具有时间旅行功能的状态容器。如果你想了解更多关于 Redux 的信息,请访问 https://redux.js.org/。
构建一个类似 HyperApp 的框架
Frameworks help reduce development time by allowing us to build on something that already exists and to develop applications within less time. The most common assumption with frameworks is that all the common concerns like caching, garbage collection, state management, and DOM rendering (applicable to UI frameworks only) are addressed. It would be like reinventing the wheel if you start to build an application without any of these frameworks. However, most of the frameworks available in the market to build a single-page UI application suffer from a common problem: bundle size. Table 11-2 provides the gzipped bundle size of most popular modern JavaScript frameworks.Table 11-2
流行 JavaScript 框架的捆绑包大小
|名字
|
大小
|
| --- | --- |
| 角度 1.4.5 | 51K |
| 角度 2 + Rx | 143K |
| React 16.2.0 + React DOM | 31.8K |
| Ember 2.2.0 | 111K |
来源:【https://gist.github.com/Restuta/cda69e50a853aa64912d】T2
Figure 11-3
下图显示了 JSFiddle 编辑器
另一方面,HyperApp 有望成为构建 UI 应用程序可用的最薄的 JavaScript 框架。HyperApp 的 gzip 版本为 1 KB。为什么我们在谈论一个已经建成的图书馆?本节的目的不是介绍或用 HyperApp 构建应用程序。HyperApp 建立在函数式编程概念之上,比如不变性、闭包、高阶函数等等。这是我们学习建立一个类似超级应用程序的库的主要原因。
因为 HyperApp 需要解析 JSX (JavaScript 扩展)语法等等,所以我们将在接下来的章节中学习什么是虚拟 Dom 和 JSX。
虚拟 DOM
DOM is a universally accepted language to represent documents like HTML. Each node in an HTML DOM represents an element in an HTML document. For example:
Hello, Alice
Logged in Date: 16th June 2018
Hello, {{username}}
➔ Component 1Logged in Date: {{dateTime}}
➔ Component 2-
状态不是中心的:应用程序的状态本地存储在组件中,并在组件间传递,导致整体状态及其在任何给定时间点的转换的不确定性。
-
直接 DOM 操作:每次状态改变都会触发一次 DOM 更新,所以在一个页面上有 50 个或更多控件的大型应用程序中,对性能的影响是非常明显的。
为了解决这些问题,我们需要一个能够集中存储和减少 DOM 操作的 JavaScript 框架。在上一节中,我们学习了 Redux,它可以用来构建一个中央可预测状态容器。使用虚拟 DOM 可以减少 DOM 操作。
Virtual DOM is an in-memory representation of DOM using JSON. The DOM operations are done on the in-memory representation before they are applied to the actual DOM. Based on the framework, the representation of DOM varies. The HyperApp library we discussed earlier uses Virtual DOM to detect the changes during state change and only re-creates the delta DOM, which leads to an increase in the overall efficiency of the application. The following is a sample representation of DOM used by HyperApp.{ name: "div", props: { id: "app" }, children: [{ name: "h1", props: null, children: ["Hello, Alice"] }] }
React 框架大量使用虚拟 DOM,它使用 JSX 来表示 DOM。
小艾
JSX is a syntax extension of JavaScript that can be used to represent DOM. Here is an example of JSX:const username = "Alice" const h1 =
Hello, {username}
; //HTML DOM embedded in JS React heavily uses JSX but it can live without it, too. You can put any valid JavaScript expression into the JSX expression like calling a function as shown next.const username = "aliCe"; const h1 =Hello, {toTitleCase(username)}
; let toTitleCase = (str) => { // logic to convert string to title case here }我们将不深究 JSX 的概念;引入 JSX 和虚拟 DOM 的目的是让您熟悉这些概念。要了解更多关于 JSX 的信息,请访问 https://reactjs.org/docs/introducing-jsx.html。
js 提琴手
在前面的所有章节中,我们已经执行了来自开发机器的代码。在本节中,我们将介绍一个名为 JS Fiddle(jsfiddle.net
)的在线代码编辑器和编译器。JS Fiddle 可用于编码、调试和协作基于 HTML、JavaScript 和层叠样式表(CSS)的应用程序。JS Fiddle 包含现成的模板,它支持多种语言、框架和扩展。如果你打算做快速和肮脏的 POCs(概念证明)或者像这本书一样学习一些有趣的东西,JS Fiddle 是最好的工具。它允许您在线保存工作,并在任何地方工作,使我们不再需要为任何语言、编译器和库的新组合建立合适的开发环境。
Let us start building our library by creating a new JS Fiddle. Click Save on the top ribbon anytime you wish you save the code. As shown in Figure 11-4, in the Language drop-down list box, select Babel + JSX. In the Frameworks & Extensions drop-down list box, select No-Library (Pure JS). Selecting the right combination of language and framework is very important for the library to compile. Figure 11-4
下图显示了该代码示例的框架和扩展选择
Our library consists of three main components: state, view, and actions (like HyperApp). The following function acts as a bootstrap for our library. Paste this code into the JavaScript + No-Library (Pure JS) code section.function main() { app({ view: (state, actions) =>
{state.count}
{state.changeText}
{state.count}
{state.changeText}
这由 JSX 解析器读取,并调用 h 函数。
app 函数包含各种子函数,这些子函数将在接下来的章节中解释。这些函数是使用我们已经学过的函数式编程概念构建的。每个函数接受一个输入,对其进行操作,并返回一个新的状态。转换器(即 h 函数)接收标签和属性。该函数由 JSX 解析器调用,通常是在解析 JSX 并将标签和属性作为参数发送时调用。如果我们仔细观察 h 函数,就会发现它使用了基本的函数式编程范例——递归。它以 JavaScript 数据类型递归构建 DOM 的树结构。
For example, calling h('buttons', props) where props is an object carrying other properties attached to the tag like onclick function , the function h would return a JSON equivalent as shown here.{ children:["Increase"] generatedId:1 props:{onclick: ƒ} tag:"button" }
创建操作
The createActions function creates an array of functions, one each for action. The actions object is passed in as a parameter as shown earlier. Notice the usage of Object.Keys, closures, and the map function here. Each object within the actions array is a function that can be identified by its name. Each such function has access to the parent’s variable scope (withActions), a closure. The closure when executed retains the values in the parent scope even though the function createAction has exited the execution context. The name of the function here in our example is up, down, and changeText.function createActions(actions,withActions){ Object.keys(withActions || {}).map(function(name){ return actions[name] = function(data) { data = withActions[name]; update(data) } }) return actions } Figure 11-5 is a sample of how the actions object looks during runtime. Figure 11-5
运行时的操作对象
Figure 11-6
下图显示了运行时子对象的状态
提出
The render function is responsible for replacing the old DOM with the new DOM. function render() { let doc = patch(node,(node = appView(appState,appActions))) if(doc) { let children = document.body.children; for(let i = 0; i <= children.length; i++){ removeElement(document.body, children[i], children[i]) } document.body.appendChild(doc); } }
修补
patch 函数负责在递归中创建 HTML 节点;例如,当 patch 接收虚拟 DOM 对象时,它递归地创建节点的 HTML 等价物。
function patch(node,newNode) { if (typeof newNode === "string") { let element = document.createTextNode(newNode) } else { let element = document.createElement(newNode.tag); for (let i = 0; i < newNode.children.length; ) { element.appendChild(patch(node,newNode.children[i++])) } for (let i in newNode.props) { element[i] = newNode.props[i] } element.setAttribute("id",newNode.props.id != undefined ? newNode.props.id : newNode.generatedId); } return element; } }
更新
The update function is a higher order function responsible for updating the old state with a new state and rerendering the application. The update function is invoked when the user invokes an action like clicking any of the buttons shown in Figure 11-7. Figure 11-7
下图显示了该示例的最终用户界面
更新函数接收一个函数作为参数;例如,up、down 或 changeText,这使它成为一个高阶函数。这给了我们向应用程序添加动态行为的好处。怎么做?更新函数直到运行时才知道状态参数,这使得应用程序的行为在运行时根据传递的参数来决定。如果通过了 up,则状态递增;如果向下传递,则递减。用更少的代码实现如此多的功能,这就是函数式编程的强大之处。
The current state of the application is passed on to your actions (example, up, down). Actions fundamentally follows the functional paradigm by returning a new state altogether. (Yes, HyperApp strictly follows the concepts of Redux, which in turn is fundamentally based on functional programming concepts.) This is done by the merge function. Once we get a new state, we will call the render function, as shown here.function update(withState) { withState = withState(appState) if(merge(appState,withState)){ appState = merge(appState,withState) render(); } }
合并
The merge function is a simple function that ensures the new state is merged with the old state.function merge(target, source) { let result = {} for (let i in target) { result[i] = target[i] } for (let i in source) { result[i] = source[i] } return result } As you can see, where the state is altered, a new state that contains the old state and the state that has changed is created and altered. For example, if you invoke the Increase action, the merge ensures only the count property is updated. If you look closely, the merge function very closely resembles what Object.assign does; that is, it creates a new state from any given state by not affecting the given states. Hence we can also rewrite the merge function as shown here.function merge(target, source) { let result = {} Object.assign(result, target, source) return result }
这就是 ES8 语法的强大之处。
移动
The following functions are used to remove the children from the real DOM.// remove element function removeElement(parent, element, node) { function done() { parent.removeChild(removeChildren(element, node)) } let cb = node.attributes && node.attributes.onremove if (cb) { cb(element, done) } else { done() } } // remove children recursively function removeChildren(element, node) { let attributes = node.attributes if (attributes) { for (let i = 0; i < node.children.length; i++) { removeChildren(element.childNodes[i], node.children[i]) } } return element } The UI of the application looks like Figure 11-8. Increase, Decrease, and ChangeText are the actions, the number is 5, and Date is the state. Figure 11-8
下图显示了该示例的最终用户界面
库的源代码可以在 checkout 分支的 hyperapp.js 下找到。您可以将它复制粘贴到一个新的 JS Fiddle 中来创建应用程序(记住要选择前面解释过的正确语言)。你也可以在jsfiddle.net/vishwanathsrikanth/akhbj9r8/70/
从我的 JS 小提琴上拿叉子。
这样,我们就完成了第二个图书馆的建设。显然,我们的库比 1 KB 小得多,但它能够构建交互式 web 应用程序。我们构建的两个库都只基于函数。所有这些函数只作用于输入,而不是全局状态。函数使用类似高阶函数的概念,使系统更容易维护。我们看到每个函数如何按时接收输入,并只处理该输入,返回新的状态或函数。我们重用了许多高阶函数,比如 map、each、assign 等等。这显示了如何在我们的代码库中重用定义良好的函数。
此外,这两个代码都取自 Redux 和 HyperApp(当然有所调整),但是您可以看到只要遵循函数概念就可以构建出多么受欢迎的库。归根结底都是关于功能的!
尝试使用本书中解释的函数式 JavaScript 概念来构建更多这样的库。
摘要
在这一章中,我们学习了使用函数式 JavaScript 概念来构建一个库。我们已经了解了分布式状态如何随着时间的推移破坏应用程序的可维护性和可预测性,以及类似 Redux 的框架如何帮助我们集中状态。Redux 是一个状态容器,具有集中的只读状态;状态更改仅允许还原器通过传递操作和旧状态来进行。我们还使用函数式 JavaScript 概念构建了一个类似 Redux 的库和一个 HTML 应用程序。我们学习了虚拟 DOM 以及它如何帮助减少 DOM 操作,以及可用于在 JavaScript 文件中表示 DOM 的 JSX 语法。JSX 和虚拟 DOM 概念用于构建 HyperApp 这样的库,HyperApp 是可用于构建单页面应用程序的最薄的库。
十二、测试和最后的想法
所有的代码都是有罪的,直到被证明是无辜的。
—匿名
我们已经介绍了围绕函数式 JavaScript 的大部分概念。我们已经学习了 ES8 规范中的基础知识、先进理念和最新概念。我们的学习完成了吗?我们能断言我们已经写出了可行的代码吗?没有;除非代码经过测试,否则没有代码是完整的。
在这最后一章中,我们将学习为我们已经编写的功能性 JavaScript 代码编写测试。我们将学习使用业界最好的测试框架和编码模式来创作灵活、易于学习的自动化测试。本章讨论的模式和实践可以用来测试所有可能场景的任何功能代码。我们还将学习测试使用高级 JavaScript 的代码,比如 Promises 和异步方法。本章的剩余部分涉及使用各种工具来运行测试,报告测试状态,计算代码覆盖率,以及应用林挺来实施更好的编码标准。最后,我们总结了第二版的一些结论性想法。
注意
章节示例和库源代码在第十二章。回购的网址是github.com/antsmartian/functional-es8.git
。
一旦你检查出代码,请检查分支第十二章:
git checkout -b 第十二章来源/第十二章
以管理员身份打开命令提示符,导航到包含 package.json 的文件夹,然后运行
npm 安装
下载代码运行所需的包。
介绍
每个开发人员都应该知道,编写测试用例是证明代码运行并确保没有错误路径的唯一方法。测试有很多种——单元测试、集成测试、性能测试、安全/渗透测试等等——每一种都满足代码的某些标准。编写哪些测试完全取决于功能和功能的优先级:这完全取决于投资回报(ROI)。您的测试应该回答这些问题:这个功能对应用程序重要吗?如果我写这个测试,我能证明这个功能工作吗?应用程序的核心功能包含在前面提到的所有测试中,而很少使用的功能可能只需要单元和集成测试。宣扬单元测试并不是本节的主旨。相反,我们将学习在当前 DevOps 场景中创作自动化单元测试的重要性。
DevOps (Development + Operations) is a set of processes, people, and tools together used to define and ensure continuous frictionless delivery of software applications. Now where does testing fit into this model? The answer lies within continuous testing. Every high-performing Agile team with a DevOps delivery model should ensure they follow practices like continuous integration, testing, and delivery. In simple terms, every code check-in done by a developer is integrated into the one single repository, all the tests are run automatically, and the latest code is deployed automatically (provided the tests’ passing criteria are met) to a staging environment. Having a flexible, reliable, and fast delivery pipeline is the key to success for the most successful companies as shown in Table 12-1.Table 12-1
成功公司的交付渠道
|组织
|
部署
|
| --- | --- |
| 脸谱网 | 每天 2 次部署 |
| 亚马孙 | 每 11.6 秒部署一次 |
| 网飞 | 每天 1000 次 |
资料来源:维基百科。
假设您是使用 Node 构建应用程序的敏捷团队的一员,您已经使用本书中解释的最佳实践编写了大量代码,现在您也有责任为您的代码编写测试,以便它达到可接受的代码覆盖率和通过标准。本章的目的是教你如何为 JavaScript 函数编写测试。
Figure 12-1 shows where the continuous testing phase sits in the overall application life cycle. Figure 12-1
应用程序生命周期的持续测试阶段
测试类型
The following are the most important categories of tests.
-
单元测试 : 编写单元测试是为了孤立地测试每一个功能。这将是本章的主要焦点。单元测试通过提供输入并确保输出符合预期来测试单个功能。单元测试模仿依赖行为。本章后面会有更多关于嘲讽的内容。
-
集成测试 : 集成测试是为了测试端到端的功能而编写的。例如,对于一个用户注册场景,这个测试可能会在数据存储中创建一个用户,并确保它存在。
-
UI(功能测试) : UI 测试是针对 web 应用的;编写这些测试是为了控制浏览器和实现用户旅程。
其他类型的测试包括冒烟测试、回归测试、验收测试、系统测试、飞行前测试、渗透测试和性能测试。有各种框架可用于编写这些类别的测试,但是对这些测试类型的解释超出了本书的范围。本章只讨论单元测试。
BDD 和 TDD
在我们深入研究 JavaScript 测试框架之前,让我们简单介绍一下最著名的测试开发方法,行为驱动开发(BDD)和测试驱动开发(TDD)。
BDD suggests testing the behavior of the function instead of its implementation. For example, consider the following function that just increments a given number by 1.var mathLibrary = new MathLibrary(); var result = mathLibrary.increment(10) BDD advises the test to be written as shown next. Although this looks like a simple unit test, there is a subtle difference. Here we are not worried about the implementation logic (like the initial value of Sum).var expectedValue = mathlibrary.seed + 10; // imagine seed is a property of MathLibrary Assert.equal(result, expectedValue);
断言是帮助我们对照期望值验证实际值的函数,反之亦然。在这里,我们不担心实现细节;相反,我们断言该函数的行为,即将值递增 1。如果种子的值明天改变,我们不必更新函数。
注意
Assert 是大多数测试框架中术语的一部分。它主要用于以各种方式比较预期值和实际值。
TDD suggests you write the test first. For example, in the current scenario we write the following test first. Of course it would fail because there is no MathLibrary or its corresponding function called increment.Assert.equal(MathLibrary.increment(10), 11);
TDD 背后的思想是首先编写满足功能需求的断言,这些断言最初会失败。通过进行必要的修改(编写代码)来通过测试,开发就取得了进展。
JavaScript 测试框架
JavaScript being a vastly adapted language for writing functional code, there are numerous test frameworks available, including Mocha, Jest (by Facebook), Jasmine, and Cucumber, to name a few. The most famous among them are Mocha and Jasmine. To write a unit test for JavaScript functions we need the libraries or tools that can cover the following basic needs.
-
测试结构,它定义了文件夹结构、文件名和相应的配置。
-
断言函数,一个可以用来灵活断言的库。
-
Reporter,一个以控制台、HTML、JSON 或 XML 等各种格式显示结果的框架。
-
Mocks,一个可以提供测试替身来伪造依赖组件的框架。
-
代码覆盖率,所以框架应该能够清楚地说出测试覆盖的行数或函数数。
不幸的是,没有一个测试框架提供所有这些功能。例如,Mocha 没有断言库。幸运的是,像 Mocha 和 Jasmine 这样的大多数框架都是可扩展的;我们可以使用 Babel 的断言库或带有 Mocha 的 expect.js 来执行干净的断言。在 Mocha 和 Jasmine 之间,我们将编写 Mocha 测试,因为我们觉得它比 Jasmine 更灵活。当然,在这一节的最后,我们还会看到 Jasmine 测试的一瞥。
注意
在撰写本文时,Jasmine 不支持对 ES8 特性的测试,这也是偏向 Mocha 的原因之一。
使用摩卡测试
以下部分解释了如何为创作测试设置 Mocha,以及用模拟创作同步和异步测试的本质。我们开始吧。
装置
mocha(mochajs.org
)是一个社区支持的、功能丰富的 JavaScript 测试框架,可以在 Node.js 和浏览器上运行。Mocha 自诩让异步测试变得简单有趣,这一点我们一会儿就能见证。
Install Mocha globally and for the development environment as shown here.npm install –global mocha npm install –save-dev mocha Add a new folder called test and add a new file within the test folder called mocha-tests.js . The following is the updated file structure.| functional-playground |------play.js | lib |------es8-functional.js | test | -----mocha-tests.js
简单摩卡测试
Add the following simple Mocha test to mocha-tests.js .var assert = require('assert'); describe('Array', function () { describe('#indexOf()', function () { it('should return -1 when the value is not present', function () { assert.equal(-1, [1, 2, 3].indexOf(4)); }); }); }); Let’s understand this bit by bit. The first line of code is required to import the Babel assertion library. As mentioned earlier, Mocha doesn’t have an out-of-the-box assertion library so this line is required. You can also use any other assertion library like expect.js, chai.js, should.js, or many more.var assert = require('assert'); Mocha tests are hierarchical in nature. The first describe function shown earlier describes the first test category 'Array'. Each primary category can have multiple describes, like '#indexOf'. Here '#indexOf' is a subcategory that contains the tests related to the indexOf function of the array. The actual test starts with the it keyword. The first parameter of the it function should always describe the expected behavior (Mocha uses BDD).it('should return -1 when the value is not present', function(){})
一个子类别中可以有多个 it 职能。以下代码用于断言预期值与实际值。在一个测试用例中也可以有多个断言(这里的 it 功能是一个测试用例)。默认情况下,在多次断言的情况下,测试在第一次失败时停止,但是这种行为是可以改变的。
The following code is added to package.json for running the Mocha tests. Also check the dev dependencies and dependencies section when you check out the branch to understand the support libraries that are pulled in."mocha": "mocha --compilers js:babel-core/register --require babel-polyfill", The switches –compilers and –require here are optional; in this case they are used to compile ES8 code. Running the following command runs the tests.npm run mocha Figure 12-2 shows a sample response. Figure 12-2
开关响应示例
观察测试结果呈现的方式。数组是层次结构中的第一级,后面是#indexOf,然后是实际的测试结果。上面的语句 1 通过显示了测试的总结。
Currying、Monads 和 Functors 的测试
我们已经学习了很多函数式编程的概念,比如 currying、函子和单子。在这一节中,我们将学习为我们之前学过的概念编写测试。
Let’s start by authoring unit tests for currying, the process of converting a function with n number of arguments into a nested unary function. Well, that’s the formal definition, but it will probably not help us author unit tests. Authoring unit tests for any function is quite easy. The first step is to list its primary feature set. Here we are referring to the curryN function we wrote in Chapter 6. Let’s define its behavior
-
CurryN 应该总是返回一个函数。
-
CurryN 应该只接受函数,传递任何其他值都应该抛出错误。
-
当使用相同数量的参数调用时,CurryN 函数应该返回与普通函数相同的值。
Now, let us start writing tests for these features.it("should return a function", function(){ let add = function(){} assert.equal(typeof curryN(add), 'function'); }); This test will assert if curryN always returns a function object.it("should throw if a function is not provided", function(){ assert.throws(curryN, Error); }); This test will ensure that curryN throws Error when a function is not passed.it("calling curried function and original function with same arguments should return the same value", function(){ let multiply = (x,y,z) => x * y * z; let curriedMultiply = curryN(multiply); assert.equal(curriedMultiply(1,2,3), multiply(1,2,3)); assert.equal(curriedMultiply(1)(2)(3), multiply(1,2,3)); assert.equal(curriedMultiply(1)(2,3), multiply(1,2,3)); curriedMultiply = curryN(multiply)(2); assert.equal(curriedMultiply(1,3), multiply(1,2,3)); }); The preceding test can be used to test the basic functionality of a curried function. Now let’s write some tests for functors. Before that, like we did for currying, let’s review the features of a functor.
-
函子是保存值的容器。
-
函子是实现函数映射的普通对象。
-
像 MayBe 这样的函子应该处理 null 或 undefined。
-
像 MayBe 这样的仿函数应该链。
Now, based on how we defined the functor let’s see some tests.it("should store the value", function(){ let testValue = new Container(3); assert.equal(testValue.value, 3); }); This test asserts that a functor like container holds a value. Now, how do you test if the functor implements map? There are couple of ways: You can assert on the prototype or call the function and expect a correct value, as shown here.it("should implement map", function(){ let double = (x) => x + x; assert.equal(typeof Container.of(3).map == 'function', true) let testValue = Container.of(3).map(double).map(double); assert.equal(testValue.value, 12); }); The following tests assert if the function handles null and is capable of chaining.it("may be should handle null", function(){ let upperCase = (x) => x.toUpperCase(); let testValue = MayBe.of(null).map(upperCase); assert.equal(testValue.value, null); }); it("may be should chain", function(){ let upperCase = (x) => x.toUpperCase(); let testValue = MayBe.of("Chris").map(upperCase).map((x) => "Mr." + x); assert.equal(testValue.value, "Mr.CHRIS"); }); Now, with this approach it should be easy to write tests for monads. Where do you start? Here is a little help: Let’s see if you can author tests for the following rules by yourself.
-
单子应该实现 join。
-
单子应该实现 chain。
-
单子应该去掉嵌套。
如果你需要帮助,请查看 GitHub 网址的第十二章分支。
测试函数库
We have authored many functions in the es-functional.js library and used play.js to execute them. In this section we learn how to author tests for the functional JavaScript code we have written so far. Like play.js, before using the functions they should be imported in the file mocha-tests.js, so add the following line to the mocha-tests.js file.import { forEach, Sum } from "../lib/es8-functional.js"; The following code shows the Mocha tests written for JavaScript functions.describe('es8-functional', function () { describe('Array', function () { it('Foreach should double the elements of Array, when double function is passed', function () { var array = [1, 2, 3]; const doublefn = (data) => data * 2; forEach(array, doublefn); assert.equal(array[0], 1) }); it('Sum should sum up elements of array', function () { var array = [1, 2, 3]; assert.equal(Sum(array), 6) }); it('Sum should sum up elements of array including negative values', function () { var array = [1, 2, 3, -1]; assert.notEqual(Sum(array), 6) }); });
用 Mocha 进行异步测试
Surprise, surprise! Mocha also supports async and await, and it is suprisingly simple to test Promises or async functions as shown here. describe('Promise/Async', function () { it('Promise should return es8', async function (done) { done(); var result = await fetchTextByPromise(); assert.equal(result, 'es8'); }) }); Notice the call to done here. Without the call to the done function, the test will time out because it does not wait for 2 s as required by our promise. The done function here notifies the Mocha framework. Run the tests again using the following command.npm run mocha The results are shown in Figure 12-3. Figure 12-3
下图显示了测试结果
重申一下开头的陈述,Mocha 最初可能很难建立,因为它固有的灵活性坚持了这样一个事实,即它几乎可以与任何用于编写优秀单元测试的框架很好地结合在一起,但是最终,回报是丰厚的。
用西农嘲讽
假设你是团队 A 的一部分,团队 A 是一个大的敏捷团队的一部分,这个团队被划分成更小的团队,比如团队 A、团队 B 和团队 c。更大的敏捷团队通常被业务需求或地理区域所划分。假设团队 B 使用团队 C 的库,团队 A 使用团队 B 的函数库,每个团队都应该提交经过全面测试的代码。作为团队 A 的开发人员,在使用团队 B 的功能时,您会再次编写测试吗?不。那么当你依赖于调用团队 B 的函数时,你如何确保你的代码工作?这就是嘲讽图书馆的由来,Sinon 就是这样一个图书馆。如前所述,Mocha 没有开箱即用的嘲讽库,但它与 Sinon 无缝集成。
sinon(Sinonjs.org)是一个独立的框架,为 JavaScript 提供间谍、存根和模仿。Sinon 可以轻松地与任何测试框架集成。
注意
间谍、模拟或存根,虽然它们解决了类似的问题,听起来也相关,但有一些微妙的区别,理解起来很重要。我们建议更详细地了解假货、仿制品和存根之间的区别。本节仅提供摘要。
A fake imitates any JavaScript object like a function or object. Consider the following function.var testObject= {}; testObject.doSomethingTo10 = (func) => { const x = 10; return func(x); } This code takes a function and runs it on constant 10. The following code shows how to test this function using Sinon fakes. it("doSomethingTo10", function () { const fakeFunction = sinon.fake(); testObject.doSomethingTo10(fakeFunction); assert.equal(fakeFunction.called, true); });
如你所见,我们没有创建一个实际的函数来作用于 10;相反,我们伪造了一个函数。断言 fake 是很重要的,因此 assert . equal(fake function . called,true)语句确保调用 fake 函数,它断言函数 doSomethingTo10 的行为。Sinon 在测试函数的上下文中提供了更全面的方法来测试 fake 的行为。有关更多详细信息,请参见文档。
Consider this function:testObject.tenTimes = (x) => 10 * x; The following code shows a test case written using Sinon’s stub. As you notice, a stub can be used to define the behavior of the function. it("10 Times", function () { const fakeFunction = sinon.stub(testObject, "tenTimes"); fakeFunction.withArgs(10).returns(10); var result = testObject.tenTimes(10); assert.equal(result, 10); assert.notEqual(result, 0); });
更常见的是,我们编写与外部依赖项交互的代码,如 HTTP 调用。如前所述,单元测试是轻量级的,应该模拟外部依赖,在本例中是 HTTP 调用。
Let’s say we have the following functions:var httpLibrary = {}; function httpGetAsync(url,callback) { // HTTP Get Call to external dependency } httpLibrary.httpGetAsync = httpGetAsync; httpLibrary.getAsyncCaller = function (url, callback) { try { const response = httpLibrary.httpGetAsync(url, function (response) { if (response.length > 0) { for (let i = 0; i < response.length; i++) { httpLibrary.usernames += response[i].username + ","; } callback(httpLibrary.usernames) } }); } catch (error) { throw error } } If you would like to test only getAsyncCaller without getting into the nitty-gritty of httpGetAsync (let’s say it is developed by Team B), we can use Sinon mocks as shown here. it("Mock HTTP Call", function () { const getAsyncMock = sinon.mock(httpLibrary); getAsyncMock.expects("httpGetAsync").once().returns(null); httpLibrary.getAsyncCaller("", (usernames) => console.log(usernames)); getAsyncMock.verify(); getAsyncMock.restore(); }); This test case makes sure while testing getAsyncCaller , httpGetAsync is mocked. The following test case tests the same method without using mock. it("HTTP Call", function () { httpLibrary.getAsyncCaller("https://jsonplaceholder.typicode.com/users"); });
在结束编写函数式 JavaScript 代码的测试之前,让我展示一下如何使用 Jasmine 编写测试。
用 Jasmine 测试
Jasmine ( jasmine.github.io
) is also a famous testing framework; in fact, the APIs of Jasmine and Mocha are similar. Jasmine is the most widely used framework when building applications with AngularJS (or Angular). Unlike Mocha, Jasmine comes with a built-in assertion library. The only troublesome area with Jasmine at the point of writing was testing asynchronous code. Let’s learn to set up Jasmine in our code in the next few steps.npm install –save-dev jasmine If you intend to install it globally, run this command:npm install -g jasmine Jasmine dictates a test structure including a configuration file, so running the following command will set up the test’s structure../node_modules/.bin/jasmine init That command creates the following folder structure:|-Spec |-----Support |---------jasmine.json (Jasmine configuration file)
Jasmine.json 包含测试配置;例如,spec_dir 用于指定查找 Jasmine 测试的文件夹,而 spec_files 描述了用于识别测试文件的公共关键字。更多配置详情,请访问jasmine . github . io/2.3/node . html # section-Configuration
。
让我们在用 init 命令创建的 spec 文件夹中创建一个 Jasmine 测试文件,并将该文件命名为 jasmine-tests-spec.js。(记住,如果没有关键字 spec,Jasmine 将无法找到我们的测试文件。)
The following code shows a sample Jasmine test.import { forEach, Sum, fetchTextByPromise } from "../lib/es8-functional.js"; import 'babel-polyfill'; describe('Array', function () { describe('#indexOf()', function () { it('should return -1 when the value is not present', function () { expect([1, 2, 3].indexOf(4)).toBe(-1); }); }); }); describe('es8-functional', function () { describe('Array', function () { it('Foreach should double the elements of Array, when double function is passed', function () { var array = [1, 2, 3]; const doublefn = (data) => data * 2; forEach(array, doublefn); expect(array[0]).toBe(1) }); });
如您所见,除了断言之外,代码看起来非常类似于 Mocha 测试。您可以完全使用 Jasmine 来重建测试库,我们让您来决定如何去做。
The following command is added to package.json to execute Jasmine tests."jasmine": "jasmine" Running the following command executes the tests:npm run jasmine Figure 12-4
下图显示了使用 Jasmine 的测试结果
代码覆盖率
我们有多确定测试已经覆盖了关键领域?对于任何语言来说,代码覆盖率是唯一能够解释测试所覆盖的代码的度量。JavaScript 也不例外,因为我们可以获得测试覆盖的代码行数或百分比。
Istanbul ( gotwarlost.github.io/istanbul/
) is one of the best known frameworks that can calculate the code coverage for JavaScript at the statement, Git branch, or function level. Setting up Istanbul is easy. nyc is the name of the command-line argument that can be used to get code coverage, so let us run this command to install nyc:npm install -g --save-dev nyc The following command can be used to run Mocha tests with code coverage, so let us add it to package.json."mocha-cc": "nyc mocha --compilers js:babel-core/register --require babel-polyfill" Run the following command to run the Mocha tests and also get the code coverage.npm run mocha-cc The results are shown in Figure 12-5. Figure 12-5
下图显示了使用 Mocha 编写的测试的代码覆盖率
正如你所看到的,除了文件 es8-functional.js 中的第 20 行和第 57 行,我们覆盖了 93%。代码覆盖率的理想百分比取决于几个因素,所有因素都考虑到了投资回报。通常 85%是一个推荐的数字,但是如果代码被任何其他测试覆盖,那么低于这个数字也是可行的。
林挺
代码分析和代码覆盖率一样重要,尤其是在大型团队中。代码分析帮助您实施统一的编码规则,遵循最佳实践,并为可读性和可维护性实施最佳实践。到目前为止,我们编写的 JavaScript 代码可能不符合最佳实践,因为这更适用于生产代码。在这一节中,让我们看看如何将编码规则应用于功能性 JavaScript 代码。
ESLint ( eslint.org/
) is a command-line tool for identifying incorrect coding patterns in ECMAScript/JavaScript. It is relatively easy to install ESLint into any new or existing project. The following command installs ESLint.npm install --save-dev -g eslint ESLint is configuration driven, and the command that follows creates a default configuration. You might have to answer a few questions as shown in Figure 12-6 here. For this coding sample we are using coding rules recommended by Google.eslint --init Figure 12-6
下图显示了 eslint 初始化步骤
Here is the sample configuration file.{ "parserOptions": { "ecmaVersion": 6, "sourceType": "module" }, "rules": { "semi": ["error", "always"], "quotes": ["error", "double"] }, "env": { "node": true } } Let's look at the first rule. "semi": ["error", "always"], This rule says a semicolon is mandatory after every statement. Now if we run it against the code file es-functional.js we have written so far, we get the results shown in Figure 12-7. As you can see, we violated this rule in many places. Imposing coding rules or guidelines should be done at the very beginning of the project. Introducing coding rules or adding new rules after accumulating a huge code base results in an enormous amount of code debt, which will be difficult to handle. Figure 12-7
下图显示了 eslint 工具的结果
ESLint helps you fix these errors. As suggested earlier, you just have to run this command:eslint lib\es8-functional.js --fix
所有错误都消失了!你可能并不总是幸运的,所以确保你在开发阶段的早期施加限制。
单元测试库代码
在前一章中,我们学习了如何创建有助于构建应用程序的库。一个好的库是可测试的,所以测试的代码覆盖率越高,消费者信任你的代码的可能性就越大。当您更改某些内容时,测试有助于快速检查代码中受影响的区域。在这一节中,我们为我们在前一章中编写的 Redux 库代码编写 Mocha 测试。
The following code is available in the mocha-test.js file. The mocha-test.js file refers to the code from our Redux library. The following test ensures that initially the state is always empty.it('is empty initially', () => { assert.equal(store.getState().counter, 0); }); One of the main functions in our library was to assert if actions can influence state change. In the following state we initiate state change by calling IncrementCounter , which is called when a click event is raised. IncrementCounter should increase the state by 1.// test for state change once it('state change once', () => { global.document = null; incrementCounter(); assert.equal(store.getState().counter, 1); }); // test for state change twice it('state change twice', () => { global.document = null; incrementCounter(); assert.equal(store.getState().counter, 2); }); The last function we are going to assert is to check if there is at least one listener registered for state change. To ensure we have a listener we also register a listener; this is also called an Arrange phase .// test for listener count it('minimum 1 listener', () => { //Arrange global.document = null; store.subscribe(function () { console.log(store.getState()); }); //Act var hasMinOnelistener = store.currentListeners.length > 1; //Assert assert.equal(hasMinOnelistener, true); }); You can run npm run mocha or npm run mocha-cc to execute the tests with code coverage. You will notice in Figure 12-8 that we have covered more than 80% of the code we have written in the library. Figure 12-8
下图显示了代码覆盖率的结果
有了这个经验,为我们在前一章中构建的类似 HyperApp 的库编写单元测试将是一个很好的练习。
结束语
Another wonderful journey comes to an end. We hope you had fun like we did learning new concepts and patterns in JavaScript functional programming. Here are some closing thoughts.
-
如果你刚开始一个项目,试着使用本书中的概念。本书中使用的每个概念都有特定的使用范围。在浏览一个用户场景时,分析你是否可以使用任何解释过的概念。例如,如果您正在进行 REST API 调用,您将分析是否可以创建一个库来异步执行 REST API 调用。
-
如果您正在处理一个现有的项目,该项目包含大量杂乱的 JavaScript 代码,那么分析这些代码,将其中的一些代码重构为可重用的、可测试的功能。最好的学习方法是通过实践,所以扫描你的代码,找到松散的部分,把它们缝合在一起,形成一个可扩展的、可测试的、可重用的 JavaScript 函数。
-
请继续关注 ECMAScript 更新,因为随着时间的推移,ECMAScript 将继续成熟并变得更好。你可以在
github.com/tc39/proposals
上关注这些提议,或者如果你有一个新的想法或者想法可以改进 ECMAScript 或者帮助开发者,你可以继续你的提议。
摘要
在这一章中,我们学习了测试的重要性,测试的类型,以及像 BDD 和 TDD 这样的开发模型。我们开始理解 JavaScript 测试框架的需求,并了解了最著名的测试框架 Mocha 和 Jasmine。我们使用 Mocha 编写了简单测试、函数库测试和异步测试。Sinon 是一个 JavaScript 模仿库,它为 JavaScript 提供了间谍、存根和模仿。我们学习了如何将 Sinon 与 Mocha 相结合来模仿依赖行为或对象。我们还学习了使用 Jasmine 为 JavaScript 函数编写测试。伊斯坦布尔与 Mocha 集成得很好,并提供了我们的代码覆盖率,可以作为可靠性的衡量标准。林挺帮助我们编写干净的 JavaScript 代码,在本章中,我们学习了使用 ESLint 定义编码规则。