深入研究 JavaScript 作用域及执行上下文

前端2016-12-221 篇评论 JavaScript

首先,让我们来回顾一下,JavaScript 中作用域的概念。作用域是一个变量及函数能够被正常访问的范围,在此范围之外就无法进行访问。

与其它一些语言不同, ES6 之前 JavaScript 的作用域只分为全局作用域和函数作用域两种,并不包含块级作用域。如下的代码会造成一些问题:

function loop() {for (var i = 0; i < 10; i++) {
        // Do some thing...
    }
    console.log(i);
    // 输出:10
}

loop();

这段代码中,我们的本意是让循环变量 i 仅在循环中可访问,但由于在 JavaScript 中不具有块状作用域,因此实际上在运行时变量 i 拥有函数作用域,在整个函数内都可访问。

在 ECMAScript 6 之后,JavaScript 可通过 letconst 来支持块状作用域。如上代码使用 let 即可达到我们的本意:

function loop() {
    for (let i = 0; i < 10; i++) {
        // Do some thing...
    }
    console.log(i);
    // 输出:ReferenceError: i is not defined
}

loop();

然而,JavaScript 中与作用域相关的原理并没有那么简单, 下面让我们来深入了解一下。

执行上下文

执行上下文 (execution context)是 JavaScript 中的一个重要概念,执行上下文决定了在此上下文中定义的变量和函数的访问。在每个执行上下文中都有一个与其关联的 变量对象 (variable object,以下简写为 VO),在该上下文中声明的所有变量和函数都保存在相应的 VO 中,也就是说,声明的变量是 VO 的属性,声明的函数是 VO 的方法.

VO 分为两种, 活跃对象 (activition object,以下简写为 AO)和全局对象(global object,以下简称 GO)。在函数执行上下文中的 VO 即为 AO,而全局执行上下文中的 VO 就叫做全局对象,在浏览器中的 GO 就是 window 对象。

执行上下文的创建

每一次函数调用发生时,都会创建一个新的执行上下文,并将其送入执行栈顶。执行上下文可以分为创建和执行两个阶段。首先是创建阶段,依次经历以下步骤:

  1. 创建一个空对象作为 VO。
  2. 创建 arguments 对象,并将其作为 VO 的属性。
  3. 分别将执行上下文中定义的函数实参、函数声明及变量声明添加到 VO 的属性中去。
    • 函数实参会作为 VO 的一个属性,并会被调用函数时传入的形参初始化。若存在未传递的参数,则其属性值为 undefined
    • 函数声明会作为 VO 的一个方法,并从函数定义中立即初始化。
    • 变量声明会作为 VO 的一个属性,其值为 undefined。 当已经有同名的方法存在时,不会覆盖其值。
  4. 初始化作用域链(下文说明)。
  5. 决定 this 的引用,并将其加入到 VO 中去。

当 VO 创建完成后,执行上下文进入到执行阶段,开始执行其中的代码。

在执行上下文运行完成后,则将其从执行栈中弹出,保存在该执行上下文中的变量和函数都将被销毁。

变量对象的作用

变量声明提升

当使用 var 关键字声明一个变量时,则会将其加入当前执行上下文的 VO 中去。此过程发生在此变量对象创建完成之后,与 var 声明的位置无关,因此,多次重复使用 var 声明同一个变量是无效的。

console.log(a);
// 输出 undefined

var a = 1;

等同于:

var a;
console.log(a);
a = 1;

函数声明提升

使用 function 关键字声明函数,会出现函数声明提升现象,即在执行代码前会首先此执行上下文中的所有函数声明,因为在上下文创建阶段,此函数就已经加入到当前执行上下文的 VO 中了,因此可以在一个函数声明前调用它。

看如下一个例子:

console.log(a);
// 输出:function a() {}

var a = 10;

console.log(a);
// 输出:10

function a() {}

console.log(a);
// 输出:10

当同名的变量声明和函数声明均存在时,只会将函数声明初始化,因此第一次获取 a 的值时,输出的是函数声明的结果,第二次输出 10 是因为 a 的值被重新覆盖,第三次输出 10 是因为函数声明在执行时被跳过,a 依旧为原值。

作用域链

在执行上下文的创建阶段,会自动创建该上下文中的 作用域链 (scope chain),来保证有权访问执行环境的所有变量和函数的有序访问。作用域链中包含了当前执行栈中每一个执行上下文相应的 VO。

在函数中,作用域链通常是作为一个隐藏属性 [[Scope]] 而存在,它决定了哪些数据能够被在此函数中被访问。

作用域链的顶端始终是当前的执行上下文 VO。作用域链的下一个 VO 则来自于外一层执行上下文,这样一直延续到全局执行上下文。因此, 全局对象始终都是作用域链的最后一个对象。

与原型链类似,当需要获取的变量或函数在作用域链顶端的 AO 中不存在时,便会顺着作用域链向下进行查找,直到找到为止。若到了全局对象中依旧不存在,则会报 ReferenceError: xxx is not defined

以下便是作用域链的一个例子:

var num1 = 1;
var num2 = 1;

function add() {
    var num2 = 2;
    return num1 + num2;
}

console.log(add());
// 输出:3

此处 num1 变量来自于全局上下文的 VO,而 num2 则来自于 add 函数中的 AO。

改变作用域链

通常来说,在一个执行上下文中的作用域链是不会改变的,但是,有两种方法可以对当前执行上下文作用域链进行修改:with 语句和 try...catch 语句。

with 语句通常用得不多,它是用来将一个对象中的所有属性创建一个变量,使得其属性和方法可以直接被当做变量和函数来访问:

with(document) {
    var element =  getElementById('foo');
    console.log(element);
    // 输出 foo 元素
}
console.log(element);
// 依然输出 foo 元素

with 语句将此对象添加到作用域链的顶端,成为作用域链中一个新的变量对象,但不会是活跃对象。因此,在 with 语句内部定义的变量依然是存放于函数执行上下文的活跃对象中。当 with 语句块结束后,作用域链将恢复。

try...catch 语句块中的 catch 子句可以接受一个从 try 语句块中抛出的变量。当运行到 catch 子句中时,会自动创建一个全新的变量对象并将变量加入对象中去,随后将这个新的变量对象添加到作用域链的顶端。当 catch 语句块执行完毕后,作用域链将恢复。

通过 try...catch 语句块的这个特性,可以实现类似于块状作用域的特性:

try {
    throw 1;
} catch (e) {
    var a = e;
    console.log(e);
    // 输出 1;
}

console.log(a);
// 输出:1

console.log(e);
// 抛出:ReferenceError

如上的 try...catch 语句块即可等同于以下 ES6 语法:

{
    let e = 1;
    var a = e;
    console.log(e);
    // 输出 1;
}

console.log(a)
// 输出 1;

console.log(e)
// 抛出:ReferenceError

参考资料:

本文将时常更新。。。

最后更新日期 2016 年 12 月 22 日。

评论区

发表评论
用户名
(必填)
电子邮箱
(必填)
个人网站
(选填)
评论内容
Copyright © 2017 dremy.cn
皖ICP备16015002号