(译) 函数式 JS #4: 闭包

318 阅读7分钟

这是"函数式 JS" 系列文章的第三篇。 点击查看 上一篇第一篇

photo by Brunno Tozzo on Unsplash

原文链接 By: Krzysztof Czernek

介绍

关于函数式编程术语,我们已经讲过了很多基础知识。

现在让我们把注意力转向另一个概念,这个概念在尝试用更加函数式的方式编程时很有用。这就是闭包

前一段时间,我们介绍了一等函数 (first-class functions) 以及高阶函数的概念。我们已经了解了如何使用它们从多个小型单一用途的函数中构造程序的复杂逻辑。现在,我们将了解这些概念与闭包之间的关系-以及为什么说没有闭包的话高阶函数就几乎毫无用处的。

上一次,我们专注于应用程序状态和纯函数。今天,我们将讨论如何使用闭包对应用程序状态进行建模和存储。

但是,先来看看这个神秘的术语……

什么是闭包?

闭包是某种编程语言中存在的一种机制,它允许函数“记住”定义时在其外部作用域中存在的变量。

好吧,要理解的东西很多。让我们从头开始。

“他们的外部作用域”是什么意思?变量可以在几个不同的“作用域”中定义。让我们看一下其中的几个。

首先,最直接的是本地作用域:

const userGreetingMessage = user => {
  const localGreeting = 'Hello'
  const message = `${localGreeting}, ${user.firstName}`
  return message
}

userGreetingMessage({firstName: 'Krzysztof'})

仔细看一下第3行。很明显,变量localGreeting是可以被访问的,因为他就是在这个函数中的前一行定义的。它属于 userGreetingMessage本地作用域

然后是全局作用域:

const globalGreeting = 'Hello'

const userGreetingMessage = user => {
  const message = `${globalGreeting}, ${user.firstName}`
  return message
}

userGreetingMessage({firstName: 'Krzysztof'})

在这里,我们可以访问第4行的 globalGreeting 变量,因为它已在代码的全局作用域内定义-因此可以在任何地方访问。

到目前为止,一切都没问题

前面的例子演示了一些比较易于理解的常见模式。现在,我们将注意力转移到闭包上。

首先,我们来看看一个高阶函数(故意编造的例子)。

const makeGreeter = () => {
  const closureGreeting = 'Hello'
  
  return user => {
    const message = `${closureGreeting}, ${user.firstName}`
    return message
  }
}

userGreetingMessage = makeGreeter()

userGreetingMessage({firstName: 'Krzysztof'})

在这里,我们可以看到,虽然没有在内部函数(第4-7行)的本地作用域或应用程序的全局作用域中定义 ClosureGreeting,我们依然可以在第 5 行中引用它。

让我们来跟踪执行一下这段代码:

  1. 外部函数(makeGreeter)在第10行中执行。
  2. 进入到第2行的makeGreeter函数体。ClosureGreetingmakeGreeter的本地作用域内初始化。
  3. 定义并立即返回内部函数。
  4. 内部函数被赋值给 userGreetingMessage,第10行完成。
  5. 内部函数在第12行执行。打印出 Hello Krzysztof

在第12行中,外部函数(makeGreeter)已经退出,因此我们可能会觉得 closureGreeting 变量已经消失了。但事实并非如此–我们可以看到内部函数仍然可以访问 ClosureGreeting变量

而能做到这一点就是因为有了闭包

闭包是这样一种机制,它允许内部函数记住定义它们时在其外部作用域中存在的变量。

我们可以使用 console.dir 看到闭包:

const makeGreeter = () => {
  const closureGreeting = 'Hello'
  
  return user => {
    const message = `${closureGreeting}, ${user.firstName}`
    return message
  }
}

userGreetingMessage = makeGreeter()

console.dir(userGreetingMessage)

在这段代码的倒数第三行,我们可以看到 userGreetingMessage 函数把ClosureGreeting值记在了它的作用域属性中。这个作用域的属性可以在这个函数被调用的时候访问到。

另外一个角度看闭包

在尝试了解闭包的工作方式时,可以尝试用不同的方式来看待闭包。

识别闭包的一种方法是,每当看到一个函数在另一个函数内部定义时--那个内部函数就可以访问外部函数中定义的变量。

这适用于外部函数中明确定义的变量(如前面所示),也适用于外部函数的参数。同样,所有这些都适用于箭头功能以及用 function 定义的函数。

下面是一个例子:

const makeGreeter = greetingMessage => user => {
  return `${greetingMessage}, ${user.firstName}`
}

userGreetingMessage = makeGreeter('Hello')
userGreetingMessage({firstName: 'Krzysztof'})

我们还可以将闭包视为具有绑定变量的一等函数

注意!

在尝试理解一段使用闭包的代码执行逻辑时,我们需要注意一件事。 先看一下这个示例:

const makeGreeter = () => {
  let closureGreeting = 'Hello'
  let innerGreeter = user => {
    return `${closureGreeting}, ${user.firstName}`
  }
  
  closureGreeting = 'Hi'
  
  return innerGreeter
}

userGreetingMessage = makeGreeter()

userGreetingMessage({firstName: 'Krzysztof'})

运行此代码将产生 Hi,Krzysztof,而不是我们可能期望的 Hello,Krzysztof。 这是因为内部函数会记住变量在外部函数返回时的值,而不是在定义内部函数时的值。

在实际工作中,我们不应该像这段代码那样重新给变量赋值,现在只需要牢记这一点就好了。

但是... 为什么需要闭包

如果闭包看起来很复杂,那么原因是他们确实很复杂。不过,好的一面是,如果我们开始使用闭包,他们很快就会成为一种本能。

尽管如此,你可能还是会好奇:我们到底为什么需要闭包?那就先来看几个例子吧。

组合函数,高阶函数

闭包可以让我们用函数式的方法把代码写得更易读--利用一些通用函数来构造专用函数。

来看一下这个例子:

const getObjectAttributeByName = attributeName => obj => obj[attributeName]

const getFirstName = getObjectAttributeByName('firstName')

const krzysztof = { firstName: 'Krzysztof', lastName: 'Czernek' }

getFirstName(krzysztof) // "Krzysztof"

getObjectAttributeByName, 就像前面讨论过的一些函数一样,演示了一种定义多参数函数的通用方法 -- 柯里化。我们以后会对这个技术进行更详细的讨论,但是现在你可能也很想知道这段代码是怎么回事。 我们在第三行调用了 getObjectAttributeByName, 提供了一个叫做 attributeName的参数。而这个调用返回的是另一个接受 obj 作为参数并返回 obj[attributeName] 的内部函数。

当我们运行到第 7 行,开始执行getFirstName的时候, getObjectAttribute 已经执行结束并且返回了结果。但是因为有了闭包,这段代码依然知道 attributeName的值是'firstName'

使用柯里化和另外一个相关的技术-**局部应用(partial application)**还可以带来更多的好处。 我们会在后面的部分重点讲解。现在我们只需要知道的是,正是因为闭包,这些技术才得以实现或者说变得更为有用。 如果没有它,那些内部函数就没有办法访问到外部函数的参数和变量。

封装 (Encapsulation)

使用闭包还有另外一个好处 -- 实现封装。

看一下这个受了 Douglas Crockford 启发的示例。

const counter = initialValue => {
  let currentValue = initialValue
  return () => {
    console.log(`The current value is: ${currentValue++}`)
  }
}

const countFromFive = counter(5)

countFromFive() // The current value is: 5
countFromFive() // The current value is: 6
countFromFive() // The current value is: 7

这个示例看起来好像没什么大不了。但是使用闭包来保存 currentValue 的值意义重大。 一旦 counter 函数执行完成之后,我们就没有任何办法再访问和修改 currentValue 变量了。(实现了对内部数据的严格保护)

当然,这个例子是人为制造出来的(还有一个副作用)--但是我们看到这个模式也可以应用到更复杂的数据结构。

我们可以利用闭包仅暴露少部分“接口”函数来和外部交互并保持内部数据不可访问。如果想通过普通对象(Plain Object)来实现这个是不可能的。

总结

关于闭包,有两点要牢记:

  1. 我们没有谈到以非标准方式定义函数时会发生什么,例如使用 eval或 Function()构造函数。 这并不是很重要,因为这些不经常(也不应该)使用。
  2. 闭包一词用于指代两个稍有不同的事物:1)外部函数的变量集合,以及2)具有这组绑定变量的内部函数。

现在,我们了解了这个神秘术语“闭包”的含义,以及它如何被用于函数的封装和组合。

如果你想了解更多关于闭包的知识,我向你推荐以下这些资源:

我希望这些能派上用场。 下一章见!