学习链接
汤姆大叔:深入理解JavaScript系列(11-16👍👍👍,大部分内容来自这里,感谢作者!)
作用域与作用域链
(未考虑 let
和 const
)
变量对象
如果变量与执行上下文相关,那变量自己应该知道它的数据存储在哪里,并且知道如何访问。这种机制称为变量对象(Variable Object)。
变量对象(缩写为VO)是一个与执行上下文相关的特殊对象,它存储着在上下文中声明的以下内容:
变量 (var, 变量声明);
函数声明 (FunctionDeclaration, 缩写为FD);
函数的形参
VO 就是执行上下文的属性(property):
activeExecutionContext = {
VO: {
// 上下文数据(var, FD, function arguments)
}
};
只有全局上下文的变量对象允许通过 VO 的属性名称来间接访问(因为在全局上下文里,全局对象自身就是变量对象),在其它上下文中是不能直接访问VO对象的,因为它只是内部机制的一个实现。
当我们声明一个变量或者函数的时候,其实就是在 VO 上创建一个新的属性。
var a = 10;
function test(x) {
var b = 20;
};
test(30);
对应的变量对象是:
// 全局上下文的变量对象
VO(globalContext) = {
a: 10,
test: <reference to function>
};
// test函数上下文的变量对象
VO(test functionContext) = {
x: 30,
b: 20
};
在具体实现层面(以及规范中)变量对象只是一个抽象概念。
(从本质上说,在具体执行上下文中, VO 名称是不一样的,并且初始结构也不一样。)
不同执行上下文中的变量对象
抽象变量对象VO (变量初始化过程的一般行为)
║
╠══> 全局上下文变量对象 GlobalContextVO
║ (VO === this === global)
║
╚══> 函数上下文变量对象 FunctionContextVO
(VO === AO, 并且添加了<arguments>和<formal parameters>)
全局上下文中的变量对象
全局对象(Global Object) 是在进入任何执行上下文之前就已经创建了的对象;
这个对象只存在一份,它的属性在程序中任何地方都可以访问,全局对象的生命周期终止于程序退出那一刻。
global = {
Math: <...>,
String: <...>
...
...
window: global //引用自身
};
当访问全局对象的属性时通常会忽略掉前缀,这是因为全局对象是不能通过名称直接访问的。不过我们依然可以通过全局上下文的 this 来访问全局对象,同样也可以递归引用自身,例如,DOM 中的 window。
全局上下文中的【变量对象】就是【全局对象】本身。
VO(globalContext) === global
直接访问变量对象,通过全局对象间接访问
var a = new String('test');
alert(a); // 直接访问,在VO(globalContext)里找到:"test"
alert(window['a']); // 间接通过global访问:global === VO(globalContext): "test"
alert(a === this.a); // true
var aKey = 'a';
alert(window[aKey]); // 间接通过动态属性名称访问:"test"
函数上下文中的变量对象
在函数执行上下文中,VO 是不能直接访问的,此时由活动对象(Activation Object,缩写为AO)扮演 VO 的角色。
VO(functionContext) === AO;
活动对象是在进入函数上下文时刻被创建的,它通过函数的 arguments 属性初始化。arguments 属性的值是 Arguments 对象:
AO = {
arguments: <ArgO>
};
Arguments 对象是活动对象的一个属性,它包括如下属性:
- callee — 指向当前函数的引用
- length — 真正传递的参数个数
- properties-indexes(字符串类型的整数)属性的值就是函数的参数值(按参数列表从左到右排列)。 properties-indexes 内部元素的个数等于 arguments.length,properties-indexes 的值和实际传递进来的参数之间是共享的。
执行上下文
- 进入执行上下文
- 执行代码
进入执行上下文
当进入执行上下文(代码执行之前)时,VO 里已经包含了下列属性(开头已经说了):
函数的所有形参(如果在函数执行上下文中)
- 由名称和对应值组成的一个变量对象的属性被创建;没有传递对应参数的话,那么由名称和undefined值组成的一种变量对象的属性也将被创建。
所有函数声明(FunctionDeclaration, FD)
- 由名称和对应值(函数对象(function-object))组成一个变量对象的属性被创建;如果变量对象已经存在相同名称的属性,则完全替换这个属性。
所有变量声明(var, VariableDeclaration)
- 由名称和对应值(undefined)组成一个变量对象的属性被创建;如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性。
让我们看一个例子:
function test(a, b) {
var c = 10;
function d() {}
var e = function _e() {};
(function x() {});
}
test(10); // call
当进入带有参数10的test函数上下文时,AO表现为如下:
AO(test) = {
a: 10,
b: undefined,
c: undefined,
d: <reference to FunctionDeclaration "d">
e: undefined
};
注意,AO 里并不包含函数 x
。这是因为 x
是一个函数表达式(FunctionExpression,缩写为 FE)而不是函数声明,函数表达式不会影响 VO。 不管怎样,函数 _e
同样也是函数表达式,但是就像我们下面将看到的那样,因为它分配给了变量 e
,所以它可以通过名称 e
来访问。
代码执行
这个周期内,AO/VO 已经拥有了属性(不过,并不是所有的属性都有值,大部分属性的值还是系统默认的初始值 undefined )。
还是前面那个例子, AO/VO在代码解释期间被修改如下:
AO['c'] = 10;
AO['e'] = <reference to FunctionExpression "_e">;
再次注意,因为 FunctionExpression _e
保存到了已声明的变量 e
上,所以它仍然存在于内存中。而 FunctionExpression x
却不存在于 AO/VO 中,也就是说如果我们想尝试调用 x
函数,不管在函数定义之前还是之后,都会出现一个错误 x is not defined
,未保存的函数表达式只有在它自己的定义或递归中才能被调用。
关于 this
this 是执行上下文中的一个属性:
activeExecutionContext = {
VO: {...},
this: thisValue
};
这里 VO 是变量对象。
this 与上下文中可执行代码的类型有直接关系,this 值在进入上下文时确定,并且在上下文运行期间永久不变。
规则
-
如果没有显示标明 this 的值(call、apply),JavaScript 引擎就会根据调用语句去推断 this 的值。
-
引擎会试图将调用语句格式化为
[对象名].[函数名]()
。如果能够格式化,this 就为上述对象。否则,this 只好取 window(非严格模式)。 -
对于
[函数名]()
的调用,引擎会先根据作用域链找到隐式的对象(变量对象)。-
由于全局上下文的变量对象、动态(with、catch)变量对象是可以给用户访问的,因此格式化成功。
-
函数上下文的变量对象不能给用户直接访问,因此格式化失败。
-
-
对于
(表达式)()
的调用,由于表达式不可能属于任何对象,因此格式化失败。
作用域链
作用域链(Scope Chain)是与执行上下文相关的变量对象链,解析标识符名称时会在其中搜索变量(即用于变量查询)。
函数上下文的作用域链在函数调用时创建的,包含活动对象和这个函数内部的 [[Scope]]
属性。
在上下文中示意如下:
activeExecutionContext = {
VO: {...}, // or AO
this: thisValue,
Scope: [ // Scope Chain
// 所有变量对象的列表
// 用于变量查询
]
};
其 Scope 定义如下:
Scope = AO + [[Scope]]
函数生命周期
函数的的生命周期分为创建和激活阶段(调用时)。
函数创建(未进入函数上下文时)
函数声明在进入上下文阶段(这里是指全局上下文)就属于变量对象(VO)/ 活动对象(AO)。
var x = 10;
function foo() {
var y = 20;
alert(x + y);
}
foo(); // 30
在 foo 的上下文的活动对象中并没有 x
,这需要借助作用域链访问。
fooContext.AO = {
y: undefined
};
(上面是进入假设进入上下文之后的讨论)
[[Scope]]
是所有父变量对象(VO)的层级链,处于当前函数上下文之上,在函数创建时存于其中。
注意关键的一点:
[[Scope]]
在函数创建时被写入函数,是静态的(不变的),直至函数被销毁。
函数可能永不调用,但 [[Scope]]
属性已经写入,并存储在函数对象中。
另一点值得注意的是,与作用域链相比,[[Scope]]
是函数的一个属性而不是上下文。
foo.[[Scope]] = [
globalContext.VO // === Global
];
函数激活
进入函数上下文创建 AO/VO 之后,上下文的 Scope 属性(变量查找的一个作用域链)作如下定义:
Scope = AO|VO + [[Scope]]
上面代码的意思是:活动对象是作用域数组的第一个对象,即添加到作用域的前端。
Scope = [AO].concat([[Scope]]);
这个特点对于标示符解析的处理来说很重要。
标识符解析是通过名称确定变量(或函数声明)属于作用域链中哪些变量对象的过程。
-
也就是说,当进行标识符解析(即变量查找)的时候,会从最深的上下文开始沿着作用域链不断往上寻找
-
也就是 Scope 数组中从前往后在各个上下文的变量/活动对象中按顺序查找变量的值
-
直到在某个变量/活动对象中找到有对应的变量名称的属性
-
一个上下文中的局部变量较之于父作用域的变量拥有较高的优先级
例子
var x = 10;
function foo() {
var y = 20;
function bar() {
var z = 30;
alert(x + y + z);
}
bar();
}
foo(); // 60
全局上下文的变量对象 VO 是:
globalContext.VO === Global = {
x: 10
foo: <reference to function>
};
在 foo 创建时,foo 的 [[Scope]]
属性是:
foo.[[Scope]] = [
globalContext.VO
];
在 foo 激活时(进入上下文),foo 上下文的活动对象 AO 是:
fooContext.AO = {
y: 20,
bar: <reference to function>
};
foo 上下文的作用域链为:
fooContext.Scope = fooContext.AO + foo.[[Scope]]
👇
fooContext.Scope = [
fooContext.AO,
globalContext.VO
];
内部函数 bar 创建时,其 [[Scope]]
为:
bar.[[Scope]] = [
fooContext.AO,
globalContext.VO
];
在 bar 激活时,bar 上下文的活动对象 AO 为:
barContext.AO = {
z: 30
};
bar 上下文的作用域链为:
barContext.Scope = barContext.AO + bar.[[Scope]]
👇
barContext.Scope = [
barContext.AO,
fooContext.AO,
globalContext.VO
];
对 x、y、z
的标识符解析如下:
- "x"
-- barContext.AO // not found
-- fooContext.AO // not found
-- globalContext.VO // found - 10
- "y"
-- barContext.AO // not found
-- fooContext.AO // found - 20
- "z"
-- barContext.AO // found - 30
闭包
ECMAScript 只使用静态(词法)作用域。
var x = 10;
function foo() {
alert(x);
}
(function (funArg) {
var x = 20;
// 变量"x"在(lexical)上下文中静态保存的,在该函数创建的时候就保存了
funArg(); // 10, 而不是20
})(foo);
技术上说,创建该函数的父级上下文的数据是保存在函数的内部属性 [[Scope]]
中的。如果你还不了解什么是 [[Scope]]
。
根据函数创建的算法,在ECMAScript中,所有的函数都是闭包,因为它们都是在创建的时候就保存了上层上下文的作用域链(除开异常的情况) (不管这个函数后续是否会激活 —— [[Scope]]
在函数创建的时候就有了):
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
上述例子就证明了 —— 同一个上下文中创建的闭包是共用一个[[Scope]]属性的。因此上层上下文中的变量 k
是可以很容易就被改变的。
activeContext.Scope = [
... // 其它变量对象
{data: [...], k: 3} // 活动对象
];
data[0].[[Scope]] === Scope;
data[1].[[Scope]] === Scope;
data[2].[[Scope]] === Scope;
这样一来,在函数激活的时候,最终使用到的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}
];
我们看到,这时函数的 [[Scope]]
属性就有了真正想要的值了,为了达到这样的目的,我们不得不在 [[Scope]]
中创建额外的变量对象。要注意的是,在返回的函数中,如果要获取 k
的值,那么该值还是会是 3。
又一个例子
for(var i = 0; i < 5; i++) {
setTimeout(console.log, 1000 * i, i);
setTimeout(() => { console.log(i); }, 1000 * i);
}
👇
for(var i = 0; i < 5; i++) {
setTimeout((i) => { console.log(i); }, 1000 * i, i); // 函数作用域找 i
setTimeout(() => { console.log(i); }, 1000 * i); // 全局作用域找 i
}
理论版本
这里说明一下,开发人员经常错误将闭包简化理解成从父上下文中返回内部函数,甚至理解成只有匿名函数才能是闭包。
再说一下,因为作用域链,使得所有的函数都是闭包(与函数类型无关: 匿名函数,FE,NFE,FD都是闭包)。
这里只有一类函数除外,那就是通过 Function 构造器创建的函数,因为其 [[Scope]]
只包含全局对象。
为了更好的澄清该问题,我们对 ECMAScript 中的闭包给出2个正确的版本定义:
ECMAScript 中,闭包指的是:
- 从理论角度:所有的函数。因为它们都在创建的时候就将上层上下文的数据保存起来了。哪怕是简单的全局变量也是如此,因为函数中访问全局变量就相当于是在访问自由变量,这个时候使用最外层的作用域。
- 从实践角度:以下函数才算是闭包:
- 即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)
- 在代码中引用了自由变量