学习链接

说说你对作用域链的理解

深入理解JavaScript作用域和作用域链

深入理解JS中声明提升、作用域(链)和this关键字

JavaScript 作用域和作用域链

JavaScript作用域原理

汤姆大叔:深入理解JavaScript系列(11-16👍👍👍)

JavaScript闭包的底层运行机制

作用域与作用域链

未考虑 letconst

变量对象

如果变量与执行上下文相关,那变量自己应该知道它的数据存储在哪里,并且知道如何访问。这种机制称为变量对象(Variable Object)。

  1. 变量对象(缩写为VO)是一个与执行上下文相关的特殊对象,它存储着在上下文中声明的以下内容:
  2. 变量 (var, 变量声明);
  3. 函数声明 (FunctionDeclaration, 缩写为FD);
  4. 函数的形参

VO 就是执行上下文的属性(property)

  1. activeExecutionContext = {
  2. VO: {
  3. // 上下文数据(var, FD, function arguments)
  4. }
  5. };

只有全局上下文的变量对象允许通过 VO 的属性名称来间接访问(因为在全局上下文里,全局对象自身就是变量对象),在其它上下文中是不能直接访问VO对象的,因为它只是内部机制的一个实现。

当我们声明一个变量或者函数的时候,其实就是在 VO 上创建一个新的属性。

  1. var a = 10;
  2. function test(x) {
  3. var b = 20;
  4. };
  5. test(30);

对应的变量对象是:

  1. // 全局上下文的变量对象
  2. VO(globalContext) = {
  3. a: 10,
  4. test: <reference to function>
  5. };
  6. // test函数上下文的变量对象
  7. VO(test functionContext) = {
  8. x: 30,
  9. b: 20
  10. };

在具体实现层面(以及规范中)变量对象只是一个抽象概念。

(从本质上说,在具体执行上下文中, VO 名称是不一样的,并且初始结构也不一样。)

不同执行上下文中的变量对象

  1. 抽象变量对象VO (变量初始化过程的一般行为)
  2. ╠══> 全局上下文变量对象 GlobalContextVO
  3. (VO === this === global)
  4. ╚══> 函数上下文变量对象 FunctionContextVO
  5. (VO === AO, 并且添加了<arguments>和<formal parameters>)

全局上下文中的变量对象

全局对象(Global Object) 是在进入任何执行上下文之前就已经创建了的对象;
这个对象只存在一份,它的属性在程序中任何地方都可以访问,全局对象的生命周期终止于程序退出那一刻。

  1. global = {
  2. Math: <...>,
  3. String: <...>
  4. ...
  5. ...
  6. window: global //引用自身
  7. };

当访问全局对象的属性时通常会忽略掉前缀,这是因为全局对象是不能通过名称直接访问的。不过我们依然可以通过全局上下文的 this 来访问全局对象,同样也可以递归引用自身,例如,DOM 中的 window

全局上下文中的【变量对象】就是【全局对象】本身

  1. VO(globalContext) === global

直接访问变量对象,通过全局对象间接访问

  1. var a = new String('test');
  2. alert(a); // 直接访问,在VO(globalContext)里找到:"test"
  3. alert(window['a']); // 间接通过global访问:global === VO(globalContext): "test"
  4. alert(a === this.a); // true
  5. var aKey = 'a';
  6. alert(window[aKey]); // 间接通过动态属性名称访问:"test"

函数上下文中的变量对象

在函数执行上下文中,VO 是不能直接访问的,此时由活动对象(Activation Object,缩写为AO)扮演 VO 的角色。

  1. VO(functionContext) === AO;

活动对象是在进入函数上下文时刻被创建的,它通过函数的 arguments 属性初始化。arguments 属性的值是 Arguments 对象:

  1. AO = {
  2. arguments: <ArgO>
  3. };

Arguments 对象是活动对象的一个属性,它包括如下属性:

  1. callee — 指向当前函数的引用
  2. length — 真正传递的参数个数
  3. properties-indexes(字符串类型的整数)属性的值就是函数的参数值(按参数列表从左到右排列)。 properties-indexes 内部元素的个数等于 arguments.length,properties-indexes 的值和实际传递进来的参数之间是共享的。

执行上下文

  1. 进入执行上下文
  2. 执行代码

进入执行上下文

当进入执行上下文(代码执行之前)时,VO 里已经包含了下列属性(开头已经说了):

函数的所有形参(如果在函数执行上下文中)

  • 由名称和对应值组成的一个变量对象的属性被创建;没有传递对应参数的话,那么由名称和undefined值组成的一种变量对象的属性也将被创建。

所有函数声明(FunctionDeclaration, FD)

  • 由名称和对应值(函数对象(function-object))组成一个变量对象的属性被创建;如果变量对象已经存在相同名称的属性,则完全替换这个属性。

所有变量声明(var, VariableDeclaration)

  • 由名称和对应值(undefined)组成一个变量对象的属性被创建;如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性

让我们看一个例子:

  1. function test(a, b) {
  2. var c = 10;
  3. function d() {}
  4. var e = function _e() {};
  5. (function x() {});
  6. }
  7. test(10); // call

当进入带有参数10的test函数上下文时,AO表现为如下:

  1. AO(test) = {
  2. a: 10,
  3. b: undefined,
  4. c: undefined,
  5. d: <reference to FunctionDeclaration "d">
  6. e: undefined
  7. };

注意,AO 里并不包含函数 x。这是因为 x 是一个函数表达式(FunctionExpression,缩写为 FE)而不是函数声明,函数表达式不会影响 VO。 不管怎样,函数 _e 同样也是函数表达式,但是就像我们下面将看到的那样,因为它分配给了变量 e,所以它可以通过名称 e 来访问。

代码执行

这个周期内,AO/VO 已经拥有了属性(不过,并不是所有的属性都有值,大部分属性的值还是系统默认的初始值 undefined )。

还是前面那个例子, AO/VO在代码解释期间被修改如下:

  1. AO['c'] = 10;
  2. AO['e'] = <reference to FunctionExpression "_e">;

再次注意,因为 FunctionExpression _e 保存到了已声明的变量 e 上,所以它仍然存在于内存中。而 FunctionExpression x 却不存在于 AO/VO 中,也就是说如果我们想尝试调用 x 函数,不管在函数定义之前还是之后,都会出现一个错误 x is not defined,未保存的函数表达式只有在它自己的定义或递归中才能被调用。

关于 this

this 是执行上下文中的一个属性:

  1. activeExecutionContext = {
  2. VO: {...},
  3. this: thisValue
  4. };

这里 VO 是变量对象。

this 与上下文中可执行代码的类型有直接关系,this 值在进入上下文时确定,并且在上下文运行期间永久不变

规则

  1. 如果没有显示标明 this 的值(call、apply),JavaScript 引擎就会根据调用语句去推断 this 的值。

  2. 引擎会试图将调用语句格式化为 [对象名].[函数名]()。如果能够格式化,this 就为上述对象。否则,this 只好取 window(非严格模式)。

  3. 对于 [函数名]() 的调用,引擎会先根据作用域链找到隐式的对象(变量对象)。

    • 由于全局上下文的变量对象、动态(with、catch)变量对象是可以给用户访问的,因此格式化成功。

    • 函数上下文的变量对象不能给用户直接访问,因此格式化失败。

  4. 对于 (表达式)() 的调用,由于表达式不可能属于任何对象,因此格式化失败。

作用域链

作用域链(Scope Chain)是与执行上下文相关的变量对象链,解析标识符名称时会在其中搜索变量(即用于变量查询)。

函数上下文的作用域链在函数调用时创建的,包含活动对象和这个函数内部的 [[Scope]] 属性

在上下文中示意如下:

  1. activeExecutionContext = {
  2. VO: {...}, // or AO
  3. this: thisValue,
  4. Scope: [ // Scope Chain
  5. // 所有变量对象的列表
  6. // 用于变量查询
  7. ]
  8. };

其 Scope 定义如下:

  1. Scope = AO + [[Scope]]

函数生命周期

函数的的生命周期分为创建和激活阶段(调用时)。

函数创建(未进入函数上下文时)

函数声明在进入上下文阶段(这里是指全局上下文)就属于变量对象(VO)/ 活动对象(AO)。

  1. var x = 10;
  2. function foo() {
  3. var y = 20;
  4. alert(x + y);
  5. }
  6. foo(); // 30

在 foo 的上下文的活动对象中并没有 x,这需要借助作用域链访问。

  1. fooContext.AO = {
  2. y: undefined
  3. };

(上面是进入假设进入上下文之后的讨论)

[[Scope]] 是所有父变量对象(VO)的层级链,处于当前函数上下文之上,在函数创建时存于其中。

注意关键的一点:

[[Scope]] 在函数创建时被写入函数,是静态的(不变的),直至函数被销毁。

函数可能永不调用,但 [[Scope]] 属性已经写入,并存储在函数对象中。

另一点值得注意的是,与作用域链相比[[Scope]]函数的一个属性而不是上下文。

  1. foo.[[Scope]] = [
  2. globalContext.VO // === Global
  3. ];

函数激活

进入函数上下文创建 AO/VO 之后,上下文的 Scope 属性(变量查找的一个作用域链)作如下定义:

  1. Scope = AO|VO + [[Scope]]

上面代码的意思是:活动对象是作用域数组的第一个对象,即添加到作用域的前端

  1. 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 都会创建一个新的变量对象,其中含有参数 xx 的值就是传递进来的 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 中,闭包指的是:

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

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