深入 JavaScript 闭包
作者:Seiya
时间:2019年06月17日
前言
在深入闭包的概念之前,我们需要先了解 JavaScript 为什么要引入闭包的概念,所以我们需要从了解函数式编程开始。
函数式编程中一些基本定义
众所周知,在函数式语言中(ECMAScript 也支持这种风格),函数即是数据。就比方说,函数可以赋值给变量,可以当参数传递给其他函数,还可以从函数里返回等等。这类函数有特殊的名字和结构。
函数式参数
定义
函数式参数(“Funarg”) —— 是指值为函数的参数。
函数式参数的函数称为高阶函数(high-order function 简称:HOF)。还可以称作:函数式函数或者偏数理或操作符。
自由变量
定义
自由变量是指在函数中使用的,但既不是函数参数也不是函数的局部变量的变量。
下面的例子中,对于函数 someFn 来说,localVar 就是自由变量:
function testFn() {
var localVar = 10;
function innerFn(innerParam) {
alert(innerParam + localVar);
}
return innerFn;
}
var someFn = testFn();
someFn(20); // 30
第一类函数
定义
函数可以作为正常数据存在(例如:当参数传递,接受函数式参数或者以函数值返回)都称作第一类函数。
在 ECMAScript 中,所有的函数都是第一类对象。
自应用函数
定义
接受自己作为参数的函数,称为自应用函数(auto-applicative function 或者 self-applicative function)
自复制函数
定义
以自己为返回值的函数称为自复制函数(auto-replicative function 或者 self-replicative function)
在《深入 JavaScript 作用域链》一文中,我们谈到函数是可以封装在父函数中的,并可以使用父函数上下文的变量。这个特性会引发 funarg
问题。
Funarg 问题
Funarg 问题分为两类,分别为第一类问题和第二类问题。
第一类问题
提示
取决于是否将函数以返回值返回
在面向堆栈的编程语言中,函数的局部变量都是保存在栈上的,每当函数激活的时候,这些变量和函数参数都会压入到该堆栈上。
当函数返回的时候,这些参数又会从栈中移除。这种模型对将函数作为函数式值使用的时候有很大的限制(比方说,作为返回值从父函数中返回)。绝大部分情况下,问题会出现在当函数有自由变量的时候。举例如下:
function testFn() {
var localVar = 10;
function innerFn(innerParam) {
alert(innerParam + localVar);
}
return innerFn;
}
var someFn = testFn();
someFn(20); // 30
上述例子中,对于 innerFn 函数来说,localVar 就属于自由变量。
对于采用面向栈模型来存储局部变量的系统而言,就意味着当 testFn 函数调用结束后,其局部变量都会从堆栈中移除。这样一来,当从外部对 innerFn 进行函数调用的时候,就会发生错误(因为 localVar 变量已经不存在了)。
第二类问题
提示
取决于是否将函数当函数参数使用
第二类问题和当系统采用动态作用域,函数作为函数参数使用的时候有关。举例如下:
var z = 10;
function foo() {
alert(z);
}
foo(); // 10 – 使用静态和动态作用域的时候
(function() {
var z = 20;
foo(); // 10 – 使用静态作用域, 20 – 使用动态作用域
})();
/* 将 foo 作为参数的时候是一样的 */
(function(funArg) {
var z = 30;
funArg(); // 10 – 静态作用域, 30 – 动态作用域
})(foo);
我们看到,采用动态作用域,变量(标识符)的系统是通过变量动态栈来管理的。因此,自由变量是在当前活跃的动态链中查询的,而不是在函数创建的时候保存起来的静态作用域链中查询的。
这样就会产生冲突。比方说,即使 Z 仍然存在(与之前从栈中移除变量的例子相反),还是会有这样一个问题: 在不同的函数调用中,Z 的值到底取哪个呢(从哪个上下文,哪个作用域中查询)?
为了解决上述问题,就引入了 闭包的概念。
闭包
定义
闭包
闭包是代码块和创建该代码块的上下文中数据的结合。
来看下面这个例子(伪代码):
var x = 20;
function foo() {
alert(x); // 自由变量 "x" == 20
}
/* 为 foo 闭包 */
fooClosure = {
call: foo // 引用到 function
lexicalEnvironment: {x: 20} // 搜索上下文的上下文
};
上述例子中是为了强调在闭包创建的同时,上下文的数据就会保存起来。当下次调用该函数的时候,自由变量就可以在保存的(闭包)上下文中找到了,正如上述代码所示,变量“z”的值总是 10。
对于要实现将局部变量在上下文销毁后仍然保存下来,基于栈的实现显然是不适用的(因为与基于栈的结构相矛盾)。因此在这种情况下,上层作用域的闭包数据是通过动态分配内存的方式来实现的(基于“堆”的实现),配合使用垃圾回收器(garbage collector 简称 GC)和 引用计数(reference counting) ,这种实现方式比基于栈的实现性能要低。
ECMAScript 闭包的实现
接下来让我们来介绍下 ECMAScript 中闭包究竟是如何实现的,注意:ECMAScript 只使用静态(词法)作用域。
根据函数创建的算法,所有的函数都是闭包,因为它们都是在创建的时候就保存了上层上下文的作用域链(除开异常的情况)。
var x = 10;
function foo() {
alert(x);
}
/* foo 是闭包 */
foo: <FunctionObject> = {
[[Call]]: <code block of foo>,
[[Scope]]: [
global: {
x: 10
}
],
... // 其它属性
}
所有对象都引用一个[[Scope]]
在 ECMAScript 中,同一个父上下文中创建的闭包是共用一个 [[Scope]]
属性的。也就是说,某个闭包对其中 [[Scope]]
的变量做修改会影响到其他闭包对其变量的读取:
var firstClosure;
var secondClosure;
function foo() {
var x = 1;
firstClosure = function() {
return ++x;
};
secondClosure = function() {
return --x;
};
x = 2; // 影响 AO["x"], 在2个闭包公有的[[Scope]]中
alert(firstClosure()); // 3, 通过第一个闭包的[[Scope]]
}
foo();
alert(firstClosure()); // 4
alert(secondClosure()); // 3
关于这个功能有一个非常普遍的错误认识,举一个经典的例子:
var data = [];
for (var k = 0; k < 3; k++) {
data[k] = function() {
alert(k);
};
}
data[0](); // 3, 而不是0
data[1](); // 3, 而不是1
data[2](); // 3, 而不是2
上层上下文中的变量“k”是可以很容易就被,这样一来,在函数激活的时候,最终使用到的 k 就已经变成了 3 了。我们可以创建一个闭包来解决:
var data = [];
for (var k = 0; k < 3; k++) {
data[k] = (function _helper(x) {
return function() {
alert(x);
};
})(k); // 传入"k"值
}
data[0](); // 0
data[1](); // 1
data[2](); // 2
在函数激活时,每次“_helper”都会创建一个新的变量对象,其中含有参数“x”,“x”的值就是传递进来的“k”的值。这样一来,返回的函数的 [[Scope]]
就成了如下所示:
data[0].[[Scope]] === [
... // 其它变量对象
父级上下文中的活动对象AO: {data: [...], k: 3},
_helper上下文中的活动对象AO: {x: 0}
]
data[1].[[Scope]] === [
... // 其它变量对象
父级上下文中的活动对象AO: {data: [...], k: 3},
_helper上下文中的活动对象AO: {x: 1}
]
data[2].[[Scope]] === [
... // 其它变量对象
父级上下文中的活动对象AO: {data: [...], k: 3},
_helper上下文中的活动对象AO: {x: 2}
]
总结
因为作用域链,使得所有的函数都是闭包(与函数类型无关: 匿名函数,FE,NFE,FD 都是闭包)。
注意:
通过 Function 构造器创建的函数是例外,因为其 [[Scope]]
只包含全局对象。
ECMAScript 中,闭包指的是:
从理论角度:所有的函数。因为它们都在创建的时候就将上层上下文的数据保存起来了。哪怕是简单的全局变量也是如此,因为函数中访问全局变量就相当于是在访问自由变量,这个时候使用最外层的作用域。
从实践角度:以下函数才算是闭包:
1. 即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回) 2. 在代码中引用了自由变量