高阶函数是大家经常挂在嘴边的,但是很少有人去解释它是什么,也许你已经知道什么是高阶函数了。但是我们如何在现中使用它们?有哪些例子可以告诉我们什么时候使用,以及他们怎么表现出实用性?我们可以使用他们操作DOM?或者,那些使用高阶函数的人实在炫耀吗?他们将代码过分复杂化?
我认为高阶函数很有用。事实上,我认为它们是JavaScript作为一种语言最重要的特性之一,但在讲这个之前,我们先来分解一下什么是高阶函数,在理解这个概念之前,让我们从函数作为变量开始说起。
函数是头等公民
在JavaScript中,我们至少有三种不同的方法来编写函数。首先,我们可以写一个函数声明。例如:
// 接受一个DOM元素,将他包裹在li里面
function itemise(el) {
const li = document.createElement('li');
li.appendChild(el);
return li;
}
希望大家都很熟悉。但是,你可能知道我们也可以把它写成函数表达式。看起来是这样的:
const itemise = function(el) {
const li = document.createElement('li');
li.appendChild(el);
return li;
}
还有另一种方法来定义这个函数,使用箭头函数:
const itemise = (el) => {
const li = document.createElement('li');
li.appendChild(el);
return li;
}
对于我们想要达到的目的,这三个函数本质上是相同的。但是注意最后两个写法,把函数赋给了一个变量。这看起来似乎没什么,为什么不能将函数赋值给一个变量呢?但是这是个非常重要的特性,JavaScript中的函数是“头等的”。也就是说,我们可以:
- 将函数赋值给变量
- 将函数作为参数传递给其他函数
- 从一个函数中返回另一个函数
听起来不错,但是和高阶函数有什么关系呢?注意最后两点。我们待会再讲。同时,让我们看一些例子。
我们已经看到了函数可以赋值给变量,那么作为参数传递给其他函数呢?我们写一个函数,可以使用DOM元素,如果我们运行 document.querySelectorAll(),我们将返回 NodeList,而不是一个数组。NodeList 没有像数组那样的 .map() 方法,所以我们来写一个:
// 将给定的函数应用于NodeList中的每一项并返回一个数组。
function elListMap(transform, list) {
// list 可能是一个 NodeList, 没有 .map() 方法, 所以我们将它转换为一个数组
return [...list].map(transform);
}
// 获取页面中所有类名为 'for-listing' 的 span 标签.
const mySpans = document.querySelectorAll('span.for-listing');
// 将每项包裹在 li 里面. 我们使用之前定义的 itemise 函数
const wrappedList = elListMap(itemise, mySpans);
在这个例子中,我们将 itemise 函数作为参数传递给 elListMap 函数,但是我们可以使用 elListMap 不仅仅是创建列表,例如,我们可以使用它向一组元素添加一个类。
function addSpinnerClass(el) {
el.classList.add('spinner');
return el;
}
// 获取所有类名为 'loader' 的按钮
const loadButtons = document.querySelectorAll('button.loader');
// 将 spinner 类名添加给所有的 button
elListMap(addSpinnerClass, loadButtons);
elListMap 函数接受 transform 函数作为参数,这意味着我们可以重用 elListMap 函数来完成一系列不同的任务。
我们现在看到了将函数作为参数传递的例子,但是从一个函数里面返回另一个函数,看起来是什么样子?
我们写一个常规的函数。我们希望获得一个 <li>
元素列表,并将它们包裹在一个 <ul>
中。这没什么难度:
function wrapWithUl(children) {
const ul = document.createElement('ul');
return [...children].reduce((listEl, child) => {
listEl.appendChild(child);
return listEl;
}, ul);
}
但是如果之后我们有一些段落元素,想包裹在 <div> 里面,没关系,我们同样可以写一个函数:
function wrapWithDiv(children) {
const div = document.createElement('div');
return [...children].reduce((divEl, child) => {
divEl.appendChild(child);
return divEl;
}, div);
}
可以正常运行,但是这两个函数看起很相似,唯一的区别就是用于包裹的父元素不一样。 现在我们来写一个函数,接受两个参数:父元素的类型,子元素列表。但是,还有另一种方法可以实现。我们可以创建一个返回函数的函数。看起来是这样:
function createListWrapperFunction(elementType) {
// 很直接,我们返回了一个函数
return function wrap(children) {
// 在wrap函数里面, 我们可以‘看到’ elementType 这个参数
const parent = document.createElement(elementType);
return [...children].reduce((parentEl, child) => {
parentEl.appendChild(child);
return parentEl;
}, parent);
}
}
刚开始看起来有点复杂,我们把它分解一下。我们创建了一个函数,它没有其他功能,只是返回另一个函数。但是这个被返回的函数记住了 elementType 这个参数,那么,当我们随后调用这个被返回的函数时,它就知道该创建什么元素,所以我们可以创建 wrapWithUl 和 wrapWithDiv:
const wrapWithUl = createListWrapperFunction('ul');
// wrapWithUl() 函数现在记住了,它要创建一个 ul 元素
const wrapWithDiv = createListWreapperFunction('div');
// wrapWithDiv() 函数现在记住了,它要创建一个 div 元素
返回的函数会“记住”某个东西,我们有个专业的叫法:闭包。闭包非常实用,但是我们现在还不用想太多。 所以,我们已经看到了:
- 将函数赋值给变量
- 作为参数传递
- 从一个函数返回另一个函数
总而言之,函数是头等公民,这确实不错。但这和高阶函数有什么关系呢?我们来看看高阶函数的定义。
什么是高阶函数
高阶函数是:
将函数作为参数传入或作为结果返回的函数
听起来是不是很熟悉?在JavaScript中,函数是一等公民。我们所说的“高阶函数”就是指利用这一点的函数。没什么特别的,只是一个简单的概念,听起来很高大上。
高阶函数例子
一旦你注意到,你会发现到处都是高阶函数。最常见的是接受函数作为参数的函数。我们先来看这些。然后我们将讨论一些返回函数的实际例子。
接受函数作为参数的函数
在传递回调函数的地方,都使用高阶函数,这些在前端开发中随处可见。最常见的方法之一是 .addeventlistener()方法。当我们想要使我们的操作发生以响应事件时,可以使用这个,例如,如果我想让一个按钮弹出一个警告:
function showAlert() {
alert('Fallacies do not cease to be fallacies because they become fashions');
}
document.body.innerHTML += `<button type="button" class="js-alertbtn">
Show alert
</button>`;
const btn = document.querySelector('.js-alertbtn');
btn.addEventListener('click', showAlert);
在上面例子中,我们创建了一个显示警告的函数。然后我们在页面上添加一个按钮。最后我们将 showAlert() 函数作为参数传递给 btn.addEventListener()
当我们使用数组迭代方法时,我们也会看到高阶函数,像 .map(), .filter(), 和 .reduce(),我们已经在 elListMap() 函数中看到了。
function elListMap(transform, list) {
return [...list].map(transform);
}
setTimeout() 和 setInterval() 函数都帮助我们管理函数何时执行。例如,如果我们想在30秒后删除一个显示高亮类名,我们可以这样做:
function removeHighlights() {
const highlightedElements = document.querySelectorAll('.highlighted');
elListMap(el => el.classList.remove('highlighted'), highlightedElements);
}
setTimeout(removeHighlights, 30000);
同样,我们创建一个函数并将其作为参数传递给另一个函数。就像你看到的,我们使用的函数通常在JavaScript中接受函数。事实上,您可能已经使用过它们了。
返回结果为函数的函数
返回结果为函数的函数不如接受函数作为参数的函数常见。但他们仍然很有用,最实用的例子之一就是 maybe() 函数,我从 Reginald Braithewaite的 JavaScript Allongé 一书中改写了这个函数,看起来像这样:
function maybe(fn)
return function _maybe(...args) {
// Note that the == is deliberate.
if ((args.length === 0) || args.some(a => (a == null)) {
return undefined;
}
return fn.apply(this, args);
}
}
我们先来看看如何使用它,而不是马上理解它的原理,我们继续使用 elListMap() 函数:
function elListMap(transform, list) {
return [...list].map(transform);
}
如果我们不小心将一个 null 或 undefined 值传递给 elListMap(),会发生什么?我们会得到一个 TypeError,程序会崩溃,停止执行。maybe() 解决了这个问题,我们可以这样使用:
const safeElListMap = maybe(elListMap);
safeElListMap(x => x, null);
// ← undefined
这时候函数会返回 undefined,而不是崩溃。如果我们把它传递给另一个受 maybe() 保护的函数,它会再次返回 undefined。我们可以使用 maybe() 函数 来保护我们任何我们想要的函数。这比用 if 语句要简单得多。
一个函数返回另一个函数,在React社区中也很常见。例如,react-redux 中的 connect() 就是一个返回函数的函数。
为什么要使用高阶函数
我们已经看到了一些高阶函数的例子。但是他们给我们带来了什么好处呢?为了回答这个问题,让我们再看一个例子。数组的内置 .sort() 方法。它的确是有问题,它改变了原数组本身,而不是返回一个新的数组,我们暂且忽略它。
sort() 方法是一个高阶函数。它接受一个函数作为它的一个参数。
它是如何工作的?如果我们想对一组数字排序,我们首先创建一个比较函数。大概是这样的:
function compareNumbers(a, b) {
if (a === b) return 0;
if (a > b) return 1;
/* else */ return -1;
}
排序数组,我们可以这样做:
let nums = [7, 3, 1, 5, 8, 9, 6, 4, 2];
nums.sort(compareNumbers);
console.log(nums);
// 〕[1, 2, 3, 4, 5, 6, 7, 8, 9]
我们可以对一串数字进行排序。但这有什么用呢?我们多久会有一个需要排序的数字列表? 很少。如果有需要排序的情况,通常是一个对象数组。就像这样:
let typeaheadMatches = [
{
keyword: 'bogey',
weight: 0.25,
matchedChars: ['bog'],
},
{
keyword: 'bog',
weight: 0.5,
matchedChars: ['bog'],
},
{
keyword: 'boggle',
weight: 0.3,
matchedChars: ['bog'],
},
{
keyword: 'bogey',
weight: 0.25,
matchedChars: ['bog'],
},
{
keyword: 'toboggan',
weight: 0.15,
matchedChars: ['bog'],
},
{
keyword: 'bag',
weight: 0.1,
matchedChars: ['b', 'g'],
}
];
假设我们要根据每个元素的权重对这个数组排序。我们可以从头开始写一个新的排序函数。但我们不需要这么做。相反,我们创建一个新的比较函数。
function compareTypeaheadResult(word1, word2) {
return -1 * compareNumbers(word1.weight, word2.weight);
}
typeaheadMatches.sort(compareTypeaheadResult);
console.log(typeaheadMatches);
// 〕[{keyword: "bog", weight: 0.5, matchedChars: ["bog"]}, … ]
我们可以为任何类型的数组写一个比较函数,sort() 方法与我们达成协议。给定一个比较函数,它将排序任何数组。不用担心数组里有什么。所以我们不用担心自己去写一个排序算法。我们将重点放在比较两个元素这一更为简单的任务上。
现在,想象一下如果没有高阶函数。我们不能将函数传递给 .sort() 方法。当我们需要排序不同类型的数组时,我们必须写一个新的排序函数,或者,我们最终会使用函数指针或对象来重复创造同样的东西。任何一种方式都显得很笨拙。
正因为有了高阶函数,我们可以将比较函数和排序函数分离开,想象一下,如果有个聪明的浏览器工程师想到了更快的算法来实现 sort(),每个程序员的代码都会受益,不管他们想要排序的数组内部元素是什么,有一大堆 高阶数组函数 都遵循这个模式。
这可以引申出更广泛的概念, sort() 方法将排序任务从数组中抽象出来,我们就有了所谓的关注点分离,高阶函数允许我们创建抽象,否则就会显得很笨拙或不可能实现。创建抽象占据了软件工程的80%。
每当我们移除重复的部分来重构代码,我们都在创建抽象。这就好像一个模式,可以将其替换为该模式的抽象表示形式。因此,我们的代码变得更简洁,更容易理解。至少,这是我的想法。
高阶函数是创建抽象的强大工具。还有一个与抽象相关的数学领域-范畴论。更准确地说,范畴论是关于发现抽象的抽象。换句话说,就是要找到模式中的模式。在过去的70年左右,聪明的程序员一直在窃取他们的想法,这些思想表现为编程语言特性和库。
如果我们学习这些模式的模式,我们有时可以移除大片代码。或者将复杂的问题分解为简单构建快之间的组合,那些构建快就是高阶函数,这就是为什么高阶函数很重要。因为有了它们,我们就有了一个强大的工具来对抗代码中的复杂性。
如果你想了解更多关于高阶函数的知识,这里有一些参考资料:
Higher-Order Functions Chapter 5 of Eloquent JavaScript by Marijn Haverbeke.
Higher-Order Functions Part of the Composing Sofware series by Eric Elliott.
Higher-Order Functions in JavaScript by M. David Green for Sitepoint.
可能你已经在使用高阶函数了。JavaScript使它变得如此简单,以至于我们没有过多地考虑它们。但是当人们抛出这个词时,我们知道这是什么,这并不复杂。但在这看似简单的概念背后,蕴藏着巨大的力量。
Update 3 July 2019:如果你是一名有经验的函数式编程开发者,可能你已经注意到我使用了非纯函数和一些冗长的函数名。这并不是因为我不了解非纯函数或一般函数编程原理。这不是我在生产环境中定义函数名的方式。这是一篇有教育意义的文章,所以我尽量选择一些初学者能理解的实际例子,作为一种妥协。如果你有兴趣,可以看看我另外两篇文章 functional purity 和 general functional programming principles
最后
- 函数有三种以上的写法,不过我们可以下次再讨论。
- 这并不总是正确的。这三种写法在实践中都有细微的差别。区别在于
this关键字和函数调用堆栈跟踪过程中标签的变化 - 维基百科:Wikipedia contributors (2019). ‘First–class citizen,’ Wikipedia, the free encyclopedia, viewed 19 June 2019, en.wikipedia.org/wiki/First-…
- 如果你想了解更多关于闭包,参考: Master the JavaScript Interview: What is a Closure? by Eric Elliott
- Higher Order Function (2014), viewed 19 June 2019, wiki.c2.com/?HigherOrde….