浏览器原理系列-JS执行上下文详解

710 阅读13分钟

转载请保留这部分内容,注明出处。
另外,头条号前端团队非常 期待你的加入

本文主要介绍JS执行上下文相关的内容,理解了JavaScript的执行上下文才能更好地理解JavaScript语言本身以及该语言一些特性,如变量提升、作用域和闭包。

一、作用域

1.1 作用域

作用域是指程序中变量定义的区域,该位置决定了变量的生命周期。通俗地说,作用域就是变量与函数的可访问范围,作用域控制着变量和函数的可见性和生命周期。

JavaScript在设计之初并未想过这门语言会如此受欢迎,所以只是按照最简单的方式来设计,只保留全局作用域和函数作用域。

  • 全局作用域中对象和变量在任何地方都能访问,其生命周期同页面的生命周期。

  • 函数作用域是在函数内部定义的变量或函数,只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。

1.2 变量提升

JavaScript在执行前会先进行编译,编译阶段会将变量和函数存放在执行上下文的变量环境中,值设为undefined。编译完成进入执行阶段,使用变量时从变量环境中读取,在执行变量的赋值代码前,读取该变量时值均为默认值undefined。

变量提升给初学者带来了很多疑惑,有如下问题:

  • 变量容易在不被察觉的情况下被覆盖掉
var myname = "JavaScript";
function showName(){
     console.log(myname); // undefined
     if(0){
     var myname = "Go"
     }
     console.log(myname);
}
showName();
  • 本应销毁的变量没有被销毁
function foo(){
     for (var i = 0; i < 7; i++) {
     }
    console.log(i); // for循环结束后i变量没有被销魂
}
foo();

1.3 块级作用域

为了解决变量提升等问题、打造JavaScript为企业级的开发语言,ES6引入了let和const关键字,从而使 JavaScript 也能像其他语言一样拥有了块级作用域。

我们已经知道 JavaScript 引擎是通过变量环境实现函数级作用域的,那么 ES6 又是如何在函数级作用域的基础之上,实现对块级作用域的支持呢?

我们来看个例子:

function foo() {
    var a = 1;
    let b = 2;
    {
        let b = 3;
        var c = 4;
        let d = 5;
        console.log(a);
        console.log(b);
    }
    console.log(b);
    console.log(c);
    console.log(d)
}
foo();

当执行上面这段代码的时候,JavaScript 引擎会先对其进行编译并创建执行上下文,然后再按照顺序执行代码,现在我们引入了let关键字,let关键字会创建块级作用域,那么 let 关键字是如何影响执行上下文的呢?

接下来我们就来一步步分析上面这段代码的执行流程。

第一步是编译并创建执行上下文 ,执行上下文如下图所示:

从上图可以看出:

  • 函数内部通过 var 声明的变量 ,在编译阶段会被存放到 变量环境 中。

  • 通过 let 声明的变量 ,在编译阶段会存放到 词法环境 中。

  • 在函数的作用域内部,通过let声明的变量并没有被存放到词法环境中。

第二步是执行代码,当执行到函数代码块里面时(第4行),当执行到代码块里面时,变量环境中 a 的值已经被设置成了 1,词法环境中 b 的值已经被设置成了 2,这时候函数的执行上下文就如下图所示: 从图中可以看出,当进入函数的作用域块时,作用域块中通过let声明的变量,会被存放在词法环境的一个单独的区域中,这个区域中的变量并不影响作用域块外面的变量,比如在作用域外面声明了变量 b,在该作用域块内部也声明了变量 b,当执行到作用域内部时,它们都是独立的存在。

其实, 在词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量压到栈顶;当作用域执行完成之后,该作用域的信息就会从栈顶弹出,这就是词法环境的结构。 需要注意下,我这里所讲的变量是指通过let或者const声明的变量。

再接下来,当执行到作用域块中的console.log(a)这行代码时,就需要在词法环境和变量环境中查找变量 a 的值了,具体查找方式是: 沿着词法环境的栈顶向下查询,如果在词法环境中的某个块中查找到了,就直接返回给 JavaScript 引擎,如果没有查找到,那么继续在变量环境中查找。

当作用域块执行结束之后,其内部定义的变量就会从词法环境的栈顶弹出,最终执行上下文如下图所示: 总结一下,块级作用域就是通过词法环境的栈结构来实现的,而变量提升是通过变量环境来实现,通过这两者的结合,JavaScript 引擎也就同时支持了变量提升和块级作用域了。

二、作用域链 & 闭包

前文我们提到了作用域以及ES6是如何通过变量环境和词法环境来同时支持变量提升和块级作用域,这个小节我们聊聊作用域链和闭包。

在开始之前我们来看下面这段代码:

function bar() {
    console.log(myName);
}
function foo() {
    var myName = "Tom";
    bar();
}
var myName = "Linda";
foo();

这段代码的执行结果是打印Linda还是Tom呢?我相信等你看完本章节会知道答案的。

2.1 作用域链

在每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文,我们把这个外部引用称为outer。

当一段代码使用了一个变量时,JavaScript 引擎首先会在“当前的执行上下文”中查找该变量。

比如上面那段代码在查找 myName 变量时,如果在当前的变量环境中没有查找到,那么 JavaScript 引擎会继续在 outer 所指向的执行上下文中查找。为了直观理解,你可以看下面这张图:

从图中可以看出,bar 函数和 foo 函数的 outer 都是指向全局上下文的,这也就意味着如果在 bar 函数或者 foo 函数中使用了外部变量,那么 JavaScript 引擎会去全局执行上下文中查找。我们把这个查找的链条就称为 作用域链

现在我们知道变量是通过作用域链来查找的了,不过还有一个疑问没有解开,foo 函数调用的 bar 函数,那为什么 bar 函数的外部引用是全局执行上下文,而不是 foo 函数的执行上下文?

要回答这个问题,你还需要知道什么是词法作用域。这是因为 在 JavaScript 执行过程中,其作用域链是由词法作用域决定的。

2.2 词法作用域

词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。

我们看看下面这张图:

从图中可以看出,词法作用域就是根据代码的位置来决定的,其中 main 函数包含了 bar 函数,bar 函数中包含了 foo 函数,因为 JavaScript 作用域链是由词法作用域决定的,所以整个词法作用域链的顺序是: foo 函数作用域—>bar 函数作用域—>main 函数作用域—> 全局作用域

直白地说,词法作用域是代码编译阶段就决定好的,和函数是怎么调用的没有关系。

我们已经在全局作用域和函数级作用域分析了作用域链,那接下来我们再来看看块级作用域中变量是如何查找的?

2.3 块级作用域中的变量查找

来看下面这段代码:

function bar() {
    var myName = "Firefox";
    let test1 = 100;
    if (1) {
        let myName = "Chrome";
        console.log(test);
    }
}
function foo() {
    var myName = "Safari";
    let test = 2;
    {
        let test = 3;
        bar();
    }
}
var myName = "IE";
let test = 1;
foo();

要想得出其执行结果,那接下来我们就得站在作用域链和词法环境的角度来分析下其执行过程。下图是我画的执行到console.log(test)时调用栈的情况:

现在需要打印出来变量 test,那么就需要查找到 test 变量的值,其查找过程我已经在上图中使用序号 1、2、3、4、5 标记出来了。首先是在 bar 函数的执行上下文中查找,但因为 bar 函数的执行上下文中没有定义 test 变量,所以根据词法作用域的规则,下一步就在 bar 函数的外部作用域中查找,也就是全局作用域。

2.4 闭包

了解了词法作用域链,接着我们就可以来聊聊闭包了。初学者对闭包的理解可能会是一道坎,刚接触时会产生一些挫败感,因为很难通过背后的原理来彻底理解闭包,从而导致学习过程中似乎总是似懂非懂。

当理解变量环境、词法环境和作用域链等概念后,再来理解JavaScript中的闭包会容易很多,下面看看这段代码:

function foo() {
    var myName = "IE";
    let test1 = 1;
    const test2 = 2;
    var innerBar = {
        getName:function(){
            console.log(test1);
            return myName;
        },
        setName:function(newName){
            myName = newName;
        }
    }
    return innerBar;
}
var bar = foo();
bar.setName("Chrome");
bar.getName();
console.log(bar.getName());

熟练掌握JavaScript的同学一定能一眼看出答案,但是你真的清楚getName函数执行时的上下文吗?下面我们来详细分析一下这段代码执行时的上下文环境:

首先我们看看当执行到 foo 函数内部的 return innerBar 这行代码时的调用栈情况,如下图:

从上面的代码可以看出,innerBar 是一个对象,包含了 getName 和 setName 的两个方法(通常我们把对象内部的函数称为方法)。你可以看到,这两个方法都是在 foo 函数内部定义的,并且这两个方法内部都使用了 myName 和 test1 两个变量。

根据词法作用域的规则,内部函数getName 和 setName 总是可以访问它们的外部函数 foo 中的变量 ,所以当 innerBar对象被返回并赋值给全局变量bar时,虽然foo函数已经执行结束,但是getName 和setName函数总是可以使用foo 函数中的变量myName 和 test1。所以当foo函数执行完成后调用栈的状态如下:

从上图可以看出,foo 函数执行完成之后,其执行上下文从栈顶弹出了,但是由于返回的 setName 和 getName 方法中使用了 foo 函数内部的变量 myName 和 test1,所以这两个变量依然保存在内存中。这像极了 setName 和 getName 方法背的一个专属背包,无论在哪里调用了 setName 和 getName 方法,它们都会背着这个 foo 函数的专属背包。

之所以是 专属 背包,是因为除了 setName 和 getName 函数之外,其他任何地方都是无法访问该背包的,我们就可以把这个背包称为 foo 函数的 闭包

看到这里,是时候搬出闭包的正式定义: 在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。比如外部函数是 foo,那么这些变量的集合就称为 foo 函数的闭包。

下面我们接着分析,代码执行到 bar.setName 方法中的 myName = 'Chrome' 时,JavaScript 引擎会沿着“ 当前执行上下文–>foo 函数闭包–> 全局执行上下文 ”的顺序来查找 myName 变量,你可以参考下面的调用栈状态图:

从图中可以看出,setName 的执行上下文中没有 myName 变量,foo 函数的闭包中包含了变量 myName,所以调用 setName 时,会修改 foo 闭包中的 myName 变量的值。

同样的流程,当调用 bar.getName 的时候,所访问的变量 myName 也是位于 foo 函数闭包中的。

我们可以通过Chrome的开发者工具来看看闭包的情况,在getName函数内部加上断点查看:

从图中可以看出来,当调用 bar.getName 的时候,右边 Scope 项就体现出了作用域链的情况:Local 就是当前的 getName 函数的作用域,Closure(foo) 是指 foo 函数的闭包,最下面的 Global 就是指全局作用域,从“Local–>Closure(foo)–>Global”就是一个完整的作用域链。

2.5 闭包回收

通常,如果引用闭包的函数是一个全局变量,那么闭包会一直存在直到页面关闭;但如果这个闭包以后不再使用的话,就会造成内存泄漏。

如果引用闭包的函数是个局部变量,等函数销毁后,在下次 JavaScript 引擎执行垃圾回收时,判断闭包这块内容如果已经不再被使用了,那么 JavaScript 引擎的垃圾回收器就会回收这块内存。

所以在使用闭包的时候,你要尽量注意一个原则: 如果该闭包会一直使用,那么它可以作为全局变量而存在;但如果使用频率不高,而且占用内存又比较大的话,那就尽量让它成为一个局部变量。

三、this指向

聊完作用链和闭包,我们来另外一个让我们非常疑惑的对象- this ,在聊this之前,我们要明确一个原则: 作用域链和this是两套不同的系统,它们之间基本没有太多的联系。

3.1 JavaScript 中的 this 是什么

关于 this,我们还是得先从执行上下文说起。前文提到执行上下文包含了变量环境、词法环境、outer,但其实还是有一个this没有提及,参考下图:

从上图可以看出,this和执行上下文是绑定的,也就是说每个执行上下文都有一个this。我们知道,执行上下文主要分为三种—— 全局执行上下文、函数执行上下文和 eval 执行上下文 ,所以对应的 this 也只有这三种——全局执行上下文中的 this、函数中的 this 和 eval 中的 this。

不过由于 eval 我们使用的不多,那么接下来我们就重点聊聊 全局执行上下文中的 this函数执行上下文中的 this

3.2 全局执行上下文this

你可以在控制台中输入console.log(this)来打印出来全局执行上下文中的 this,最终输出的是 window 对象。所以你可以得出这样一个结论: 全局执行上下文中的 this 是指向 window 对象的。这也是 this 和作用域链的唯一交点,作用域链的最底端包含了 window 对象,全局执行上下文中的 this 也是指向 window 对象。

3.3 函数执行上下文中的 this

现在你已经知道全局对象中的 this 是指向 window 对象了,那么接下来,我们就来重点分析函数执行上下文中的 this。还是先看下面这段代码:

function foo() {
  console.log(this)
}
foo()

我们在 foo 函数内部打印出来 this 值,执行这段代码,打印出来的也是 window 对象,这说明在默认情况下调用一个函数,其执行上下文中的 this 也是指向 window 对象的。估计你会好奇,那能不能设置执行上下文中的 this 来指向其他对象呢?答案是肯定的。通常情况下,有下面三种方式来设置函数执行上下文中的 this 值。

3.3.1 通过函数的 call 方法设置

你可以通过函数的 call 方法来设置函数执行上下文的 this 指向,比如下面这段代码,我们就并没有直接调用 foo 函数,而是调用了 foo 的 call 方法,并将 bar 对象作为 call 方法的参数。

let bar = {
    myName : "IE",
    test1 : 1
};
function foo(){
    this.myName = "Chrome"
}
foo.call(bar);
console.log(bar);

执行这段代码,然后观察输出结果,你就能发现 foo 函数内部的 this 已经指向了 bar 对象,因为通过打印 bar 对象,可以看出 bar 的 myName 属性已经由“IE”变为“Chrome”了,同时在全局执行上下文中打印 myName,JavaScript 引擎提示该变量未定义。

和call方法类似,我们还可以使用 bind 和 apply 方法来设置函数执行上下文的this。

3.3.2 通过对象调用方法设置

要改变函数执行上下文中的 this 指向,除了通过函数的 call 方法来实现外,还可以通过对象调用的方式,比如下面这段代码:

var myObj = {
    name : "Chrome",
    showThis: function(){
        console.log(this);
    }
};
myObj.showThis();

在这段代码中,我们定义了一个 myObj 对象,该对象是由一个 name 属性和一个 showThis 方法组成的,然后再通过 myObj 对象来调用 showThis 方法。执行这段代码,你可以看到,最终输出的 this 值是指向 myObj 的。

所以,我们可以得出这样的结论: 使用对象来调用其内部的一个方法,该方法的 this 是指向对象本身的。

接下来我们稍微改变下调用方式,把 showThis 赋给一个全局对象,然后再调用该对象,代码如下所示:

var myObj = {
    name : "Chrome",
    showThis: function(){
        this.name = "IE";
        console.log(this);
    },
};
var foo = myObj.showThis;
foo();
myObj.showThis();

执行这段代码,你会发现 this 又指向了全局 window 对象。

所以通过以上两个例子的对比,你可以得出下面这样两个结论:

  • 在全局环境中调用一个函数,函数内部的 this 指向的是全局变量 window。

  • 通过一个对象来调用其内部的一个方法,该方法的执行上下文中的 this 指向对象本身。

3.3.3 通过构造函数中设置

你可以像这样设置构造函数中的 this,如下面的示例代码:

function CreateObj(){
    this.name = "Chrome"
}
var myObj = new CreateObj();

在这段代码中,我们使用 new 创建了对象 myObj,那你知道此时的构造函数 CreateObj 中的 this 到底指向了谁吗?

其实,当执行new CreateObj()的时候,JavaScript 引擎做了如下四件事:

  • 首先创建了一个空对象 tempObj;

  • 接着调用 CreateObj.call 方法,并将 tempObj 作为 call 方法的参数,这样当 CreateObj 的执行上下文创建时,它的 this 就指向了 tempObj 对象;

  • 然后执行 CreateObj 函数,此时的 CreateObj 函数执行上下文中的 this 指向了 tempObj 对象;

  • 最后返回 tempObj 对象。

3.4 this 的设计缺陷以及应对方案

3.4.1 嵌套函数中的 this 不会从外层函数中继承

这应该算一个严重的设计错误,影响了很多的开发者,让他们“前赴后继”迷失在该错误中。我们结合下面这样一段代码来分析下:

var myObj = {
    name : "IE",
    showThis: function(){
        console.log(this);
        function bar(){
            console.log(this);
        }
        bar();
    }
}
myObj.showThis();

我们在这段代码的 showThis 方法里面添加了一个 bar 方法,然后接着在 showThis 函数中调用了 bar 函数,那么现在的问题是:bar 函数中的 this 是什么?

如果你是刚接触 JavaScript,那么你可能会很自然地觉得,bar 中的 this 应该和其外层 showThis 函数中的 this 是一致的,都是指向 myObj 对象的,这很符合人的直觉。但实际情况却并非如此,执行这段代码后,你会发现 函数 bar 中的 this 指向的是全局 window 对象,而函数 showThis 中的 this 指向的是 myObj 对象。 这就是 JavaScript 中非常容易让人迷惑的地方之一,也是很多问题的源头。

解决此问题有两种方式:1、声明一个变量 self 用来保存 this,把 this 体系转换为了作用域的体系;2、使用 ES6 中的箭头函数。因为 ES6 中的箭头函数并不会创建其自身的执行上下文,所以箭头函数中的 this 取决于它的外部函数。

3.4.2 普通函数中的 this 默认指向全局对象 window

上文提到,在默认情况下调用一个函数,其执行上下文中的 this 是默认指向全局对象 window 的。

不过这个设计也是一种缺陷, 因为在实际工作中,我们并不希望函数执行上下文中的 this 默认指向全局对象,因为这样会打破数据的边界,造成一些误操作。如果要让函数执行上下文中的 this 指向某个对象,最好的方式是通过 call 方法来显示调用。

这个问题可以通过设置 JavaScript 的“严格模式”来解决。在严格模式下,默认执行一个函数,其函数的执行上下文中的 this 值是 undefined,这就解决上面的问题了。