彻底理解js的作用域链

之前的文章中我已经介绍了执行上下文的变量对象。在这一篇文章我要介绍执行上下文的作用域链了。

执行上下文.作用域链(scope chain)

作用域链与变量对象有着密不可分的关系,因为作用域链就是变量对象的数组!其中第一个是当前函数的活动对象,第二个是当前活动函数的父亲上下文的活动对象,第三个是当前活动函数的爷爷上下文的活动对象,……,最后一个是全局上下文的活动对象。当执行引擎在解析一个变量名的时候,会沿着当前执行函数的作用域链查找,如果在某个活动对象中找到了,则使用它。找不到则报错。

什么叫父亲上下文?这个函数在哪个上下文中被声明,那个上下文就是此函数的父亲上下文,这个函数的声明会被保存到父亲上下文的变量对象中。上层上下文(也就是所有前辈上下文)对于作用域链的创建至关重要,我们会在文章后面讲。

举个栗子:

var global_var = -1;

function outter() {
    var outter_var = 111;
    console.log(
        "in outter, global_var = " + global_var +
        ", outter_var = " + outter_var
    );

    function inner() {
        var inner_var = 333;
        console.log(
            "in inner, global_var = " + global_var +
            ", outter_var = " + outter_var +
            ", inner_var = " + inner_var
        );
    }
    inner();
}
outter();
// 输出
// in outter, global_var = -1, outter_var = 111
// in inner, global_var = -1, outter_var = 111, inner_var = 333

开始执行outter()之前,创建的执行上下文.作用域链是:
[outter的变量对象, 全局的变量对象]

  • 解析global_var的时候,在outter的变量对象中没有找到,因此在全局的变量对象中找,找到这个属性,所以可以打印出它的值-1。
  • 解析outter_var的时候,在outter的变量对象中就找到了,因此不用往后找了,直接打印111。

inner的执行上下文.作用域链是:
[inner的变量对象,outter的变量对象, 全局的变量对象]
变量解析的过程同上。

inner的父亲上下文是outter,outter的父亲上下文是全局上下文。

为什么js有函数级作用域

前一篇文章,我们解释过为什么js不是块级作用域,到这里我们又可以解释为什么js有函数级作用域了。如果你尝试在上面例子的outter()中打印inner_var,会报错。因为outter的作用域链是[outter的变量对象, 全局的变量对象],根本就没有inner的变量对象!自然也就找不到inner_var了。

作用域链是怎么创建的

虽然说作用域链是在函数执行之前,与执行上下文一起创建的,但是实际上其中很大一部分内容在定义的时候就已经确定了。

作用域链这个数组可以分成两部分:

  1. 第一个元素就是本函数的变量对象
  2. 后面的所有元素都是上层上下文的变量对象(按照由近到远的顺序排列)。在网上的一些资料中,所有上层上下文的链叫做[[Scope]]。

第一部分是在函数执行之前创建的。第二部分其实在函数声明的时候就已经确定了,[[Scope]]是函数的一个属性,从函数声明到函数销毁一直存在,且不会改变。我们在文章开头也说过,决定上层上下文的是函数在哪里声明,而不是函数在哪里调用
因此,本函数的变量对象一旦创建好,执行引擎就可以合并[[Scope]],创建出一个作用域链了。

可以用这种方式轻松地找出[[Scope]]:[[Scope]]就是函数被声明时的上下文的作用域链。

举个例子:

function inner() {
    // 创建作用域链:[inner的变量对象,全局上下文的变量对象],其中没有outter的变量对象!怪不得找到不到outter_var!
    console.log("in inner, outter_var = "+outter_var);
}
// inner声明完毕,[[scope]]已经确定
// 因为此时的上下文是全局上下文,所以[[scope]]就是全局上下文的作用域链,也就是全局变量对象

function outter() {
    var outter_var = 111;
    inner();  
}

outter(); // 报错:outter_var is not defined

如果将inner声明在outter里面,就不会报错啦:

function outter() {
    var outter_var = 111;

    function inner() {
        // 创建作用域链:[inner的变量对象,outter的变量对象,全局上下文的变量对象]
        console.log("in inner, outter_var = " + outter_var);
    }
// 声明完毕,[[scope]]已经确定
// 因为此时的上下文是:outter上下文
// outter上下文的作用域链是:[outter上下文的变量对象,全局上下文的变量对象]
// 所以[[scope]]是:[outter上下文的变量对象,全局上下文的变量对象]
    inner();
}

outter();
//  输出:in inner, outter_var = 111

解释闭包的原理

如果看过我之前的文章彻底理解js闭包现在应该已经能掌握闭包的使用方法了。是时候给闭包最后一击了!我们接下来要用作用域链来解释闭包的原理!
看看我在“彻底理解js闭包”中举的一个栗子:

function outter() {
  var private= "I am private";
  function show() {
    console.log(private);
  }
// [[scope]]已经确定:[outter上下文的变量对象,全局上下文变量对象]
  return show;
}

var ref = outter();
// console.log(private);  // 尝试直接访问private会报错:private is not defined
ref(); // 打印I am private

outter执行完以后,private不会被销毁,并且只能被show方法所访问,而尝试直接访问它会出现报错。

  • 首先我们解释为什么不能直接从外部访问它:
    执行到报错的那一行的时候,当前上下文的作用域链是:[全局上下文变量对象],其中的变量对象中并没有private(private在outter的上下文变量对象中)!这就是为什么我们不能直接访问private
  • 然后我们解释为什么能够通过show来访问private
    调用ref(也就是show)的时候,因为show的[[scope]]是:
    [outter变量对象,全局变量对象]
    所以创建的作用域链是:
    [show变量对象,outter变量对象,全局变量对象]
    查找private的时候,在outter变量对象中找到了,所以可以访问到。

可见,闭包之所以能够“记忆”它被创建时候的环境,就是因为[[scope]]将函数声明时所在上下文的作用域链给存起来了!

有关this指针是如何确定的,请看这一篇文章

点赞