js基础梳理-如何理解做用域和做用域链?

本文重点是要梳理执行上下文的生命周期中的创建做用域链,在此以前,先回顾下关于做用域的一些知识。javascript

1.什么是做用域(scope)?

在《JavaScritp高级程序设计》中并无找到确切的关于做用域的定义,只是在“4.2执行环境及做用域”中简单说了下执行环境(execution context)的概念。而执行环境其实就是以前博客:js基础梳理-究竟什么是执行上下文栈(执行栈),执行上下文(可执行代码)?中的执行上下文。html

而在《JavaScript权威指南》中,对做用域的描述为:前端

变量做用域:一个变量的做用域(scope)是程序源代码中定义这个变量的区域java

在《你不知道的Javascript·上卷》中对做用域的描述则为:jquery

负责收集并维护由全部生命的标识符(变量)组成的一系列查询,并实施一套很是严格的规则,肯定当前执行的代码对这些标识符的访问权限。浏览器

简单来说,做用域(scope)就是变量访问规则的有效范围闭包

  • 做用域外,没法引用做用域内的变量;
  • 离开做用域后,做用域的变量的内存空间会被清除,好比执行完函数或者关闭浏览器
  • 做用域与执行上下文是彻底不一样的两个概念。我曾经也混淆过他们,可是必定要仔细区分。

JavaScript代码的整个执行过程,分为两个阶段,代码编译阶段与代码执行阶段。编译阶段由编译器完成,将代码翻译成可执行代码,这个阶段做用域规则会肯定。执行阶段由引擎完成,主要任务是执行可执行代码,执行上下文在这个阶段建立。函数

说得很深奥的样子,其实上面这段话重点用函数做用域与函数执行上下文来区分是最好不过的了。函数做用域是在函数声明的时候就已经肯定了,而函数执行上下文是在函数调用时建立的。假如一个函数被调用屡次,那么它就会建立多个函数执行上下文,可是函数做用域显然不会跟着函数被调用的次数而发生什么变化。学习

1.1 全局做用域

var foo = 'foo';
console.log(window.foo);   // => 'foo'

在浏览器环境中声明变量,该变量会默认成为window对象下的属性。this

function foo() {
    name = "bar"
}
foo();
console.log(window.name) // bar

在函数中,若是不加 var 声明一个变量,那么这个变量会默认被声明为全局变量,若是是严格模式,则会报错。

全局变量会形成命名污染,若是在多处对同一个全局变量进行操做,那么久会覆盖全局变量的定义。同时全局变量数量过多,很是不方便管理。

这也是为何jquery要在全局创建变量 $,其他私有方法属性挂在 $ 下的缘由。

1.2 函数做用域

假如在函数中定义一个局部变量,那么该变量只能够在该函数做用域中被访问。

function doSomething () {
    var thing = '吃早餐';
}
console.log(thing); // Uncaught ReferenceError: thing is not defined

嵌套函数做用域:

function outer () {
    var thing = '吃早餐';
    function inner () {
        console.log(thing);
    }
    inner();
}

outer();  // 吃早餐

在外层函数中,嵌套一个内层函数,那么这个内层函数能够向上访问到外层函数中的变量。

既然内层函数能够访问到外层函数的变量,那若是把内层函数return出来会怎样?

function outer () {
    var thing = '吃早餐';
    
    function inner () {
        console.log(thing);
    }
    
    return inner;
}

var foo = outer();
foo();  // 吃早餐

前面提到,函数执行完后,函数做用域的变量就会被垃圾回收。而这段代码看出当返回了一个访问了外部函数变量的内部函数,最后外部函数的变量得以保存。

这种当变量存在的函数已经执行结束,但扔能够再次被访问到的方式就是“闭包”。后期会继续对闭包进行梳理。

1.3 块级做用域

不少书上都有一句话,javascript没有块级做用域的概念。所谓块级做用域,就是{}包裹的区域。可是在ES6出来之后,这句话并不那么正确了。由于能够用 let 或者 const 声明一个块级做用域的变量或常量。

好比:

for (let i = 0; i < 10; i++) {
    // ...
}
console.log(i); // Uncaught ReferenceError: i is not defined

发现这个例子就会和函数做用域中的第一个例子同样的错误提示。由于变量i只能够在 for循环的{ }块级做用域中被访问了。

扩散思考:

究竟何时该用let?何时该用const?

默认使用 const,只有当确实须要改变变量的值的时候才使用let。由于大部分的变量的值在初始化以后不该再改变,而预料以外的变量的修改是不少bug的源头。

1.4 词法做用域

词法做用域,也能够叫作静态做用域。意思是不管函数在哪里调用,词法做用域都只在由函数被声明时所处的位置决定。
既然有静态做用域,那么也有动态做用域。
而动态做用域的做用域则是由函数被调用时执行的位置所决定。

var a = 123;
function fn1 () {
    console.log(a);
}
function fn2 () {
    var a = 456;
    fn1();
}
fn2();   // 123

以上代码,最后输出结果 a 的值,来自于 fn1 声明时所在位置访问到的 a 值 123。
因此JS的做用域是静态做用域,也叫词法做用域。

上面的1.1-1.3能够看作做用域的类型。而这一小节,其实跟上面三小节仍是有差异的,并不属于做用域的类型,只是关于做用域的一个补充说明吧。

2. 什么是做用域链(scope chain)

在JS引擎中,经过标识符查找标识符的值,会从当前做用域向上查找,直到做用域找到第一个匹配的标识符位置。就是JS的做用域链。

var a = 1;
function fn1 () {
    var a = 2;
    function fn2 () {
        var a = 3;
        console.log(a);
    }
    fn2 ();
}
fn1(); // 3

console.log(a) 语句中,JS在查找 a变量标识符的值的时候,会从 fn2 内部向外部函数查找变量声明,它发现fn2内部就已经有了a变量,那么它就不会继续查找了。那么最终结果也就会打印3了。

3. 做用域链与执行上下文

在此前的博客:js基础梳理-究竟什么是执行上下文栈(执行栈),执行上下文(可执行代码)?中讲到执行上下文的生命周期:

3.执行上下文的生命周期

3.1 建立阶段

  • 生成变量对象(Variable object, VO)
  • 创建做用域链(Scope chain)
  • 肯定this指向

3.2 执行阶段

  • 变量赋值
  • 函数引用
  • 执行其余代码

上面作了那么多铺垫,其实重点是想梳理这一小节。
下面,以一个函数的建立和激活两个时期来说解做用域链是如何建立及变化的。

3.1函数建立阶段

上文中讲到,函数的做用域在函数定义的时候就决定了。

这是由于函数有一个内部属性[[scope]],当函数建立的时候,就会保存全部父变量对象到其中,可是注意:此时[[scope]]并不表明完整的做用域链,由于在建立阶段,它尚未包括本身的做用域。

举个栗子:

function foo () {
    function bar () {
        ...
    }
}

函数建立时,各自的[[scope]]为:

foo.[[scope]] = [
    globalContext.VO
];

bar.[[scope]] = [
    fooContext.AO,
    globalContext.AO
];

3.2 函数激活阶段

当函数激活时,进入函数上下文,建立VO/AO后,就会将活动对象添加到做用域链的前端。

这时候执行上下文的做用域链,命名为 Scope:

Scope = [AO].concat([[scope]]);

至此,做用域链建立完毕。

3.3 举个栗子

如下面的例子为例,结合以前的变量对象,活动对象和执行上下文栈,总结一下函数执行上下文中做用域链和变量对象的建立过程:

var x = 10;
 
function foo() {
  var y = 20;
 
  function bar() {
    var z = 30;
    console.log(x +  y + z);
  }
 
  bar();
}
 
foo(); // 60

你们确定都知道打印结果会是60。可是从第一行代码开始到最后一行代码结束,整个代码的执行上下文栈以及做用域链是怎样变化的呢?

// 第一步:进入全局上下文,此时的执行上下文栈是这样:
ECStack = [
    globalContext: {
        VO: {
            foo: <reference to function foo() {}>,
            x: 10
        }
    }
];

// 第二步:foo函数被建立,此时的执行上下文栈没有变化,可是建立了foo函数的做用域,保存做用域链到内部属性[[scope]]。
ECStack = [
    globalContext: {
        VO: {
            foo: <reference to function foo() {}>,
            x: 10
        }
    }
];
foo.[[scope]] = [
    globalContext.VO
];

// 第三步:foo函数执行,进入foo函数执行上下文的建立阶段
// 这个阶段它作了三件事:
// 1.复制以前的foo.[[scope]]属性到foo函数上下文下,建立foo函数的做用域链;
// 2. 建立foo函数上下文的变量对象,并初始化变量对象,依次加入形参,函数声明,变量声明
// 3. 把foo函数上下文的变量对象加入到第一步建立的foo函数做用域链的最前面。
// 最终,通过这三个步骤以后,整个执行上下文栈是这样

ECStack = [
    globalContext: {
        VO: {
            foo: <reference to function foo() {}>,
            x: 10
        }
    },
    <foo>functionContext: {
        VO: {
            arguments: {
                length: 0
            },
            bar: <reference to function bar() {}>,
            y: undefined
        },
        Scope: [foo.VO, globalContext.VO]
    }
];

foo.[[scope]] = [
    foo.VO,
    globalContext.VO
];

// 第四步:foo函数执行,进入foo函数执行上下文的执行阶段。
// 这个阶段又作了如下2件事:
// 1. 把foo执行上下文的变量对象VO改为了活动对象AO,而且修改AO中变量的值
// 2. 发现建立了一个 bar函数,就保存了bar函数的全部父变量对象到bar函数的[[scope]]属性上。


ECStack = [
    globalContext: {
        VO: {
            foo: <reference to function foo() {}>,
            x: 10
        }
    },
    <foo>functionContext: {
        AO: {
            arguments: {
                length: 0
            },
            bar: <reference to function bar() {}>,
            y: 20
        },
        Scope: [foo.AO, globalContext.VO]
    }
];

foo.[[scope]] = [
    foo.AO,
    globalContext.VO
];

bar.[[scope]] = [
    foo.AO,
    globalContext.VO
];

// 第五步,bar函数执行,进入bar函数执行上下文的建立阶段
// 与第三步相似,也作了三件事,只不过主体变成了bar
// 1.复制以前的bar.[[scope]]属性到bar函数上下文下,建立foo函数的做用域链;
// 2. 建立bar函数上下文的变量对象,并初始化变量对象,依次加入形参,函数声明,变量声明
// 3. 把bar函数上下文的变量对象加入到第一步建立的bar函数做用域链的最前面。
// 最终,通过这三个步骤以后,整个执行上下文栈是这样

ECStack = [
    globalContext: {
        VO: {
            foo: <reference to function foo() {}>,
            x: 10
        }
    },
    <foo>functionContext: {
        AO: {
            arguments: {
                length: 0
            },
            bar: <reference to function bar() {}>,
            y: 20
        },
        Scope: [foo.AO, globalContext.VO]
    },
    <bar>functionContext: {
        VO: {
            arguments: {
                length: 0
            },
            z: undefined
        },
        Scope: [bar.VO, foo.AO, globalContext.VO]
    }
];

foo.[[scope]] = [
    foo.AO,
    globalContext.VO
];

bar.[[scope]] = [
    bar.VO,
    foo.AO,
    globalContext.VO
];

// 第六步:bar函数执行,进入bar函数执行上下文的执行阶段
// 与第四步相似。不过此时bar函数里面不会再建立新的函数上下文了
// 1. 把bar执行上下文的变量对象VO改为了活动对象AO,而且修改AO中变量的值
ECStack = [
    globalContext: {
        VO: {
            foo: <reference to function foo() {}>,
            x: 10
        }
    },
    <foo>functionContext: {
        AO: {
            arguments: {
                length: 0
            },
            bar: <reference to function bar() {}>,
            y: 20
        },
        Scope: [foo.AO, globalContext.VO]
    },
    <bar>functionContext: {
        AO: {
            arguments: {
                length: 0
            },
            z: 30
        },
        Scope: [bar.AO, foo.AO, globalContext.VO]
    }
];

foo.[[scope]] = [
    foo.AO,
    globalContext.VO
];

bar.[[scope]] = [
    bar.AO,
    foo.AO,
    globalContext.VO
];

// 第七步:执行bar函数中的console.log(x + y +z),查找x, y, z三个标识符

- "x"
-- <bar>functionContext.AO   // 没找到,继续到foo.AO中找
-- <foo>functionContext.AO   // 还没找到,再往globalContext.VO中找
-- globalContext.VO     // 找到了,值为 10

- "y"
-- <bar>functionContext.AO   // 没找到,继续到foo.AO中找
-- <foo>functionContext.AO   // 找到了,值为20

-- "z"
-- <bar>functionContext.AO   // 找到了,值为 30

打印结果: 60。

// 第八步:bar函数执行完毕,将其从执行上下文栈中弹出,foo函数执行完毕,将其从执行上下文栈中弹出。最终,执行上下文栈,只剩下globalContext

ECStack = [
    globalContext: {
        VO: {
            foo: <reference to function foo() {}>,
            x: 10
        }
    }
]

感受其实能够简化理解一下,把第三四步,第五六步分别分红一个步骤。

打算每周定一个小主题,多是基础知识巩固,也多是本身学习新知识的记录。在下一篇博文中,将对this指向问题进行梳理。若是你也感兴趣,也能够去搜集下相关资料,到时候你们共同窗习探讨一下。

相关文章
相关标签/搜索