【JavaScript】作用域与作用域链

学习链接

说说你对作用域链的理解

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

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

JavaScript 作用域和作用域链

JavaScript作用域原理

汤姆大叔:深入理解JavaScript系列(11-16👍👍👍,大部分内容来自这里,感谢作者!)

JavaScript闭包的底层运行机制


作用域与作用域链

未考虑 letconst

变量对象

如果变量与执行上下文相关,那变量自己应该知道它的数据存储在哪里,并且知道如何访问。这种机制称为变量对象(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 对象是活动对象的一个属性,它包括如下属性:

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

执行上下文

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

进入执行上下文

当进入执行上下文(代码执行之前)时,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 值在进入上下文时确定,并且在上下文运行期间永久不变


规则

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

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

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

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

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

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


作用域链

作用域链(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 都会创建一个新的变量对象,其中含有参数 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. 在代码中引用了自由变量
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值