JavaScript 闭包(面向 C 和 Pascal 程序员)






4.75/5 (6投票s)
揭秘 JavaScript 闭包的内部工作原理
面向大众编程,而非面向类编程
“在编程语言中,闭包(也称为词法闭包或函数闭包)是一种在具有头等函数的语言中实现词法作用域名称绑定的技术。” - 维基百科
“闭包是函数与其声明时所处的词法环境的组合。” - MDN web docs
如果您能从上述定义中理解什么是 JavaScript 闭包,那么请不要浪费时间阅读本文。
对于我们其他人来说,引用 Stack Overflow 问答中的一句舒适的话——“一旦理解了核心概念,闭包就很容易理解。然而,通过阅读任何理论或学术导向的解释,它们是无法理解的!”
纵观历史,人们的知识大多是经验性的。
我将使用 Dheeraj Kumar Kesri 在 Code Project 上的文章中的代码示例:JavaScript 闭包
这是一个闭包
function getMultiplier(multiplyBy) {
return function(num) {
return multiplyBy * num;
}
}
这是它的用法
var multiplyByTwo = getMultiplier(2);
var multiplyByTen = getMultiplier(10);
var twoIntoFive = multiplyByTwo(5);
var tenIntoSix = multiplyByTen(6);
console.log(twoIntoFive); // prints 10
console.log(tenIntoSix); // prints 60
现在不必介意这个闭包示例没有做任何有用的事情,您将不得不习惯闭包,因为如果您用 JavaScript 编程,您会经常看到它们。
即使您刚开始学习 JavaScript,并且 C 没有它的一些特性,您的直觉也会告诉您
- 在处理嵌套函数时,内部函数可以访问其外层函数的声明和变量、函数以及参数的定义。
- 在
getMultiplier
函数执行期间,这个未命名的内部函数被返回并赋值给multipleByTwo
变量,稍后用于其调用。
我们 C 程序员的真正问题是——**_在外部函数执行并返回后,内部函数如何仍然可以访问 getMultiplier 的参数?getMultiplier 的所有局部变量难道不会被销毁吗?_**
简短的回答是:**垃圾回收器**允许这样做。
长答案当然也是一样的,但让我们一步步来。
JavaScript 中的函数
“函数是一个参数化的代码块,定义一次,之后可以调用任意多次。”
在 JS 中声明函数有几种方法,但对于本文,我们只提两种。
function double(num) {
return 2*num;
}
我们假设……
var triple = function(num) {
return 3*num;
};
第二种方式称为**函数表达式**。赋值右侧的未命名函数称为**匿名**函数。它的引用被赋值给 triple
变量。
从现在开始,符号名称 double
和 triple
都代表函数,它们持有对函数的引用。
尽管我们 C 程序员习惯于第一种函数声明方式,但《Getting MEAN with Mongo, Express, Angular and Node》一书在其免费章节“附录 D 重新介绍 JavaScript”中指出关于第二种声明方式“JavaScript 反正就是这样看的”。
function double(num) {}
// JavaScript interprets this as
var double = function(num) {};
这意味着即使您以第一种方式声明函数,您也可以随意将其他内容重新分配给 double
标识符。
function foo(num) {}
foo = 5;
// this works
如果你在 C 语言中尝试类似的操作,你会收到一个错误。如果没有其他原因,那是因为你会在运行时改变标识符 foo
的类型。即使你尝试将 foo
重新声明为 float
或 char
,也仍然不允许,前提是你将其重新声明为 global
,而不是在 main
内部,那样你会产生遮蔽。
程序员们经常使用“**引用**”这个词。为了简单起见,我只想把这个词的含义剥离到只剩下“**地址**”。无论“**引用**”这个词在已知宇宙中所有不同的编程语言中承载着怎样的包袱,在编程中,**引用**最重要的信息是对象在计算机内存中的位置,即它的**地址**。
在上面的例子中,变量 triple
被赋值为**匿名**函数的**引用**。您可以将该引用传递给其他标识符,您可以复制该引用,该引用最重要的信息,即**地址**的值,将始终相同。它将始终告知该匿名函数在经过 JavaScript 解释器转换后,其可执行形式的内存位置。
因此,在带有 triple
的**函数表达式**示例中发生的事情,也发生在开头闭包的示例中。函数 getMultiplier
作为其执行的结果,正如他们在高级语言中所说,返回另一个函数。对于我们 C 语言世界的人来说,它返回一个指向内部函数经 JavaScript 解释器处理后的可执行表示的引用。该引用立即被赋值给变量 multiplyByTwo
。
还记得**垃圾回收器**吗?如果赋值语句左侧的 multiplyByTwo
标识符没有持有**匿名**函数的返回引用,那么**垃圾回收器**将很乐意执行其任务并清除该函数实例。它将释放该函数占用的内存,因为您或任何人都无法再引用并调用它。
嵌套函数
对于 Pascal 程序员来说,嵌套函数没什么大不了的。我们也要熟悉它们,如果不是为了它们有什么用处(相信我,它们很有用),那么至少也要了解它们是如何工作的。
函数调用前,其栈帧会设置好:参数、局部变量、返回值,所有这些都会进入...但是,像 Pascal 和 D 这样具有**嵌套函数**能力的语言,会在内部函数的栈帧上额外压入一条信息,一个指向外部函数栈帧的**指针**。
当内部函数使用其**私有**变量时,没问题。但是,当它使用其外部函数的局部变量时,它必须经历一个**委托**过程,委托到外部函数的栈帧。
你在 JavaScript 编程中会随处发现**委托**的概念,其中最显著的是通过**原型链**的委托。
之前我说过,JavaScript 解释器在处理函数时,会将其翻译成可执行形式,即机器代码。这可能并非事实,但它很好地解释了引用。我真的不知道 JavaScript 源代码翻译的直接结果是什么。也许它将源代码分解成可重用的令牌,这些令牌本身就被转换成或已经存在于机器代码中。也许,它将“源代码翻译成某种高效的中间表示并立即执行”。
您必须理解我正在使用一个模型来描述正在发生的事情,该模型可能与现实不完全精确,但我希望它能很好地帮助您更好地理解闭包和 JS 编程的主题。
话虽如此,内部函数还有一些其他的事情发生。与 JS 中**构造函数**的工作方式非常相似。
function Person(name) {
this.name = name;
this.getThyName = function() {
return this.name;
};
}
var peter = new Person("Peter");
var shmeter = new Person("Shmeter");
你可能会直观地认为 peter
和 shmeter
共享同一个函数,但事实并非如此。它们共享完全相同的功能,但 Person
**构造函数**只是自动化了根据你的蓝图为你编写 getThyName
方法的任务。你不仅会得到两个带有 name
变量成员的对象,你还会得到两个 getThyName
方法。
var john = {
name: "John",
getThyName: function() {
return this.name;
}
};
var shmon = {
name: "Shmon",
getThyName: function() {
return this.name;
}
};
现在你直观地知道这两个方法驻留在不同的内存位置,并且它们具有不同的引用值,即**地址**。
为了在前一个例子中让 peter
和 shmeter
只重用一个方法,你需要将函数 getThyName
赋值给 Person
构造函数的**原型**。这样 peter
和 shmeter
将通过**原型链**的**委托**过程调用 getThyName
。
在 Pascal 中,你会在为内部函数设置一个引用名称后,期望我们的嵌套函数示例能编译通过,然后就完成了。你将有一个 getMultiplier
的实例和一个你将命名为 multiply
的内部函数的实例。在此之后,唯一会改变的将是在每次调用 getMultiplier
或 multiply
时创建和销毁的相应栈帧。
在 JavaScript 中,解释器将在全局作用域中找到 getMultiplier
的主体,并为其实例化保留所需的内存,将其“翻译成某种高效的中间表示”,将其引用赋值给标识符 getMultiplier
,并可以随意传递其引用,只要你需要它的副本。中间表示只在一个位置创建一次。
另一方面,当你调用,比如 getMultiplier(2)
时,JS 会到达内部函数,它的任务是创建它的一个实例。这时事情就变得类似于我们的**构造函数**示例了,对于每次不同的 getMultiplier
调用,解释器都会构造一个内部**匿名**函数的新实例。当实例创建后,JS 解释器会得到它的引用,并将其作为 getMultiplier
的返回值。对于每次对外部函数的调用,你都会得到一个不同的内部函数的引用。
如果返回时没有东西持有该引用值,那就太好了。**垃圾回收器**可以处理掉该匿名函数实例占用的内存。
幸运的是,我们有变量 twoIntoFive
在赋值语句的左侧等待,一个 lvalue
来解救。
作用域链
“作用域是**函数编写时**创建的上下文环境(也称为词法环境)。这个上下文定义了它能访问哪些其他数据。”
让我们尝试为 Pascal 将指向外部函数栈帧的指针插入到内部函数栈帧中,找到一个 JavaScript 类比。**作用域链**中的**委托**是如何实现的?作用域链是什么?
您运行的每个 JavaScript 程序都会创建一个**执行上下文栈**。这个栈由不同的执行上下文构成。它的第一个元素是**全局执行上下文**。简单来说,那就是函数体之外的所有内容。每当调用一个函数时,都会创建一个新的**执行上下文**。
假设我们刚刚运行了一个脚本,所以在这个栈上,我们只有**全局执行上下文**,并且随着脚本的进行,我们的第一个函数调用被执行。全局执行上下文将被**暂停**,然后为我们的函数创建一个新的**执行上下文**,并将其推到**执行上下文栈**的顶部,在全局执行上下文之上。
如果我们的函数调用了另一个函数,我们函数的执行上下文将被暂停,并且为被调用函数新创建的执行上下文将被推到我们函数的执行上下文之上。当被调用函数退出时,它的执行上下文会从栈中弹出,我们的执行上下文会变得活跃,只要我们的函数没有退出。当它退出时,它的执行上下文也会被弹出,全局执行上下文再次变得活跃……写了这么多“执行上下文”真是无聊,我必须想个办法在前两段中把它提取出来。
每个函数调用都会创建一个新的执行上下文。getMultiplier(2)
和 getMultiplier(10)
将在两个不同的执行上下文中执行。
每个执行上下文都有**两个阶段**:**创建**阶段和**执行**阶段。
**在创建阶段,会创建激活对象。**局部变量和函数参数会被添加到这个对象中,基本上是这个作用域的所有新声明。然后创建**作用域链**。最后,设置**上下文**。**上下文**与**this**的值直接相关,它与**执行上下文**不同,但它又是执行上下文的一部分。
出于某种原因,**全局执行上下文**的激活对象被称为**变量对象**。
**执行上下文的作用域链是激活对象的列表**,当它被评估时,总是从当前最内层的激活对象开始,以**变量对象**结束。最内层的激活对象是当前正在执行的函数的执行上下文的激活对象。
因此,执行函数的作用域链的**委托**总是从其局部作用域到其定义所在的外部函数的外部作用域(如果有的话?)依此类推……并最终在全局作用域中结束,正如您所期望的那样。
您可能已经注意到,在处理其**执行上下文**中的**嵌套函数**时,被调用的**内部函数**通过**作用域链**对其**外部函数**的**激活对象**有一个**引用**。因此,即使外部函数完成其工作并返回后,**垃圾回收器**也无法处理外部函数的激活对象以释放内存。
这是长篇的回答,希望您喜欢。 :)
这意味着
function getMultiplier(multiplyBy) {
var t = "something";
return function(num) {
return multiplyBy * num;
}
}
var multiplyByTwo = getMultiplier(2);
变量 t
和参数 multiplyBy
的值被保留,因为它们是同一个激活对象的一部分,您不必在内部函数中显式引用 t
。
任何关于改进此处答案有效性的建议都将不胜感激并予以注明。
历史
- 2019年7月25日:初始版本