深入 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. 在代码中引用了自由变量
    
最后更新时间: 2019-7-7 21:55:38