二、函数式思维——第一个例子

第一章,成为功能——几个问题,我们走过去 FP 是什么,提到一些优势的应用,并列举了一些工具,我们需要在 JavaScript 中,但现在让我们留下理论,首先考虑一个简单的问题和如何解决它功能。

在本章中,我们将做以下工作:

  • 看一个简单、常见的电子商务相关问题
  • 考虑几种常见的解决方法,以及它们相关的缺陷
  • 从功能上看问题,找到解决问题的方法
  • 设计一个可以应用于其他问题的高阶解决方案
  • 找出如何执行功能解决方案的单元测试

在以后的章节中,我们将回到这里列出的一些主题,所以我们不会太详细。 我们将展示 FP 如何为我们的问题提供一个不同的视角,并将进一步的细节留到后面。 读完这一章后,你将第一次看到一个常见的问题,并通过功能思维来解决它,作为本书其余部分的前奏。

我们的问题是只做一件事

让我们考虑一个简单但常见的情况。 你开发了一个电子商务网站; 用户可以填满他们的购物车,最后,他们必须点击 Bill me 按钮,这样他们的信用卡就会被支付。 然而,用户不应该点击两次(或更多),否则他们将被计费多次。

你的应用的 HTML 部分可能在某个地方有这样的东西:

<button id="billButton" onclick="billTheUser(some, sales, data)">Bill me</button>

并且,在这些脚本中,您将拥有类似于以下代码的内容:

function billTheUser(some, sales, data) {
  window.alert("Billing the user...");
  // *actually bill the user*
}

Assigning the events handler directly in HTML, the way I did it, isn't recommended. Rather, unobtrusively, you should assign the handler through code. So, do as I say, not as I do!

这是对问题和您的网页的一个非常简单的解释,但是对于我们的目的来说已经足够了。 现在让我们考虑避免重复点击按钮的方法。 如何避免用户多次点击? 这是一个有趣的问题,有几种可能的解决办法——让我们从看一些不好的开始吧!

你能想出多少种方法来解决我们的问题? 让我们看看几种解决方案,并分析它们的质量。

解决方案 1——抱最好的希望!

我们如何解决这个问题? 第一个解决方案可能看起来像一个笑话:什么都不做,告诉用户不要点击两次,并期待最好的结果! 您的页面可能看起来像图 2.1:

Figure 2.1: An actual screenshot of a page, just warning you against clicking more than once

这是逃避问题的一种方法; 我看到过一些网站只是警告用户点击超过一次的风险(见图 2.1),实际上并没有采取任何措施来防止这种情况:用户收到了两次账单? 我们警告他们… 这是他们的错!

您的解决方案可能看起来像以下代码:

<button id="billButton" onclick="billTheUser(some, sales, data)">Bill me</button>
<b>WARNING: PRESS ONLY ONCE, DO NOT PRESS AGAIN!!</b>

好吧,这其实不是一个解; 让我们来谈谈更严肃的提议。

解决方案 2 -使用全局标志

大多数人可能首先想到的解决方案是使用一些全局变量来记录用户是否已经点击了按钮。 你需要定义一个类似于clicked的标志,用false初始化。 当用户点击按钮时,如果clickedfalse,则将其改为true,执行该功能; 否则,你什么都不会做。 在下面的代码中可以看到所有这些:

let clicked = false;
.
.
.
function billTheUser(some, sales, data) {
  if (!clicked) {
    clicked = true;
    window.alert("Billing the user...");
    // *actually bill the user*
  }
}

For more good reasons not to use global variables, read http://wiki.c2.com/?GlobalVariablesAreBad.

这显然是可行的,但它有几个必须解决的问题:

  • 您正在使用一个全局变量,您可能会意外地更改它的值。 无论是在 JavaScript 中还是在其他语言中,全局变量都不是一个好主意。
  • 您还必须记住,当用户再次开始购买时,重新初始化它为false。 如果你不这么做,用户将无法进行第二次购买,因为付费将变得不可能。
  • 您将很难测试这段代码,因为它依赖于外部事物(即clicked变量)。

所以,这不是一个很好的解决方案。 我们一直在想吧!

解决方案 3 -移除处理程序

我们可能会采取横向解决方案,而不是让功能避免重复点击,我们可能会完全删除点击的可能性。 下面的代码就是这样做的; billTheUser()做的第一件事是从按钮中删除onclick处理程序,因此没有进一步调用将是可能的:

function billTheUser(some, sales, data) {
 document.getElementById("billButton").onclick = null;
  window.alert("Billing the user...");
  // actually bill the user
}

这种解决方案也存在一些问题:

  • 代码与按钮紧密耦合,因此您无法在其他地方重用它。
  • 您必须记住重置处理程序,否则用户将无法进行第二次购买。
  • 测试也会更加困难,因为您必须提供一些 DOM 元素。

我们可以对这个解决方案进行一些增强,并通过在调用中提供后者的 ID 作为额外参数来避免函数与按钮的耦合。 (这个想法也可以应用到下面的一些解决方案中。) HTML 部分将如下所示,并注意billTheUser()的额外参数:

<button
  id="billButton"
  onclick="billTheUser('billButton', some, sales, data)"
>
  Bill me
</button>;

我们还需要更改被调用的函数,因此它将使用接收到的buttonId值来访问相应的按钮:

function billTheUser(buttonId, some, sales, data) {
  document.getElementById(buttonId).onclick = null;
  window.alert("Billing the user...");
  // actually bill the user
}

这个解决方案稍微好一些。 但是,在本质上,我们仍然使用全局元素—不是变量,而是onclick值。 所以,尽管增强了,这也不是一个很好的解决方案。 让我们继续。

解决方案 4 -改变处理程序

上一种解决方案的变体是不删除单击功能,而是分配一个新的。 当我们将alreadyBilled()函数赋给 click 事件时,我们使用函数作为一级对象。 警告用户他们已经点击的函数可能是如下内容:

function alreadyBilled() {
  window.alert("Your billing process is running; don't click, please.");
}

我们的billTheUser()函数将会像下面的代码一样——注意如何不像上一节中那样将null赋值给onclick函数,现在赋值alreadyBilled()函数:

function billTheUser(some, sales, data) {
  document.getElementById("billButton").onclick = alreadyBilled;
  window.alert("Billing the user...");
  // actually bill the user
}

这个解决方案有一个很好的观点; 如果用户第二次点击,他们将得到一个警告,不要这样做,但他们不会再次计费。 (从用户体验的角度来看,这样更好。) 然而,这个解决方案仍然有与前一个相同的反对意见(与按钮耦合的代码,需要重置处理程序,以及更困难的测试),所以无论如何我们都不会认为它很好。

解决方案 5 -禁用按钮

这里有一个类似的想法,我们可以禁用按钮,而不是删除事件处理程序,这样用户就不能点击了。 你可能有一个像下面的代码一样的函数,它通过设置按钮的disabled属性来实现这一点:

function billTheUser(some, sales, data) {
  document.getElementById("billButton").setAttribute("disabled", "true");
  window.alert("Billing the user...");
  // actually bill the user
}

这也是可行的,但是我们仍然反对前面的解决方案(将代码耦合到按钮,需要重新启用按钮,以及更困难的测试),所以我们也不喜欢这个解决方案。

解决方案 6 -重新定义处理程序

另一个想法是:我们不改变按钮中的任何东西,而是让事件处理程序改变自己。 诀窍在第二行; 通过给billTheUser变量赋一个新值,我们实际上是在动态地改变函数的功能! 当你第一次调用这个函数时,它会做它自己的事情,但它也会通过将自己的名字赋予一个新函数而改变自己的存在:

function billTheUser(some, sales, data) {
  billTheUser = function() {};
  window.alert("Billing the user...");
  // *actually bill the user*
}

这里有一个特殊的解决方法。 函数是全局的,所以billTheUser=...行实际上改变了函数的内部工作方式。 从那一刻起,billTheUser将是新的(空)函数。 这个解决方案仍然难以测试。 更糟糕的是,你如何恢复billTheUser的功能,让它回到原来的目标?

解决方案 7 -使用本地标志

我们可以回到使用国旗的想法,而是使其全球(这是我们的主要理由),我们可以使用一个立即调用函数表达式(IIFE),我们会看到更多在第三章,开始函数——一个核心概念【显示】,第 11 章, 实现设计模式-功能方式 通过这个,我们可以使用闭包,所以clicked将是函数的局部,在其他地方不可见:**

var billTheUser = (clicked => {
  return (some, sales, data) => {
    if (!clicked) {
      clicked = true;
      window.alert("Billing the user...");
      // *actually bill the user*
    }
  };
})(false);

See how clicked gets its initial false value from the call at the end.

此解决方案与全局变量解决方案类似,但使用私有的局部变量是一种改进。 我们可以发现的唯一缺点是,您将不得不重新工作每个只需要调用一次的函数,以这种方式工作(并且,正如我们将在下一节中看到的,我们的 FP 解决方案在某些方面与它类似)。 好吧,这并不难做到,但不要忘记不要重复自己(DRY)的建议!

现在我们已经通过多种方法来解决我们的只做一次的问题——但正如我们所看到的,它们不是很好! 让我们用泛函的方法来考虑这个问题,我们会得到一个更一般的解。

用函数来解决我们的问题

让我们试着更一般化; 毕竟,要求某个功能只执行一次并不奇怪,可能在其他地方也需要这样做! 让我们来制定一些原则:

  • 原始函数(可能只被调用一次的函数)应该做它应该做的任何事情,而不是做其他的事情。
  • 我们不想以任何方式改变原始函数。
  • 我们需要有一个只调用原始函数一次的新函数。
  • 我们想要一个通解可以应用到任意数量的原始函数上。

The first principle listed previously is the single responsibility principle (the S in S.O.L.I.D.), which states that every function should be responsible for a single functionality. For more on S.O.L.I.D., check the article by Uncle Bob (Robert C. Martin, who wrote the five principles) at http://butunclebob.com/ArticleS.UncleBob.PrinciplesOfOod.

我们能做到吗? 是的,我们会写一个高阶函数,我们可以把它应用到任何函数上,得到一个只能工作一次的新函数。 让我们来看看! 我们将介绍高阶函数(稍后我们将在第 6 章生产函数——高阶函数),然后我们将测试我们的函数解决方案,并对其进行一些改进。

一个高阶的解决方案

如果我们不想修改原来的函数,我们将创建一个高阶函数,我们将(灵感!)命名为once()。 这个函数将接收一个函数作为参数,并将返回一个新函数,该函数将只工作一次。 (如前所述,我们将在第 6 章中看到更多的高阶函数; 特别是,参见做一次事情,重温部分。)

Underscore and Lodash already have a similar function, invoked as _.once(). Ramda also provides R.once(), and most FP libraries include similar functionality, so you wouldn't have to program it on your own.

我们的once()函数一开始可能看起来很壮观,但随着你习惯了 FP 的工作方式,你会习惯这类代码,并发现它是很容易理解的:

const once = fn => {
  let done = false;
  return (...args) => {
    if (!done) {
      done = true;
      fn(...args);
    }
  };
};

让我们回顾一下这个函数的一些细节:

  • 第一行显示,once()接收一个函数(fn)作为其参数。
  • 我们利用闭包定义一个内部的私有done变量,如前面的解决方案 7。 我们选择了而不是之前的,将其称为clicked,因为您不必点击按钮来调用该函数,所以我们使用了更通用的术语。 每次将once()应用于某个函数时,都会创建一个新的、不同的done变量,并且只能从返回的函数访问该变量。
  • return (...args) => ...行表示once()将返回一个函数,带有一些(一个或多个,也可能是零)参数。 请注意,我们使用的扩展语法是我们在第一章中看到的。 在旧版本的 JavaScript 中,你必须使用arguments对象; 详见https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Functions/arguments。 现代的 JavaScript 方法更简单、更短!

  • 我们在调用fn()之前赋值done = true,以防该函数抛出异常。 当然,如果您不想禁用函数,除非它已经成功结束,那么您可以将赋值移动到fn()调用的下面。

  • 设置完成后,我们最终调用原始函数。 请注意使用扩展运算符来传递原始的fn()所具有的任何参数。

那么,我们该如何使用它呢? 我们甚至不需要将新生成的函数存储在任何地方。 我们可以简单地写出onclick方法,如下所示:

<button id="billButton" onclick="once(billTheUser)(some, sales, data)">
  Bill me
</button>;

密切注意语法! 当用户单击按钮时,使用(some, sales, data)参数调用的函数不是billTheUser(),而是使用billTheUser作为参数调用once()的结果。 这个结果只能被调用一次。

Note that our once() function uses functions as first-class objects, arrow functions, closures, and the spread operator; back in Chapter 1, Becoming Functional – Several Questions, we said we'd be needing those, so we're keeping our word! All we are missing here from that chapter is recursion, but as the Rolling Stones sang, You Can't Always Get What You Want!

我们现在有了一种函数式的方法让一个函数只做一次; 我们如何测试它? 现在让我们进入这个话题。

手动测试解决方案

我们可以做个简单的测试。 让我们编写一个squeak()函数,当调用它时,它会发出适当的吱吱声! 代码很简单:

const squeak = a => console.log(a, " squeak!!");

squeak("original"); // "original squeak!!"
squeak("original"); // "original squeak!!"
squeak("original"); // "original squeak!!"

如果我们对它应用once(),我们得到一个只能发出一次吱吱声的新函数。 请看下面代码中突出显示的行:

const squeakOnce = once(squeak);

squeakOnce("only once"); // "only once squeak!!"
squeakOnce("only once"); // no output
squeakOnce("only once"); // no output

查看结果在 CodePen 或图 2.2:

Figure 2.2: Testing our once() higher-order function

前面的步骤向我们展示了如何手工测试once()函数,但我们使用的方法并不完全理想。 让我们在下一节中看看为什么,以及如何做得更好。

自动测试解决方案

手动运行测试是不好的:它会变得令人厌烦和无聊,并且在一段时间后,导致不再运行测试。 让我们做得更好,用 Jasmine 编写一些自动测试。 按照https://jasmine.github.io/pages/getting_started.html的说明,我设置了一个独立的跑步器; 所需的 HTML 代码,使用 Jasmine Spec Runner 2.6.1,如下:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>Jasmine Spec Runner v2.6.1</title>

  <link rel="shortcut icon" type="img/png" 
        href="lib/jasmine-2.6.1/jasmine_favicon.png">
  <link rel="stylesheet" href="lib/jasmine-2.6.1/jasmine.css">

  <script src="lib/jasmine-2.6.1/jasmine.js"></script>
  <script src="lib/jasmine-2.6.1/jasmine-html.js"></script>
  <script src="lib/jasmine-2.6.1/boot.js"></script>

  <script src="src/once.js"></script>
  <script src="tests/once.test.1.js"></script>
</head>
<body>
</body>
</html>

src/once.js文件具有我们刚才看到的once()定义,tests/once.test.js具有实际的测试套件。 我们的测试代码如下:

describe("once", () => {
  beforeEach(() => {
    window.myFn = () => {};
    spyOn(window, "myFn");
  });

 it("without 'once', a function always runs", () => {
    myFn();
    myFn();
    myFn();
    expect(myFn).toHaveBeenCalledTimes(3);
  });

  it("with 'once', a function runs one time", () => {
    window.onceFn = once(window.myFn);
    spyOn(window, "onceFn").and.callThrough();
    onceFn();
    onceFn();
    onceFn();
    expect(onceFn).toHaveBeenCalledTimes(3);
    expect(myFn).toHaveBeenCalledTimes(1);
  });
});

这里有几点需要注意:

  • 要监视一个函数,它必须与一个对象相关联。 (或者,你也可以直接使用 Jasmine 的createSpy()方法创建一个间谍。) 全局函数与窗口对象相关联,所以window.fn表示fn实际上是全局函数。
  • 当您监视一个函数时,Jasmine 会拦截您的调用,并记录函数被调用的情况、使用的参数以及调用的次数。 所以,我们所关心的是,window.fn可能只是null,因为它永远不会被执行。
  • 第一个测试只检查如果我们多次调用函数,它会被调用多少次。 这是微不足道的,但如果没有发生,我们就真的做错了!
  • 在第二组测试中,我们希望看到once()函数(window.onceFn())只被调用一次。 所以,我们让 Jasmine 暗中监视onceFn,但让电话通过。 任何打到fn()的电话也会被计算在内。 在我们的例子中,正如预期的那样,尽管调用了onceFn()三次,fn()只调用了一次。

图 2.3可以看出:

Figure 2.3: Running automatic tests on our function with Jasmine

现在我们不仅看到了如何手工测试我们的功能解决方案,而且还看到了自动测试的方法,所以我们已经完成了测试。 最后让我们考虑一个更好的解决方案,也是以函数式的方式实现的。

产生更好的解决方案

在前面的一个解决方案中,我们提到,每次在第一次点击之后做一些事情,而不是默默地忽略用户的点击,这将是一个好主意。 我们将编写一个新的高阶函数,它带有第二个参数——这个函数将从第二次调用开始每次调用。 我们的新函数将被称为onceAndAfter(),可以这样写:

const onceAndAfter = (f, g) => {
  let done = false;
  return (...args) => {
    if (!done) {
      done = true;
      f(...args);
    } else {
 g(...args);
 }
  };
};

我们进一步研究了高阶函数; onceAndAfter()两个函数为参数,产生第三个函数,其中包括其他两个函数。

You could make onceAndAfter() more powerful by giving a default value for g, along the lines of const onceAndAfter = (f, g = () => {}), so if you didn't want to specify the second function, it would still work fine because it would call a do-nothing function, instead of causing an error.

我们可以做一个简单的测试,就像我们之前做的那样。 让我们在之前的squeak()上添加一个creak()吱吱作响的函数,看看如果我们对它们应用onceAndAfter()会发生什么。 然后我们可以得到一个makeSound()函数,该函数应该一次squeak(),然后是creak():

const squeak = (x) => console.log(x, "squeak!!");
const creak = (x) => console.log(x, "creak!!");
const makeSound = onceAndAfter(squeak, creak);

makeSound("door"); // "door squeak!!"
makeSound("door"); // "door creak!!"
makeSound("door"); // "door creak!!"
makeSound("door"); // "door creak!!"

为这个新函数编写测试并不困难,只是稍微长一点。 我们必须检查调用了哪个函数以及调用了多少次:

describe("onceAndAfter", () => {
  it("should call the first function once, and the other after", () => {
    func1 = () => {};
    spyOn(window, "func1");
    func2 = () => {};
    spyOn(window, "func2");
    onceFn = onceAndAfter(func1, func2);

    onceFn();
    expect(func1).toHaveBeenCalledTimes(1);
    expect(func2).toHaveBeenCalledTimes(0);

    onceFn();
    expect(func1).toHaveBeenCalledTimes(1);
    expect(func2).toHaveBeenCalledTimes(1);

    onceFn();
    expect(func1).toHaveBeenCalledTimes(1);
    expect(func2).toHaveBeenCalledTimes(2);

    onceFn();
    expect(func1).toHaveBeenCalledTimes(1);
    expect(func2).toHaveBeenCalledTimes(3);
  });
});

注意,我们总是检查func1()只被调用一次。 同样,我们检查func2(); 调用计数从 0(调用func1()的时间)开始,从那时起,每次调用计数增加 1。

总结

在这一章中,我们看到了一个常见的,简单的问题,基于现实情况,分析了几种典型的解决方法,我们选择了一个功能思维的解决方法。 我们看到了如何将 FP 应用到我们的问题中,并且我们发现了一个更通用的高阶解,我们可以将其应用到类似的问题中,而无需进一步更改代码。 我们看到了如何为代码编写单元测试以完成开发工作。

最后,我们产生了一个更好的解决方案(从用户体验的角度),并看到了如何编写它和如何对它进行单元测试。 现在,你已经开始掌握如何用功能来解决问题; 接下来,在第 3 章中,我们将从函数——一个核心概念开始,更深入地研究函数,它是所有 FP 的核心。

问题

2.1。 :我们的函数实现需要使用一个额外的变量done来标记函数是否已经被调用。 这并不重要,但你能不使用任何额外的变量吗? 请注意,我们并没有告诉您使用任何变量,只是不添加任何新的变量,如done,而只是作为练习!

2.2。 交替函数:在我们的onceAndAfter()函数的精神下,你能写一个alternator()高阶函数,它有两个函数作为参数,每次调用,交替调用一个和另一个函数吗? 预期的行为应该如下例所示:

let sayA = () => console.log("A");
let sayB = () => console.log("B");
let alt = alternator(sayA, sayB);

alt(); // *A*
alt(); // *B*
alt(); // *A*
alt(); // *B*
alt(); // *A*
alt(); // *B*

2.3。 凡事都有限度! 作为once()的扩展,你能写出一个高阶函数thisManyTimes(fn,n),让你调用fn()函数到n多次,但之后什么都不做吗? 举个例子,once(fn)thisManyTimes(fn,1)会产生完全相同的函数。