Fork me on GitHub

js深入理解闭包

必包理论上的定义

必包是什么

MDN对闭包的定义为:闭包是指那些能够访问自由变量的函数。

自由变量又是什么

自由变量是指在函数中使用的,但既不是函数参数也不是函数的局部变量的变量。

总结

闭包 = 函数 + 函数能够访问的自由变量

举例

1
2
3
4
5
6
7
var a = 1
function foo() {
trueconsole.log(a)
}
foo()

foo 函数可以访问变量 a,但是 a 既不是 foo 函数的局部变量,也不是 foo 函数的参数,所以 a 就是自由变量。

那么,函数 foo + foo 函数访问的自由变量 a 就构成了一个闭包。

理论和实践

所以在《JavaScript权威指南》中就讲到:从技术的角度讲,所有的JavaScript函数都是闭包。

这和我们平时看到的必包不一样?因为这是理论上的定义,其实还有一个实践角度的闭包

有关闭包的文章中是这样定义的:

  1. 理论角度:所有的函数。因为它们都在创建的时候就将上层上下文的数据保存起来了。哪怕是简单的全局变量也是如此,因为函数中访问全局变量就相当于访问自由变量,这个时候使用最外层的作用域。
  2. 实践角度:以下函数才算是闭包:

    • 即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)
    • 在代码中引用了自由变量

在总结实践角度的闭包之前,先简短的回顾一下有关上下文的知识点~

执行上下文(EC)

函数执行环境(或执行上下文),Execution Context。

JavaScript引擎并非一行一行地分析和执行程序,而是一段一段地分析执行,每次js引擎遇到可执行代码的时候,就会进入到一个执行上下文。

遇到哪些代码是可执行的呢?可执行代码类型:

  • 全局代码
  • 函数代码
  • eval代码

EC的三个重要属性

  • 变量对象(Variable object, VO)
  • 作用域链(Scope chain)
  • this
1
2
3
4
5
ECObj: {
scopeChain: { /* 变量对象(variableObject)+ 所有父级执行上下文的变量对象*/ },
variableObject: { /*函数 arguments/参数,内部变量和函数声明 */ },
this: {}
}

变量对象

变量对象是与执行上下文相关的数据作用域,存储在上下文中定义的变量和函数声明。

变量对象在全局上下文中就是全局对象,在函数上下文中用活动对象(activation object, AO)来表示,活动对象和变量对象其实是一个东西,只有到当进入一个执行上下文中,这个执行上下文中的变量对象才会被激活,而只有被激活的变量对象,也就是活动对象上的各种属性才能被访问。

  • 全局上下文的变量对象初始化是全局对象
  • 函数上下文的遍历对象初始化只包括 Arguments 对象

执行上下文的过程

执行上下文的代码会分成两个阶段进行处理:分析和执行

分析执行上下文

当进入执行上下文时,这时候还没有执行代码

会给变量对象添加形参、函数声明、变量声明等初始的属性值(会首先处理函数声明,其次处理变量声明,如果变量名称和已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性)

1
2
3
4
5
6
7
function foo(a) {
truevar b = 2
truefunction c() {}
truevar d = function() {}
trueb = 3
}
foo(1)

这个时候AO是:

1
2
3
4
5
6
7
8
9
10
AO = {
truearguments: {
truetrue0: 1,
truetruelength: 1
true},
truea: 1,
trueb: undefined,
truec: reference to function c() {},
trued: undefined
}

执行代码

在代码执行阶段,会顺序执行代码,再次修改变量对象的属性值,这时AO是:

1
2
3
4
5
6
7
8
9
10
AO = {
truearguments: {
truetrue0: 1,
truetruelength: 1
true},
truea: 1,
trueb: 3,
truec: reference to function c() {},
trued: reference to FunctionExpression "d"
}

思考题

  1. 第一题
1
2
3
4
5
6
7
8
9
10
11
12
function foo() {
trueconsole.log(a);
truea = 1;
}
foo(); // ???
function bar() {
truea = 1;
trueconsole.log(a);
}
bar(); // ???
  1. 第二题
1
2
3
4
5
6
7
console.log(foo);
function foo(){
trueconsole.log("foo");
}
var foo = 1;

作用域链

作用域链是内部上下文所有变量对象(包括父变量对象)的列表,此链用来变量查询。

当查找变量的时候,会先从当前上下文的变量对象中查找,如果没有找到,就会从父级(词法层面上的父级)执行上下文的变量对象中查找,一直找到全局上下文的变量对象,也就是全局对象。这样由多个执行上下文的变量对象构成的链表就叫做作用域链。下面通过函数的创建和调用这两个生命周期来看作用域链是如何变化的。

函数创建

函数有个内部属性 [[scope]], 在函数创建时被存储,静态(不变的),永远永远,直至函数销毁。

但是[[scope]]属性是虚拟出来的一个属性,我们实际访问时访问不到这个属性的,这个属性是为了让我们更好的理解函数,虚拟出来的一个属性。

JavaScript是词法作用域,函数的[[scope]]它定义的时候就决定了,就是这个函数父级的变量对象的层级链。

1
2
3
4
5
function foo() {
function bar() {
...
}
}

[[scope]]是函数的一个属性而不是上下文。考虑到上面的例子,各函数的[[scope]]如下:

1
2
3
4
5
6
7
8
foo.[[scope]] = [
globalContext.VO
];
bar.[[scope]] = [
fooContext.AO,
globalContext.VO
];

函数调用

当进入函数上下文,开始做准备工作

  1. 复制函数的[[scope]]属性创建作用域链
1
2
3
funcContext = {
Scope: func.[[scope]],
}
  1. 创建 VO/AO
  2. 将活动对象压入 checkscope 作用域链顶端
1
Scope = [AO].concat([[Scope]]);
  1. 准备工作做完,开始执行函数,随着函数的执行,修改 AO 的属性值

执行上下文栈

JavaScript 引擎创建了执行上下文栈(Execution context stack,ECS)来管理执行上下文。

为了模拟执行上下文栈的行为,让我们定义执行上下文栈是一个数组:

1
ECStack = []

并且假设现在我们遇到这样一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
function fun3() {
console.log('fun3')
}
function fun2() {
fun3();
}
function fun1() {
fun2();
}
fun1();

当 JavaScript 开始要解释执行代码的时候,最先遇到的就是全局代码,所以初始化的时候首先就会向执行上下文栈压入一个全局执行上下文,我们用 globalContext 表示它,并且只有当整个应用程序结束的时候,ECStack 才会被清空,所以程序结束之前, ECStack 最底部永远有个 globalContext:

1
2
3
ECStack = [
globalContext
];

当执行一个函数的时候,就会创建一个执行上下文,并且压入执行上下文栈,当函数执行完毕的时候,就会将函数的执行上下文从栈中弹出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// fun1()
ECStack.push(<fun1> functionContext);
// fun1中竟然调用了fun2,还要创建fun2的执行上下文
ECStack.push(<fun2> functionContext);
// 擦,fun2还调用了fun3!
ECStack.push(<fun3> functionContext);
// fun3执行完毕
ECStack.pop();
// fun2执行完毕
ECStack.pop();
// fun1执行完毕
ECStack.pop();

实践角度的闭包(狭义)

  • 即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)
  • 在代码中引用了自由变量

在弄清楚什么是执行上下文和执行上下文栈之后,我们终于回到了实践角度的闭包。

分析

1
2
3
4
5
6
7
8
9
10
11
var scope = "global scope";
function checkscope(){
var scope = "local scope";
function f(){
return scope;
}
return f;
}
var foo = checkscope();
foo();
  1. 开始执行代码时,创建全局上下文,并压入执行上下文栈
  2. 执行上下文初始化和分析
  3. 执行代码,遇到checkscope(),创建checkscope函数执行上下文,并被压入执行上下文栈
  4. checkscope函数执行上下文进行初始化和分析,创建对象变量、作用域链、this等
  5. checkscope函数执行完毕,checkscope函数执行上下文弹出栈,返回f函数赋给foo变量
  6. 执行f函数,创建f函数执行上下文,并压入执行上下文栈
  7. f函数执行上下文进行初始化和分析,创建对象变量、作用域链、this等
  8. 执行f函数,读取scope变量,从当前的上下文对象变量中查找,没有找到,再从父级(词法层面上的父级)执行上下文的变量对象中查找,也就是checkscope函数执行上下文的变量对象中查找,找到了,值为"local scope"

f 函数执行的时候,checkscope 函数上下文已经被销毁了啊(即从执行上下文栈中被弹出),怎么还会读取到 checkscope 作用域下的 scope 值呢?

因为f函数执行上下文维护一个作用域链:

1
2
3
fContext = {
Scope: [AO, checkscopeContext.AO, globalContext.VO],
}

就是因为这个作用域链,f 函数依然可以读取到 checkscopeContext.AO 的值,说明当 f 函数引用了 checkscopeContext.AO 中的值的时候,即使 checkscopeContext 被销毁了,但是 JavaScript 依然会让 checkscopeContext.AO 活在内存中,f 函数依然可以通过 f 函数的作用域链找到它,正是因为 JavaScript 做到了这一点,从而实现了闭包这个概念。

闭包这种东西,其实就是基于这些规则产生的自然而然的现象。

思考题

  1. 第一题
1
2
3
4
5
6
7
8
9
10
11
var data = [];
for (var i = 0; i < 3; i++) {
data[i] = function () {
console.log(i);
};
}
data[0]();
data[1]();
data[2]();
  1. 第二题
1
2
3
4
5
6
7
8
9
10
11
12
13
var data = [];
for (var i = 0; i < 3; i++) {
data[i] = (function (i) {
return function(){
console.log(i);
}
})(i);
}
data[0]();
data[1]();
data[2]();

参考文章

汤姆大叔的博客
JavaScript深入之闭包

undefined