闭包

闭包

  和大多数现代编程语言一样,JavaScript也采用词法作用域,也就是说,函数的执行依赖于变量作用域,这个作用域是在函数定义时决定的,而不是函数调用时决定的。为了实现这这种词法作用域,JavaScript函数对象的内部状态不仅包含函数的逻辑代码,还必须引用当前的作用域链。函数对象可以通过作用域链相互关联起来,函数体内部的变量都可以保存在函数作用域内,这种特性被称为"闭包"

  作用域链:每一段JavaScript代码(全局代码或函数)都有一个与之关联的作用域链(scope chain)。这个作用域链是一个对象列表或者链表,这组对象定义了这段代码”作用域中”的变量。当JavaScript需要查找变量x的值的时候(这个过程称做”变量解析”),它会从链中的第一个对象开始查找,如果这个对象有一个名为x的属性,则会直接使用这个属性的值,如果第一个对象中不存在名为x的属性,JavaScript会继续查找链上的下一个对象。如果第二个对象依然没有名为x的属性,则会继续查找下一个对象,以此类对。如果作用域链上没有任何一个对象含有属性x,那么久人为这段代码的作用域链上不存在x,并最终抛出一个引用错误异常。
  在JavaScript最顶层代码中(也就是不包含在任何函数定义内的代码),作用域链由一个全局对象组成。在不包含嵌套的函数体内,作用域链上有两个对象,第一个是定义函数参数和局部变量的对象,第二个是全局对象。在一个嵌套的函数体内,作用域链上至少有三个对象。当定义一个函数时,它实际上保存一个作用域链。当调用这个函数时,它穿件一个新的对象来存储它的局部变量,并将这个对象添加至保存的那个作用域链上,同时创建一个新的更长的表示函数调用作用域的”链”。对于嵌套函数来讲,每次调用外部函数时,内部函数又会重新定义一遍。因为每次调用外部函数的时候,作用域链都是不同的。内部函数在每次定义的时候都有微妙的差别——在每次调用外部函数时,内部函数的代码都是相同的,而且关联这段代码的作用域链也不相同。

  所有的JavaScript函数都是闭包;他们都是对象,他们都关联到作用域链。定义大多数函数时的作用域链在调用函数时依然有效,但这并不影响闭包。当调用函数时闭包所指向的作用域链和定义函数时的作用域链不是同一个作用域链时,就会有一些微妙的变化。当一个函数潜逃了另外一个函数,外部函数将嵌套的函数对象作为返回值返回的时候往往会发生这种事情。有很多强大的编程技术都利用到了这类嵌套的函数闭包,以至于这种编程模式在JavaScript中非常常见。
  理解闭包首先要了解嵌套函数的此法作用域规则:

1
2
3
4
5
6
7
8
9
var scope = "global scope";  // 全局变量
function checkscope() {
var scope = "local scope"; // 局部变量
function f() {
return scope; // 在作用域中返回这个值
}
return f();
}
checkscope() // => "local scope"

  checkscope()函数声明了一个局部变量,并定义一个函数f(),函数f()返回了这个变量的值,最后将函数f()的执行结果返回。对上面的代码做一点改动:

1
2
3
4
5
6
7
8
9
var scope = "global scope";  // 全局变量
function checkscope() {
var scope = "local scope"; // 局部变量
function f() {
return scope; // 在作用域中返回这个值
}
return f;
}
checkscope()()

  这段代码中,函数内的一对圆括号移动到了checkscope()之后。checkscope()现在仅仅返回函数内嵌套的一个函数对象,而不是直接返回结果。在定义函数的作用域外面,调用这个嵌套的函数(包含最后一行代码的最后一对括号),最后仍会返回"local scope"
  词法作用域的基本规则:JavaScript函数的执行用到了作用域链,这个作用域链是函数定义的时候创建的。嵌套的函数f()定义在这个作用域链里,其中的变量scope一定是局部变量,不管在何时何地执行函数f(),这种绑定在执行f()时依然有效,因此最后一行代码返回"local scope",而不是"global scope"。简言之,闭包这个特性很强大:它们可以捕捉到局部变量(和参数),并一直保存下来,看起来像这些变量绑定到了再其中定义它们的外部函数。
例如,定义一个uniqueInteger函数:

1
2
3
4
5
6
7
8
9
// 初始化函数对象的计数器属性
// 由于函数声明被提前,因此这里可以在函数声明前给它的成员赋值
uniqueInteger.counter = 0;

// 每次调用这个函数都会返回一个不同的整数
// 它使用一个属性来记住下一次将要返回的值
function uniqueInteger() {
return uniqueInteger.counter++; // 先返回计数器的值,然后计数器自增1
}

  这个函数使用自身的一个属性来保存每次返回的值,以便每次调用都能跟从上次的返回值。但这种做法有一个问题,就是恶意代码可能将计数器重置或者把一个非整数赋值给它,导致uniqueInteger()函数不一定能产生”唯一”的“整数”。而闭包可以捕捉到单个函数调用的局部变量,并将这些局部变量用作私有状态。利用闭包重写uniqueInteger()函数:

1
2
3
4
var uniqueInteger = (function () { // 定义函数并立即调用
var counter = 0; // 函数的私有状态
return function() { return counter++; }
}());

  第一行代码看起来像将函数赋值给一个变量uniqueInteger,实际上,这段代码定义了一个立即调用函数(函数的开始带有左圆括号),因此是这个函数的返回值赋值给变量uniqueInteger。这个函数体返回另外一个函数,这是一个嵌套的函数,我们将它赋值给变量uniqueInteger,嵌套的函数是可以访问作用域内的变量的,而且可以访问外部函数中定义的counter变量。当外部函数返回值后,其他任何代码都无法访问counter变量,只有内部的函数才能访问到它。
  像counter一样的私有变量不是只能用在一个单独的闭包内,在同一个外部函数内定义的多个嵌套函数也可以访问它,这多个嵌套函数都共享一个作用域链:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function counter() { 
var n = 0;
return {
count: function() { return n++; },
reset: function() { n = 0; }
};
}

var c = counter(), d = counter(); // 创建两个计数器
c.count(); // => 0
d.count(); // => 0:他们互不干扰
c.reset(); // reset()和count()方法共享状态
c.count(); // => 0:因为重置了c
d.count(); // => 1:没有重置d

  counter()函数返回了一个“计数器”对象,这个对象包含两个方法:count()返回下一个整数,reset()将计数器重置为内部状态。这两个方法都可以访问私有变量n。再者,每次调用counter()都会创建一个新的作用域链和一个新的私有变量。因此,如果调用counter()量词,则会得到两个计数器对象,而且彼此包含不同的私有变量,调用其中一个计数器对象的count()reset()不会影响到另外一个对象。
  其实可以将这个闭包合并为属性存取器方法gettersetter。如下,利用闭包实现counter()的私有状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function counter(n) { // 函数参数n是一个私有变量
return {
// 属性getter方法返回并给私有计数器var递增1
get count() { return n++; },
// 属性setter不允许n递减
set count(m) {
if (m>=n) n = m;
else throw Error("count can only be set to a larger value");
}
};
}

var c = counter(1000);
c.count // => 1000
c.count // => 1000
c.count = 2000
c.count // => 2000
c.count = 2000 // => Error!

  这个counter()函数并为声明局部变量,而只是使用参数n来保存私有状态,属性存取器方法可以访问n。这样调用counter()的函数就可以指定私有变量的初始值了。
  定义一个addPrivatePropetry()函数,这个函数定义一个私有变量,以及两个嵌套的函数用来获取和设置这个私有变量的值,它将这些嵌套函数添加为所指定对象的方法,利用闭包实现的私有属性存取器方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 这个函数给对象o增加了属性存取器方法
// 方法名称为get<name>和set<name>。如果提供了一个判定函数
// setter方法就会用它来检测参数的合法性,然后在存储它
// 如果判定函数返回false,setter方法抛出一个异常

// 这个函数不的getter和setter函数
// 所操作的属性值并没有存储在对象o中
// 相反,这个值仅仅是保存在函数中的局部变量中
// getter和setter方法同样是局部函数,因此可以访问这个局部变量
// 也就是说,对于两个存取器的方法来说这个变量是私有的
// 没有办法绕过存存取器来设置或修改这个值
function addPrivatePropetry(o, name, predicate) {
var value; // 这是一个属性值

// getter方法简单地将其返回
o["get" + name] = function() { return value; };

// setter方法首先检查值是否合法,若不合法就抛出异常
// 否则就将其存储
o["set" + name] = function(v) {
if (predicate && !predicate(v)) {
throw Error("set" + name + ": invalid value" + v);
} else {
value = v;
}
};
}

// addPrivatePropetry()方法
var o = {}; // 设置一个空对象
// 增加属性存取器方法getName()和setName()
// 确保只允许字符串值
addPrivatePropetry(o, "Name", function(x) { return typeof x == "string"; });

o.setName("Frank"); // 设置属性值
console.log(o.getName()); // 得到属性值
o.setName(o); // 设置一个错误类型的值

  在同一个作用域中定义两个闭包,这两个闭包共享同样的私有变量或变量。这是很重要的一种方式,但要小心那些不希望共享的变量旺旺不经意间共享给了其他的闭包,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 这个函数返回一个总是返回v的函数
function constfunc(v) {
return function() { return v };
}

// 创建一个数组用来存储常数函数
var funcs = [];
for (var i = 0; i < 10; i++) {
funcs[i] = constfunc(i);
}

// 在第五个位置的元素所表示的函数值返回5
funcs[5]() // => 5

  这段代码利用循环创建了很多歌闭包,当写类似这种代码的时候往往会犯一个错误:那就是师徒将循环代码移入定义这个闭包的函数之内,比如:

1
2
3
4
5
6
7
8
9
10
11
// 返回一个函数组成的数组,它们的返回值是0~9
function constfunc() {
var funcs = [];
for (var i = 0; i < 10; i++) {
funcs[i] = function() { return i; };
}
return funcs;
}

var funcs = constfunc();
funcs[5]() // ??

  上面这段代码创建了10个闭包,并将它们存储到一个数组中。这些闭包都是在同一个函数调用中定义的,因此它们可以共享变量i。当constfunc()返回时,变量i的值是10,所有的闭包都共享这一个值,因此,数组中的函数的返回值都是同一个值,这不是想要的结果。关联到闭包的作用域链都是“活动的”。嵌套的函数不会将作用域链内的私有成员复制一份,也不会对所绑定的变量生成静态快照。
  this是JavaScript的关键字,而不是变量。每个函数调用都包含一个this值, 如果闭包在外部函数里是无法访问this的,除非外部函数将this转为一个变量:

1
var self = this; // 将this保存至一个变量中,以便嵌套的函数能够访问它

  绑定arguments的问题与之类似。arguments并不是一个关键字,但是在调用每个函数时都会自动声明它,由于闭包具有自己所绑定的arguments,因此闭包内无法直接访问外部函数的参数数组,除非外部函数将参数数组保存到另外一个变量中:

1
var outerArguments = arguments; // 保存起来以便嵌套的函数能使用它

上次更新 2020-04-01