阅读 279

Javascript 函数式编程:什么是高阶函数?我们为什么需要了解它?

原文:jrsinclair.com/articles/20…
作者:James Sinclair
翻译:前端小白

高阶函数是大家经常挂在嘴边的,但是很少有人去解释它是什么,也许你已经知道什么是高阶函数了。但是我们如何在现中使用它们?有哪些例子可以告诉我们什么时候使用,以及他们怎么表现出实用性?我们可以使用他们操作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 这个参数,那么,当我们随后调用这个被返回的函数时,它就知道该创建什么元素,所以我们可以创建 wrapWithUlwrapWithDiv

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);
}
复制代码

如果我们不小心将一个 nullundefined 值传递给 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 puritygeneral functional programming principles

最后

  1. 函数有三种以上的写法,不过我们可以下次再讨论。
  2. 这并不总是正确的。这三种写法在实践中都有细微的差别。区别在于 this 关键字和函数调用堆栈跟踪过程中标签的变化
  3. 维基百科:Wikipedia contributors (2019). ‘First–class citizen,’ Wikipedia, the free encyclopedia, viewed 19 June 2019, en.wikipedia.org/wiki/First-…
  4. 如果你想了解更多关于闭包,参考: Master the JavaScript Interview: What is a Closure? by Eric Elliott
  5. Higher Order Function (2014), viewed 19 June 2019, wiki.c2.com/?HigherOrde….
关注下面的标签,发现更多相似文章
评论