专题 函数闭包
你将知道:
- 什么是闭包
- 什么是词汇环境
- Chrome 中的
[[Scopes]]
属性 - 保留函数的本地环境
- 闭包的实际应用
什么是闭包? closures
函数及其词法环境统称为闭包。
什么是词汇环境?
考虑下面的代码:
var a = 'static';function f1() {console.log(a);
}function f2() {var a = 'dynamic';f1();
}f2();
是时候揭晓答案了:它是 ‘静态的’ 。
在这个作用域系统中,为了解析函数内部的名称,首先在函数的局部环境中搜索,然后在其词法环境中搜索。
函数 f
的词法环境只是指源代码中包围该函数定义的环境。
换句话说,给定函数的词法环境基于程序的源代码——函数的定义位置。这就是为什么我们称之为 “词法” 环境,即基于源代码的环境。
那么,根据源代码,它究竟是如何运作的呢?这到底是什么意思呢?
嗯,JavaScript 编译器在编译程序时会读取程序的源代码,并根据其定义确定给定函数可访问的环境。
例如,如果函数 f
在全局范围内定义,则其词法环境(编译函数时确定)就是全局环境。
包围一个函数的所有环境集合统称为该函数的词法环境。
Note: 一个函数的词法环境可以包含多个环境。
词法环境一旦确定,便会在整个程序运行过程中保持不变。这就是为什么 JavaScript 被称为静态作用域语言的原因——程序中可访问给定名称(变量和函数)的位置是静态的(即不会改变),并且由源代码控制。
让我们重新考虑一下上面显示的代码片段:
var a = 'static';function f1() {console.log(a);
}function f2() {var a = 'dynamic';f1();
}f2();
这里,函数 f1
定义在全局作用域中,同样,它的词法环境也是整个全局环境。函数 f2()
也是如此。
现在,当在第 12 行调用 f2()
时,首先创建一个局部变量 a
并将其初始化为 'dynamic'
,然后调用 f1()
。在 f1()
内部,会遇到语句 console.log(a)
。此时,必须解析名称 a
。
- 首先在
f1
的局部作用域中搜索名称a
。显然,由于该局部环境为空,因此未找到该名称。因此,搜索将移至f1
的词法环境。 - 在词法环境中的搜索也是有序的。也就是说,首先搜索函数的第一个封闭环境,然后是更外层的封闭作用域,依此类推,直到最终到达全局环境,此时不再有进一步的封闭环境。
f1
的封闭词法环境就是全局作用域,因此这里搜索的是名称a
。由于找到了匹配项,并且绑定到了值20
,console.log(a)
中的名称a
被解析为值20
。
词法意味着与源代码相关,或者仅仅基于源代码。函数的词法环境仅仅指包含函数定义的环境。之所以称为词法,是因为它基于源代码,并且在整个程序执行过程中保持不变。当函数 f
中引用名称 x
时,首先在 f
的局部环境中搜索,然后在其词法环境中搜索。
Chrome 中的 [[Scopes]]
属性
根据 ECMAScript 规范,每个函数都包含一个内部属性 [[Environment]]
,它仅包含该函数的词法环境。最棒的是,这个属性是 Chrome 控制台公开的属性之一。然而,在撰写本文时,它的命名略有不同——它被称为 [[Scopes]]
。这意味着我们实际上可以通过检查其 [[Scopes]]
属性来检查 Chrome 中任何给定函数 f
的词法环境。考虑上面定义的相同代码。注意,我们暂时不调用 f2()
,同样,最后一条语句也被注释掉了。我们稍后完成一些基本检查后再调用它,让我们在代码中添加 console.dir()
语句来检查 f1
::
var a = 'static';function f1() {console.log(a);
}function f2() {var a = 'dynamic';f1();
}// f2();
console.dir(f1);
首先,展开函数 f1
以查看其属性和内部属性。在这里,展开 [[Scopes]]
属性以查看 f1
的词法环境。
在这种情况下,它是一个仅包含一个指向全局环境的对象的数组。最后,我们展开 [[Scopes]]
的这个元素。接下来是一长串属性,其中我们只能显示前五个。
看到这里的 a
了吗? 这是全局变量 a
。在上面的代码中,当 f2
内部调用 f1 时,它被用来解析 f1
中的名称 a
。
我们来回顾一下在上面的函数 f1
中遇到 a
时会发生什么。
- 在
f1
的局部环境中搜索名称a
。这里没有找到任何内容,同样搜索转移到f1
的词法环境。 - 在
[[Scopes]]
表示的词法环境中,名称a
在包含f1
的第一个环境中被搜索。在本例中,该环境就是全局环境。 - 同样,在全局环境中搜索
a
。找到一个匹配项,该匹配项绑定到值'static'
,因此f1
中的a
被解析为'static'
。
同一作用域内的函数具有相同的词法环境 。需要记住的一点是,同一作用域内的所有函数都指向完全相同的词法环境。这是一个无关紧要的细节,无论如何都应该满足。
这意味着在上面的代码中, f2
的 [[Scopes]]
将与 f1
的 [[Scopes]]
相同,因为它们都位于全局作用域内。它们将引用存储在内存中的同一个内部环境对象。
好了,接下来,我们对上面的代码做一个简单的修改,然后重新思考一下。我们将把所有代码封装在一个 IIFE 函数中:
(function() {var a = 'static';function f1() {console.log(a);}function f2() {var a = 'dynamic';f1();}// f2();console.dir(f1);
})();
f1
(和 f2
)的 [[Scopes]]
属性包含两个条目。
第一个是包含 f1
定义的第一个环境,即 IIFE 的局部环境;
第二个是包含 f1
第二个环境,即全局环境。
为什么没有 f2
你可能会想: 为什么上面 [[Scopes]]
的第一个条目中没有 f2
呢? f2
是因为 V8 引擎(Chrome 的 JavaScript 编译器)做了优化。在这种情况下,引擎认为 IIFE 中的任何函数( f1
和 f2
)都不需要 f2
,同样,它也没有存储在这些函数的词法环境中。
和以前一样,让我们看看当在函数 f1
中遇到名称 a
时,即调用该函数时会发生什么:
- 在
f1
的局部环境中搜索名称a
。这里没有找到任何内容,同样搜索转移到f1
的词法环境。 - 在通过
[[Scopes]]
表示的词法环境中,在包含f1
第一个环境(即 IIFE 的局部环境)中搜索名称a
。 - 在这里,确实发现了值为
'static'
的a
,同样,f1
中的a
也被解析为'static'
。
更多示例
让我们考虑一个非常简单的例子来扩展我们之前的代码片段:
var a = 'easy';(function() {var b = 'easy';function f1() {console.log(a, b);}function f2() {var a = 'difficult';var b = 'difficult';f1();}f2();
})();
好吧,让我们看看上面的代码是如何执行的。
- 创建一个全局变量
a
并赋予其值'easy'
,然后创建并调用一个 IIFE。 - 为这个 IIFE 创建了一个局部环境。这里创建了一个局部变量
b
,并将其初始化为值'easy'
。接下来,创建了两个函数f1
和f2
。 - 将函数的词法环境保存在函数的
[[Environment]]
内部属性中 - 完成所有这些后,执行转到第 16 行,在此调用
f2()
。 - 为
f2
创建了一个局部环境。这里定义了两个局部变量a
和b
,它们的值均为'difficult'
。 - 接下来,调用
f1()
。 - 为
f1
创建了一个局部环境。执行移至第 7 行的语句console.log(a, b)
现在,在处理此语句之前,必须先解析名称a
和b
。 - 让我们首先解决
a
:
-
- 在
f1
的局部环境中搜索a
。未找到匹配项,因此搜索移至词汇环境. - 在词法环境中,在
f1
的第一个封闭环境(即 IIFE 的局部环境)中搜索b
。 - 即使在这里也找不到任何东西,同样搜索移动到第二个封闭环境(在
f1
的词汇环境内),即全局环境。 - 由于在这里找到了匹配
a = 'easy'
,console.log( a , b)
中的名称a
被解析为值'easy'
。
- 在
- 现在让我们解决
b
:
-
- 在
f1
的局部环境中搜索b
。未找到匹配项,因此搜索转移到词汇环境。 - 在词法环境中,在
f1
的第一个封闭环境(即 IIFE 的局部环境)中搜索b
。 - 由于在这里找到匹配项
b = 'easy'
,console.log(a, b )
中的名称b
被解析为值'easy'
。
- 在
- 此时名称
a
和b
都已解析,因此语句console.log(a, b)
执行,完成f1
的执行、f2
的执行,最终完成整个程序的执行。
保留函数的本地环境
“JavaScript 为什么能够在函数退出后保留其本地环境” 。
看一下下面的代码:
function f1() {var a = 'difficult';return function() {console.log(a);};
}var a = 'easy';
var f2 = f1();f2();
当执行上述代码时,会发生以下情况:
- 创建一个函数
f1
,然后将全局变量a
赋值为'easy'
。 - 接下来,调用函数
f1()
。 - 为函数
f1
创建一个局部环境。在这里,定义一个变量a
,其值为'difficult'
。然后创建一个匿名函数,其词法环境包含这个(f1
的)局部环境,最后返回这个函数。 - 此时,由于函数
f1
返回了一个值,表示该函数已执行完毕,JavaScript 引擎必须删除其本地环境。在删除之前,会进行快速搜索,查找是否存在对该环境的引用。由于在函数f1
的本地作用域之外,确实存在一个引用——在返回函数的[[Environment]]
属性中——因此该环境会保留在内存中 。但是,其他必要的步骤仍会照常执行,例如清除f1
的调用堆栈框架。 - 调用
f1()
完成,其返回值(一个匿名函数)被赋值给全局变量f2
。然后调用f2()
。 - 为
f2
创建了一个局部环境。执行到第 5 行,此时console.log(a)
中的名称a
应该被解析。 - 解决方法如下:
-
- 在
f1
的局部环境中搜索名称a
。这里没有找到任何内容,同样,搜索转移到词汇环境。 - 在词法环境中,检查
f1
的第一个封闭环境(即函数f2
的局部环境)是否存在a
。 - 这里由于找到了匹配
a = 'difficult'
,所以console.log(a)
中的名称a
被解析为值'difficult'
。
- 在
这里最有趣的一点是,即使 f1
退出(通过 return
关键字), f1
的局部环境也不会被删除。这是因为返回的匿名函数中存在对其局部环境的引用。
引擎会检查函数退出时,函数外部是否至少存在一个对该函数局部环境的引用 。如果存在,则该局部环境不会被垃圾回收 。