文章目录
前言
上一篇围绕【如何只让事件逻辑触发一次】给出的 7 种实现方案都不甚理想,这一节来看看函数式的解法究竟有何高明之处。
2.2 该问题的函数式解 A functional solution to our problem
来看更通用的解法。毕竟,让某函数只执行一次的需求并不那么特殊,很可能其他地方也会用到。不妨确立几个原则:
- 原函数(即只需要执行一次的函数)可以满足既定需求,仅次而已;
- 不要改动原函数逻辑;
- 找到一个新函数,让它来调用原函数,且只调用一次;
- 找到一个通用的解决方案,以便推广到任意数量原函数上。
小贴士
这里的原则一,也就是“单一职责原则”(the single responsibility principle),对应
S.O.L.I.D
五大原则中的S
原则:每个函数都应该对单一功能负责。更多S.O.L.I.D.
原则的介绍,详见 Bob 大叔(即 Robert C. Martin,五大原则的提出者)的文章。
能办到吗?是的,我们将用到一种叫做 **高阶函数(higher-order function)**的函数。它能作用于任意函数上,产生一个新的函数来“只执行一次”原函数。本节将引入高阶函数的相关概念(第 6 章会详细阐述)并着手测试找到的函数式解决方案,在此基础上再做一些改进。
1. 高阶函数解 A higher-order solution
如果不修改原函数,需要创建一个高阶函数,不妨称它为 once()
。该函数将接收一个函数作为参数,并返回一个只能运行一遍的新函数(前面提到,第 6 章详解高阶函数时还会提到本节内容,详见“重温只执行一次”(Doing things once, revisited)小节)。
专家提示
Underscore
和Lodash
已经有类似的函数实现了,调用_.once()
即可;Ramda
也提供了R.once()
方法。大部分函数式编程工具库已经包含了类似功能,因此无需再手动实现。
这里的 once()
函数可能一开始看起来有点牵强,但当您习惯用函数式的方式去思考问题后,就会逐渐认同这种用法,并且觉得这样写非常易于理解:
const once = fn => {
let done = false;
return (...args) => {
if (!done) {
done = true;
fn(...args);
}
};
};
来看一下这个函数的几个细节:
- 第一行显示
once()
函数接收一个函数(fn
)作参数; - 利用闭包,我们像上一节中的方案 7 那样,定义了一个私有的内部变量
done
。这里最好不要像前面那样叫它clicked
,因为我们不一定需要通过单击按钮来执行该函数,因此选择了一个更通用的变量名。每次调用once()
函数都会创建一个新的、唯一的done
变量。该变量的值只有新返回的函数才能访问到; return (...args) => ...
这行表示once()
函数将返回一个带有若干参数(一个或多个,也可能没有参数)的新函数。注意,这里用到了第 1 章提到过的展开运算符语法。在旧版JavaScript
中用的是arguments
对象,详见 参考资料。新版JavaScript
语法更简单、更凝练;- 在运行
fn()
之前将true
赋给done
,只是为了防止函数抛异常。当然,如果您非要在函数运行结束后才禁止下一次运行,也可以将赋值语句置于fn()
执行语句的后面; - 以上设置完成后,最终我们将调用原函数。注意第 6 行展开运算符的使用,要将
fn()
所需要的参数传进来。
那么,具体该怎么使用呢?我们都不必将新生成的函数存到一个地方,只需像下面这样编写 onclick
方法:
<button id="billButton" onclick="once(billTheUser)(some, sales, data)">
Bill me
</button>;
这里尤其要注意语法!当用户单击按钮,接收参数 (some, sales, data)
而被调用的目标函数,并不是 billTheUser()
函数自身,而是将 billTheUser()
作为参数、运行函数 once()
而返回的结果。该结果即为只运行一次的目标函数。
小贴士
注意,
once()
函数用到了作一级对象的函数,也用到了箭头函数、闭包、展开运算符。之前第 1 章提到我们会用到这些知识点,除了唯一没用到的递归,这里都兑现了。但是正如滚石乐队所唱的那样,你不可能总是得到你想要的(You Can’t Always Get What You Want!)。
至此,我们用函数式编程的方式实现了让一个函数只执行一次。该如何进行测试呢?一起来看看吧。
2. 高阶函数解的手动测试 Testing the solution manually
可以做个简单测试。编写一个 squeak()
函数(squeak
即拟声词“吱”),正常情况下运行会发出一声“吱”:
const squeak = a => console.log(a, " squeak!!");
squeak("original"); // "original squeak!!"
squeak("original"); // "original squeak!!"
squeak("original"); // "original squeak!!"
将 once()
函数应用到 squeak()
函数,则会得到一个只“吱”一声的函数(注意第 1 行):
const squeakOnce = once(squeak);
squeakOnce("only once"); // "only once squeak!!"
squeakOnce("only once"); // no output
squeakOnce("only once"); // no output
到 CodePen
网站查看运行结果:
【图 2.2:在 CodePen 实测高阶函数 once()】
以上步骤演示了手动测试 once()
函数的过程,但用到的方法并不十分理想。原因及改进意见,将在下一小节给出。
3. 高阶函数解的自动测试 Testing the solution automatically
手动运行测试太原始了,又累又枯燥,过不了多久就不再想去写测试了。让我们改用 Jasmine
这套测试框架来实现自动测试。按照 Jasmine
官网 给的步骤操作即可。笔者用的是单机版,当时版本为 v2.6.1
。实测最新单机版(2022-5-13)已升级到 v4.1.1
,于三天前更新(GitHub
地址:https://github.com/jasmine/jasmine/releases):
【GitHub 上的最新 jasmine 版本】
下载压缩包、解压,得到如下目录结构:
【Jasmine 项目目录结构】
其中最新版 SpecRunner.html
如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Jasmine Spec Runner v4.1.1</title>
<link rel="shortcut icon" type="image/png" href="lib/jasmine-4.1.1/jasmine_favicon.png">
<link rel="stylesheet" href="lib/jasmine-4.1.1/jasmine.css">
<script src="lib/jasmine-4.1.1/jasmine.js"></script>
<script src="lib/jasmine-4.1.1/jasmine-html.js"></script>
<script src="lib/jasmine-4.1.1/boot0.js"></script>
<!-- optional: include a file here that configures the Jasmine env -->
<script src="lib/jasmine-4.1.1/boot1.js"></script>
<!-- include source files here... -->
<script src="src/Player.js"></script>
<script src="src/Song.js"></script>
<!-- include spec files here... -->
<script src="spec/SpecHelper.js"></script>
<script src="spec/PlayerSpec.js"></script>
</head>
<body>
</body>
</html>
放到 VSCode
的 Live Server
下运行,默认示例结果如下:
【Jasmine 项目试运行页面截图】
接着,按照 SpecRunner.html
中的注释提示信息,创建文件 src/once.js
存放 once()
函数的定义;再创建文件 tests/once.test.js
存放实际测试用例代码。本节示例代码如下:
src/once.js
:
const once = fn => {
let done = false;
return (...args) => {
if(!done) {
done = true;
fn(...args);
}
}
}
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);
});
});
然后将默认示例替换为本节示例:(注意第 18 行和第 21 行)
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Jasmine Spec Runner v4.1.1</title>
<link rel="shortcut icon" type="image/png" href="lib/jasmine-4.1.1/jasmine_favicon.png">
<link rel="stylesheet" href="lib/jasmine-4.1.1/jasmine.css">
<script src="lib/jasmine-4.1.1/jasmine.js"></script>
<script src="lib/jasmine-4.1.1/jasmine-html.js"></script>
<script src="lib/jasmine-4.1.1/boot0.js"></script>
<!-- optional: include a file here that configures the Jasmine env -->
<script src="lib/jasmine-4.1.1/boot1.js"></script>
<!-- include source files here... -->
<script src="src/once.js"></script>
<!-- include spec files here... -->
<script src="spec/once.test.js"></script>
</head>
<body>
</body>
</html>
这里需要注意以下几点:
- 监视一个函数,务必要关联到一个对象(也可以通过
Jasmine
的createSpy()
方法创建监听器对象);全局函数与window
对象关联,这里的window.fn
表明fn
是一个全局函数; - 一个函数被监视后,
Jasmine
会拦截函数的调用并记录下该函数已经被调用,并携带哪些参数、总共调用了几次等等。就我们所关心的问题而言,window.fn
可以简单地设为null
,因为它永远不会被执行; - 第一组测试用例用于检测函数执行若干次后,可以获取其执行次数。虽然简单,但要是拿不到这个值,代码就真的可能出错了;
- 第二组测试用例,我们想考察
once()
函数(即window.onceFn()
)有且只被调用了一次。然后Jasmine
监视到onceFn
函数并放行。后面调用fn()
函数的情况也会被记录。正如我们预期的那样,本例中onceFn()
函数虽然执行了三次,但fn()
函数只执行了一次。
用 Live Server
再次打开该页面,效果如下:
【图 2.3:针对目标函数用 Jasmine 运行自动测试】
至此,我们已经熟悉了手动及自动化测试函数式解决方案的具体方法,测试部分暂告一段落。本章的最后,让我们来看看有没有更好的函数式的解决方案。
4. 更好的解决方案 Producing an even better solution
之前的解决方案曾提到过一个不错的想法:在首次单击按钮后,每次单击不是静默地忽略用户的单击操作,而是执行某段逻辑。不妨创建一个新的高阶函数,令其接收第二个参数——从第二次单击开始才会执行的一个函数。命名该函数为 onceAndAfter()
,具体代码如下:
const onceAndAfter = (f, g) => {
let done = false;
return (...args) => {
if(!done) {
done = true;
f(...args);
} else {
g(...args);
}
};
};
我们在高阶函数上做了进一步探索,onceAndAfter() 函数接收 两个 函数作参数,并产生一个新的、包含了这两个函数的结果函数。
提示
您也可以通过给函数
g
指定一个默认值来增强原函数onceAndAfter()
,例如const onceAndAfter = (f, g = () => {})
,这样即便不传入第二个参数,函数照样能正常运行,因为它默认调用了一个啥都不干的函数(do-nothing function),而不致于报错。
我们可以像之前那样做个临时测试。先创建一个可以“嘎吱作响”的(即 creak
的本意) creak()
函数。然后与 squeak() 函数一道,放入 onceAndAfter()
函数,这样我们就得到了一个发声函数 makeSound()
。它可以发出“吱”一次,随后一直发出“嘎吱”声:
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!!"
为新函数编写测试用例也不难,只是略长。我们要考察哪个函数被调用了,并查看它们调用了几次:
src/onceAndAfter.js
:
const onceAndAfter = (f, g = () => {}) => {
let done = false;
return (...args) => {
if (!done) {
done = true;
f(...args);
} else {
g(...args);
}
};
};
spce/onceAndAfter.test.js
:
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()
),随后的每次调用,其调用次数依次递增。
实测效果如下:
【最终实测效果截图】
2.3 小结 Summary
本章从一个现实的场景出发,考察了一个常见的简单问题,并提出并分析了若干种解决方案,最终选择了函数式的解。我们了解了用函数式编程解决实际问题的方法,并找到了一种基于高阶函数相关特性的更通用的解题思路,可以在不对代码动大手术的情况下,方便地应用到类似场景中。此外,我们还学习了如何为代码编写单元测试来完善开发工作。
最后,我们找到了一个从用户体验角度来说效果更好的解决方案,实现了它的逻辑并通过了自动化单元测试。现在您已经开始掌握用函数式编程思维解决问题的方法了。在接下来的第三章《从函数开始——一个核心概念》,我们将更为深入地考察函数的相关特性——这是所有函数式编程的核心。