你应该知道的执行上下文、调用栈、闭包、this、作用域等之间的关系

866 阅读7分钟

这是我参与更文挑战的第6天,活动详情查看: 更文挑战

首先我们知道,JS是门解释型语言,包括了两个阶段解释阶段执行阶段

  1. 解释阶段,主要是解释器进行词法分析语法分析、作用域规则确定。
  2. 执行阶段,是由JS引擎执行,创建上下文、执行代码、垃圾回收

作用域

前面已经提到了JS在解释阶段就确定了作用域规则。那什么是作用域?

作用域是在运行时代码中变量,函数和对象可访问性的集合[1]

作用域可分为词法作用域(静态作用域)动态作用域

动态作用域并不关心函数在何处声明,只关心它们从何处调用。静态作用域在书写时就已经能够确认了。简而言之,词法作用域是在定义时确定的,而动态作用域是在运行时确定的。JS采用的就是静态作用域。实际上动态作用域是JS另一个重要机制this的体现。一些人之所以对作用域混乱,多数是因为静态的作用域和动态的this机制相混淆。那为什么this是动态的呢?后文有解释

var a = 2;
function foo() {
    console.log( a );
}
function bar() {
    var a = 3;
    foo();
}
bar();//2 因为foo是定义在全局环境中的

把foo放在bar函数里面:

var a = 2;
function bar() {
    var a = 3;
    function foo() {
        console.log( a );
    }
    foo();
}
bar();//3 因为foo是定义在bar函数环境中的

作用域链

作用域链本质上是一个指向变量对象的指针列表,这个指针列表的第0个位置始终都是当前执行代码所在环境,下一个位置指向定义时的包含环境,在下一个位置指向下一个定义时的包含环境,这样一直延续到作用域的的最后的全局环境。标识符的解析就是通过这个作用域链一级一级查找标识符的过程。

执行上下文

执行上下文是JavaScript执行一段代码时的运行环境。当程序遇到可执行代码时,就进入了一个执行上下文,执行上下文是一个逻辑上的堆栈结构。堆栈中最顶层的执行上下文就是正在运行的执行上下文。每个执行上下文都有变量环境、词法环境、this。

当JS初始化全局执行函数的时候(JS执行阶段):

  1. 首先会创建上下文。执行上下文分为两种:全局执行上下文函数执行上下文
  2. 然后就把创建的上下文压入调用栈中,执行代码,执行完毕,弹出调用栈。
  3. 垃圾回收。 而执行上下文生命周期又可分为:创建阶段执行阶段

创建阶段

  1. 变量环境和词法环境被定义。

  2. 确定this的指向(所以说this是要到执行阶段才能确定的)。

  3. 建立作用域链。

    变量环境:通过var声明的变量存在这里

    词法环境:通过letconstwith()try-catchfunction声明的变量存在这里。

    词法环境由环境记录对外部环境引入记录两个部分组成。 全局上下文与函数上下文的外部环境引入记录不一样,全局为null,函数为全局环境或者其它函数环境。 环境记录也不一样,全局叫对象环境记录,函数叫声明性环境记录。

    词法环境是一种规范类型(specification type),它定义了标识符和ECMAScript代码中的特定变量及函数之间的联系。而词法环境中,包含了一个环境记录和一个指向外部词法环境的引用,而这个引用的值可能为null。 aHR0cHM6Ly9tbWJpei5xcGljLmNuL21tYml6X3BuZy9sb2xPV0JZMXRreTNPNGdCbktZYURFeVA2SU1SZ1M0YWliSXdGcmZHd1lPaWNBQkFpY0JTdmljV085MndWM3RHdjRXWDlpYmgyQ3g3ZjhiZDI3MWliaWJDdHZrREEvNjQw.png 执行阶段:变量赋值、函数的引用。

this

由上文可知,this的指向时在上下文创建的时候才能确定的,这个要和JS的静态作用域区分。this指向的是执行是所处的执行环境。指向无非就五种:

调用方式例子指向
函数直接调用xxx()window
属性调用a.b()a
new调用new xx()一个新对象
箭头函数调用()=>{}父级环境
显式调用call(xx)、apply(xx)、bind(xx)xx
function foo(){
    console.log(this)
};
let a = 2;
let obj = {
    a:1,
    fn1:foo,
    fn2:()=>{
        console.log(this)
    }
};
let exe1 = obj.fn1;
let exe2 = obj.fn2;

exe1();//this=>window
exe1.call(a);//this=>Number {2}
exe1.call(obj.a);//this=>Number {1}
exe1.call(obj.fn1);//foo

obj.fn1();//this=>obj
obj.fn1.call(a);//this=>Number {2}
obj.fn1.call(obj.a);//this=>Number {1}
obj.fn1.call(obj.fn1);//this=>foo

exe2();//this=>window
exe2.call(a);//this=>window
exe2.call(obj.a);//this=>window
exe2.call(obj.fn1);//this=>window
//箭头函数的this无法改变

obj.fn2();//this=>window
obj.fn2.call(a);//this=>window
obj.fn2.call(obj.a);//this=>window
obj.fn2.call(obj.fn1);//this=>window
//箭头函数的this无法改变



其实大白话this就是,你想函数在哪个环境执行,你想在window环境执行,就直接执行它;想在某个对象的环境执行,就把它当成对象的属性去执行;想自定义执行环境,就用call、apply、bind吧。

一个函数还没执行之前,这个函数的this指向哪个环境都有可能,所以当你写一个函数的时候,就不要再去纠结它这个this究竟指向谁,到你调用的时候想指向谁就指向谁。

调用栈

这么多执行上下文,如何管理这些上下文?这就需要到 执行上下文栈了。

这个用来存放、管理执行上下文的栈就被称为调用栈。

可以认为执行栈底部永远有个全局执行上下文,除非把程序停了或者把浏览器关了,否则执行栈底部部永远有个全局执行上下文。 aHR0cHM6Ly9tbWJpei5xcGljLmNuL21tYml6X3BuZy9kWmp6TDNjWkxHWjVrZXVjemR4TmJKaEdPa0FVVWljUWxXT0k3WHhYbGlhQ1RGNldpYXFiZ2RZOGZuUVI1QURBV3YyNVltaWJRQm9XSFVyUGVIMFhmbWM0ZUEvNjQw.png

闭包

我比较认同的说法是,闭包是由函数以及声明该函数的词法环境组合而成的。这个说法来源于MDN。闭包不是一个函数,而是函数和词法环境组成的。在闭包场景下,确实存在一个函数有权访问另外一个函数作用域中的变量,但闭包不是函数。

另外一种说法是,闭包是指有权访问另外一个函数作用域中的变量的函数。

总结

(番外)作用域链与原型链的区别

访问变量时,解释器会先在当前作用域查找,如果没有找到就去创建函数时所在的作用域找,作用域链顶端是全局对象window,如果window都没有这个变量则报错。

当在对象上访问某属性时,会查找当前对象,如果没有就顺着原型链往上找,原型链顶端是null,如果全程都没找到则返一个undefined,而不是报错。

(番外)变量对象(VO)/活动对象(AO)

当你阅读其它有关执行上下文的文章,一定有疑问,执行上下文创建过程不是应该解释this,作用域与变量对象/活动对象才对吗?

在阅读ECMAScript规范时,没有找到这些关键词。查阅资料可以知道,变量对象与活动对象的概念是ES3提出的老概念,从ES5开始就用词法环境和变量环境替代了。

词法环境的概念与变量对象是可以对应上的:变量对象(VO)与活动对象(AO)其实都是变量对象,变量对象是与执行上下文相关的数据作用域,存储了在上下文中定义的变量和函数声明。而在函数上下文中,我们用活动对象(AO)来表示变量对象,正好对应到了词法环境。

而且由于ES6新增的let const不存在变量提升,于是正好有了词法环境与变量环境的概念来解释。

参考:

解读闭包,这次从ECMAScript词法环境,执行上下文说起

一篇文章看懂JS执行上下文

JavaScript中的执行上下文、作用域链和闭包详解