作用域链与闭包
了解作用域链之前需要先了解下作用域是什么。
作用域
几乎所有的语言都有作用域的概念。这是因为它们都有变量这一概念。而程序代码中所用到的变量并不总是有效或者可用的,因此需要限定变量的可用性范围,这就是作用域。
也就是说作用域规定了当前执行代码对变量的访问权限,在变量作用域之外是没有访问权限的。
静态作用域与动态作用域
不同的语言在设计的时候规定的作用域是不同的,一般分为静态作用域和动态作用域。
- 静态作用域
静态作用域是指声明的作用域是根据程序正文在编译时就确定的,有时也称为词法作用域。
静态作用域关注函数在何处声明。
- 动态作用域
动态作用域是在运行时根据程序的流程信息来动态确定的。
动态作用域关注函数从何处调用,其作用域链是基于运行时的调用栈的。
事实上大部分语言都是基于静态作用域的,JavaScript 也是这样。比如下面的例子。
var value = 1;
function foo() {
console.log(value);
}
function bar() {
var value = 2;
foo();
}
bar();
// 结果是 ???
分下一下执行过程。
- 执行 bar 函数
- 进入到 bar 函数体内
- 调用 foo 函数
- 进入 foo 函数体内
- 打印 value 的值的时候要先去查找 value 的值
- foo 函数体内部没有 value 变量
- 因此需要去上一级代码查找,由于 JavaScript 是静态作用域,需要去 foo 声明的地方查找
- 找到了 value 的值等于 1
- 输出 1
总结一下就是作用域范围是在函数定义的时候就确定下来的,理解这一点非常重要。
另外这个结论可以帮助你快速确定作用域,但是要彻底了解原理还需要知道接下来的内容。
作用域链
在上篇文章 《执行上下文和执行栈》中讲到函数执行的时候会创建执行上下文,在执行上下文生成的过程中,会分别确定变量对象,作用域链,以及this的值。
而且我们知道了,函数的执行上下文在创建的时候会扫描当前上下文中声明的变量和函数,并将其初始化并保存到上下文对应的 VO 变量对象中。
那么当函数中访问到当前执行上下文中没有声明的变量的时候该怎么办呢?
这时候就会从父级(词法层面上的父级)执行上下文的变量对象中查找,一直找到全局上下文的变量对象,也就是全局对象。这样由多个执行上下文的变量对象构成的链表就叫做作用域链。
我们通过下面代码从函数的创建和激活两个时期来讲解作用域链是如何构建出来的。
function foo() {
function bar() {
...
}
}
函数创建阶段
在介绍作用域的时候我们知道了函数的作用域在它定义的时候就决定了。这是因为函数也是对象,它有一个属性 [[scope]]
(内部属性,只有 JS 引擎可以访问, 但 FireFox 的几个引擎提供了私有属性 __parent__
来访问它)。
函数在创建的时候会将 [[scope]]
属性链接到它父级(词法层面上的父级)执行上下文的变量对象组成的链表中。
foo 函数创建阶段
[[scope]] = [
globalContext.VO
]
bar 函数创建阶段
[[scope]] = [
fooContext.AO,
globalContext.VO
]
注意:
-
[[scope]]
内保存的是词法层面父级执行上下文的作用域链 -
[[scope]]
并不代表作用域链,它是函数的一个属性 - bar 函数的
[[scope]]
中是fooContext.AO
而不是 VO 是因为在 bar 函数创建的时候 foo 函数已经处于激活阶段了
函数激活阶段
函数激活的时候会创建上下文,先去创建 VO/AO 对象。然后就会将 AO 对象插入到 [[scope]]
属性的链表的底部,组成新的链表。这个链表就是当前上下文的作用域链。
foo 函数激活阶段
[[scopeChain]] = [
fooContext.AO,
globalContext.VO
]
bar 函数激活阶段
[[scopeChain]] = [
barContext.AO,
fooContext.AO,
globalContext.VO
]
可以用下面的公式来总结
scope chain = VO + All Parent VOs
注意:
-
scopeChain
是执行上下文的一个属性 - 在发生标识符解析的时候, 就会逆向查询当前 scope chain 列表的每一个活动对象的属性,如果找到同名的就返回。找不到,那就是这个标识符没有被定义。
步骤分解
我们通过下面的例子结合执行上下文来解析下具体的过程。
var a = 1
function foo() {
var b = 2
function bar() {
console.log(a + b)
}
bar()
}
foo()
1. 代码开始执行,创建全局上下文
1.1 创建 VO 对象
全局上下文在创建的时候会先创建 VO 对象。全局上下文比较特殊,这个变量对象其实就是全局对象 window。
1.2 扫描上下文
扫描上下文中变量发现了变量 a,然后将其赋值给 VO ,也就是 window。因此 a 就是 window 的一个属性。
扫描到 foo 函数,将其指针存入 VO 变量。
1.3 确定作用域链
由于没有父级执行上下文,因此全局上下文的 scopeChain
只有自己的变量对象。
globalContext = {
VO: {
a: undefined,
foo: pointer to function foo()
},
scopeChain: [
globalContext.VO
]
}
2. 全局上下文激活
全局上下文激活后变成如下状态
globalContext = {
AO: {
a: 1,
foo: pointer to function foo()
},
scopeChain: [
globalContext.AO
]
}
与此同时 foo 函数被创建,将保存其所在的词法层面的父级执行上下文的作用域链
foo.[[scope]] = globalContext.scopeChain
等同于
foo.[[scope]] = [
globalContext.AO
]
3. foo 函数执行上下文创建阶段
3.1 创建 VO 对象
先创建 VO 对象。
3.2 扫描上下文
扫描上下文中变量发现了变量 b,然后将其赋值给 VO 。因此 b 就是 fooContext.VO
的一个属性。
扫描到 bar 函数,将其指针存入 VO 变量。
3.3 确定作用域链
找到语义层面的父级执行上下文 globalContext
,并向 globalContext.scopeChain
中加入当前的变量对象。
fooContext = {
VO: {
b: undefined,
bar: pointer to function bar()
},
scopeChain: [
fooContext.VO,
globalContext.AO
]
}
4. foo 函数执行上下文激活阶段
fooContext = {
AO: {
b: 2,
bar: pointer to function bar()
},
scopeChain: [
fooContext.AO,
globalContext.AO
]
}
同时 bar 函数被创建
bar.[[scope]] = foo.scopeChain
等同于
bar.[[scope]] = [
fooContext.AO,
globalContext.AO
]
5. bar 函数执行上下文创建阶段
5.1 创建 VO 对象
先创建 VO 对象。
5.2 扫描上下文
扫描上下文中变量未发现变量声明
5.3 确定作用域链
找到语义层面的父级执行上下文 fooContext
,并向 fooContext.scopeChain
中加入当前的变量对象。
barContext = {
VO: {},
scopeChain: [
barContext.VO,
fooContext.AO,
globalContext.AO
]
}
6. bar 函数执行上下文激活阶段
fooContext = {
AO: {},
scopeChain: [
barContext.AO,
fooContext.AO,
globalContext.AO
]
}
执行到输出语句的时候
console.log(a + b)
- 先去当前
VO/AO
对象中查找,没有找到 - 顺着
scopeChain
查找 - 在
fooContext.AO
中找到b = 2
,继续往上找 - 在
globalContext.AO
中找到a = 1
- 计算并输出结果
3
闭包
闭包的定义是:
闭包是指那些能够访问自由变量的函数。
什么是自由变量呢?
自由变量是指在函数中使用的,但既不是函数参数也不是函数的局部变量的变量。
因此闭包就是由函数和自由变量组成的。
理论上所有的函数都是闭包。这是因为函数在创建的时候会将上层父级上下文中的数据保存到它的 [[scope]]
参数中。因此从这个角度来说函数都捕获了自由变量。
但是在实践中我们只把满足下面条件的函数称为闭包:
- 即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)
- 在代码中引用了自由变量
我们看以下的例子
function foo() {
var name = "Mozilla"
function f() {
alert(name)
}
return f
}
var bar = foo()
bar()
这里 bar 函数就是一个闭包。
首先:bar 执行的时候它的父级执行上下文 bar 函数的上下文已经从执行栈中出栈了。
其次:bar 中引用了自由变量 name
这里估计你会有疑问:name 所在的 foo 函数的执行上下文已经出栈了,为什么还能访问呢?
这其实就是作用域链的作用,经过分析可以知道 bar 函数执行上下文的作用域链如下
scopeChain: [
barContext.AO,
fooContext.AO,
globalContext.AO
]
虽然 fooContext
已经出栈销毁了,但是 fooContext.AO
依然存在。因此依然可以通过作用域链来访问。
必刷题
这是一个闭包面试的必刷题
var data = [];
for (var i = 0; i < 3; i++) {
data[i] = function () {
console.log(i);
};
}
data[0]();
data[1]();
data[2]();
答案是都是 3,我们来详细分析一下
1. 全局作用域创建阶段
globalContext = {
VO: {
data: undefined,
i: undefined
},
scopeChain: [
globalContext.VO
]
}
2. 全局作用域激活阶段
执行到 data[0]()
的时候
globalContext = {
VO: {
data: [...],
i: 3
},
scopeChain: [
globalContext.VO
]
}
同时 data[0]
所指的函数被创建,作用域如下
data[0].[[scope]]= [
globalContext.VO
]
3. data[0] 函数执行上下文创建阶段
data[0]Context = {
VO: { },
scopeChain: [
data[0]Context.VO,
globalContext.AO
]
}
4. data[0] 函数执行上下文激活阶段
data[0]Context = {
AO: { },
scopeChain: [
data[0]Context.AO,
globalContext.AO
]
}
data[0]Context.AO
里没有 i 的值,因此会沿着作用域链往上查找,然后在 globalContext.AO
中找到了 i = 3
。
data[1]
、data[2]
,是一样的道理。
总结
- JavaScript 是静态作用域,作用域范围是在函数定义的时候就确定下来的。
- 作用域链是执行上下文的一个属性,作用域链让闭包访问自由变量成为可能。
参考
- Identifier Resolution and Closures in the JavaScript Scope Chain
- JavaScript深入之作用域链
- Javascript作用域原理
- 前端基础进阶(四):详细图解作用域链与闭包
- JavaScript深入之闭包
- 闭包