[浏览器工作原理与实践] Day06 | 浏览器中的JavaScript执行机制

448 阅读14分钟

前言

学习资源来自极客时间 - 李兵老师 《浏览器工作原理与实践》。接下来,让我们一起每日打卡,check完成所有课程吧 ~

浏览器中的JavaScript执行机制.png

变量提升

showName()
var showName = function() {
    console.log(2)
}
function showName() {
    console.log(1)
}

解析:

// 首先是编译阶段:
var showName;
function showName() {
    console.log(1)
}

// 接下来是执行阶段:
showName();//函数只有在调用的时候才编译
showName = function() {
    console.log(2)
}

1. JavaScript的执行机制:先编译,再执行

2. 关于同名变量和函数的两点处理原则:

  1. 如果是同名的函数,JavaScript编译阶段会选择最后声明的那个。
  2. 如果变量和函数同名,那么在编译阶段,变量的生命会被忽略。

3. 函数只有在调用的时候才会被编译

调用栈

调用栈就是用来管理函数调用关系的一种数据结构。因此要清楚调用栈,首先要先弄明白函数调用栈结构

  • 函数调用就是运行一个函数,具体使用方式是使用函数名称跟着一对小括号
  • 中的元素满足后进先出的特点

调用栈是 JavaScript 引擎追踪函数执行的一个机制,当一次有多个函数被调用时,通过调用栈就能够追踪到哪个函数正在被执行以及各函数之间的调用关系。

在开发中,如何利用好调用栈

1. 利用浏览器查看调用栈的信息。

  • 利用断点 image.png
  • 利用console.trace()来输出查看 image.png

2. 栈溢出(Stack Overflow)

现在你知道了调用栈是一种用来管理执行上下文的数据结构,符合后进先出的规则。不过还有一点你要注意,调用栈是有大小的,当入栈的执行上下文超过一定数目,JavaScript 引擎就会报错,我们把这种错误叫做栈溢出

特别是在你写递归代码的时候,就很容易出现栈溢出的情况。比如下面这段代码:

function division(a,b){
    return division(a,b)
}
console.log(division(1,2))

当执行时,就会抛出栈溢出错误,如下图:

image.png 那为什么会出现这个问题呢?这是因为当 JavaScript 引擎开始执行这段代码时,它首先调用函数 division,并创建执行上下文,压入栈中;然而,这个函数是递归的,并且没有任何终止条件,所以它会一直创建新的函数执行上下文,并反复将其压入栈中,但栈是有容量限制的,超过最大数量后就会出现栈溢出的错误。

总结

  • 每调用一个函数,JavaScript 引擎会为其创建执行上下文,并把该执行上下文压入调用栈,然后 JavaScript 引擎开始执行函数代码。
  • 如果在一个函数 A 中调用了另外一个函数 B,那么 JavaScript 引擎会为 B 函数创建执行上下文,并将 B 函数的执行上下文压入栈顶。
  • 当前函数执行完毕后,JavaScript 引擎会将该函数的执行上下文弹出栈。
  • 当分配的调用栈空间被占满时,会引发“堆栈溢出”问题。

块级作用域

通过分析词法环境,理解下面一题:

let myname= '极客时间'
{
  console.log(myname) 
  let myname= '极客邦'
}

打印结果Uncaught ReferenceError: Cannot access 'myname' before initialization

分析原因

  1. Cannot access 'myname' before initialization的意思是:虽然该变量已经在词法环境中了,但是还没有被赋值,所以不能使用。这也是JavaScript语法层面的标准,JavaScript引擎是按照标准来实现的。
  2. 在块作用域内,let生命的变量被提升,但变量只是创建被提升,初始化并没有提升,在初始化之前,就会形成一个暂时性死区

暂时性死区

与通过 var 声明的有初始化值 undefined 的变量不同,通过 let 声明的变量直到它们的定义被执行时才初始化。在变量初始化前访问该变量会导致ReferenceError。该变量处在一个自块顶部到初始化处理的“暂存死区”中。

暂时性死区是语法规定的,也就是说虽然通过let声明的变量已经在词法环境中了,但是在没有赋值之前,访问该变量JavaScript引擎就会抛出一个错误。

function test(){
    console.log(a); // ReferenceError
    let a = 7;
}
test()

执行test的时候,编译阶段a已经在内存中,为什么提前访问不了?

这主要是因为V8虚拟机做了限制,虽然a在内存中,但是当你在let a 之前访问a时,根据ECMAScript定义,虚拟机会阻止的访问!

拓展(重要)

  • var 的创建和初始化时被提升,赋值时不会被提升。
  • let 的创建时被提升,在初始化和赋值时不会被提升。
  • function 的创建,初始化和赋值都会被提升

JavaScript 是如何支持块级作用域的

思考下面一段代码:

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()

执行流程分析:

第一步是编译并创建执行上下文,可参考下图:

image.png 通过上图,可以得出以下结论:

  • 函数内部通过var声明的变量,在编译阶段全部存放在变量环境里了。
  • 通过let声明的变量,在编译阶段会被存放到词法环境(Lexical Environment) 中。
  • 在函数内部的作用域块,通过let声明的变量并没有被存放到词法环境中。

第二步继续执行代码,当执行到代码块里面时,变量环境中 a 的值已经被设置成了 1,词法环境中 b 的值已经被设置成了 2,这时候函数的执行上下文就如下图所示:

image.png 从图中可以看出,当进入函数的作用域块时,作用域块中通过 let 声明的变量,会被存放在词法环境的一个单独的区域中,这个区域中的变量并不影响作用域块外面的变量,比如在作用域外面声明了变量 b,在该作用域块内部也声明了变量 b,当执行到作用域内部时,它们都是独立的存在。

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

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

image.png

当作用域块执行结束之后,其内部定义的变量就会从词法环境的栈顶弹出,最终执行上下文如下图所示:

image.png

通过上面的分析,想必你已经理解了词法环境的结构和工作机制,块级作用域就是通过词法环境的栈结构来实现的,而变量提升是通过变量环境来实现,通过这两者的结合,JavaScript 引擎也就同时支持了变量提升和块级作用域了。

作用域链和闭包

词法作用域

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

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

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

块级作用域中的变量查找

function bar() {
    var myName = "极客世界"
    let test1 = 100
    if (1) {
        let myName = "Chrome浏览器"
        console.log(test)
    }
}
function foo() {
    var myName = "极客邦"
    let test = 2
    {
        let test = 3
        bar()
    }
}
var myName = "极客时间"
let myAge = 10
let test = 1
foo()

对于上面这段代码,当执行到 bar 函数内部的 if 语句块时,其调用栈的情况如下图所示: image.png 现在是执行到 bar 函数的 if 语块之内,需要打印出来变量 test,那么就需要查找到 test 变量的值,其查找过程我已经在上图中使用序号 1、2、3、4、5 标记出来了。

下面我就来解释下这个过程。首先是在 bar 函数的执行上下文中查找,但因为 bar 函数的执行上下文中没有定义 test 变量,所以根据词法作用域的规则,下一步就在 bar 函数的外部作用域中查找,也就是全局作用域。

闭包

要理解闭包,前提要理解变量环境、词法环境和作用域链等的概念。 通过下面一段代码来辅助理解:

function foo() {
    var myName = "极客时间"
    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("极客邦")
bar.getName()
console.log(bar.getName())

首先我们看看当执行到 foo 函数内部的return innerBar这行代码时调用栈的情况,你可以参考下图: image.png 从上面的代码可以看出,innerBar 是一个对象,包含了 getName 和 setName 的两个方法(通常我们把对象内部的函数称为方法)。你可以看到,这两个方法都是在 foo 函数内部定义的,并且这两个方法内部都使用了 myName 和 test1 两个变量。

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

image.png

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

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

闭包的定义

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

红宝书的闭包定义

同时有两个函数的作用域链指针(外函数和内函数)指向了同一个变量环境对象,所以无论你删除其中任何一个指针,该变量环境对象都无法被垃圾回收(无论标记清除法还是计数法)清除,所以保存在了内存中。所以就有了所谓的“闭包”

那这些闭包是如何使用的呢?当执行到 bar.setName 方法中的myName = "极客邦"这句代码时,JavaScript 引擎会沿着“当前执行上下文–>foo 函数闭包–> 全局执行上下文”的顺序来查找 myName 变量,你可以参考下面的调用栈状态图:

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

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

思考题

var bar = {
    myName:"time.geekbang.com",
    printName: function () {
        console.log(myName) // 这里的变量myName是属于全局作用域下面的,所以,最终打印出来的值都是“极客邦”
    }    
}
function foo() {
    let myName = "极客时间"
    return bar.printName
}
let myName = "极客邦"
let _printName = foo()
_printName()
bar.printName()

这是因为 JavaScript语言的作用域链是由词法作用域决定的,而词法作用域是由代码结构来确定的。

this

this机制的原理来源:类似下面这段C++代码所示

#include <iostream>
using namespace std;
class Bar{
    public:
    char* myName;
    Bar(){
      myName = "time.geekbang.com";
    }
    void printName(){
       cout<< myName <<endl;
    }  
} bar;

char* myName = "极客邦";
int main() {
  bar.printName();
  return 0;
}

同样是调用了bar对象中的printName方法,最后打印出来的值就是 bar 对象的内部变量 myName 值——“time.geekbang.com”,而并不是最外面定义变量 myName 的值——“极客邦”,所以在对象内部的方法中使用对象内部的属性是一个非常普遍的需求但是 JavaScript 的作用域机制并不支持这一点基于这个需求,JavaScript 又搞出来另外一套 this 机制

这里要注意📢 作用域链this两套不同的系统,它们之间基本没太多联系

JavaScript中的this是什么

执行上下文中包含了变量环境、词法环境、外部环境、this image.png 从图中可以看出,this是和执行上下文绑定的。 执行上下文主要分为三种

  1. 全局执行上下文
  2. 函数执行上下文
  3. eval 执行上下文 所以对应的 this 也只有这三种
  4. 全局执行上下文中的 this
  5. 函数中的 this
  6. eval 中的 this。 接下来我们就重点讲解下全局执行上下文中的 this函数执行上下文中的 this

全局执行上下文中的 this

全局执行上下文中的 this 是指向 window 对象的。这也是 this 和作用域链的唯一交点,作用域链的最底端包含了 window 对象,全局执行上下文中的 this 也是指向 window 对象。

image.png

函数执行上下文中的 this

先看下面这段代码:

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

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

1. 通过函数call方法来设置

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

let bar = {
  myName : "极客邦",
  test1 : 1
}
function foo(){
  this.myName = "极客时间"
}
foo.call(bar)
console.log(bar)
console.log(myName)

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

其实除了 call 方法,你还可以使用 bind 和 apply 方法来设置函数执行上下文中的 this。

call、bind、apply 这三个函数的第一个参数都是 this 的指向对象,第二个参数差别就来了:call 的参数是直接放进去的,第二第三第n个参数全都用逗号分隔,直接放到后面obj.myFun.call(db,'成都', ... ,'string' )。 apply 的所有参数都必须放在一个数组里面传进去obj.myFun.apply(db,['成都', ..., 'string' ])。 bind 除了返回是函数以外,它的参数和call一样。

2.通过对象调用的方式

var myObj = {
  name : "极客时间", 
  showThis: function(){
    console.log(this)
  }
}
myObj.showThis()

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

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

其实,你也可以认为 JavaScript 引擎在执行myObject.showThis()时,将其转化为了:myObj.showThis.call(myObj)

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

var myObj = {
  name : "极客时间",
  showThis: function(){
    this.name = "极客邦"
    console.log(this)
  }
}
var foo = myObj.showThis
foo()

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

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

  • 在全局环境中调用一个函数,函数内部的 this 指向的是全局变量 window
  • 通过一个对象来调用其内部的一个方法,该方法的执行上下文中的 this 指向对象本身

3. 通过构造函数中设置

function CreateObj(){
  this.name = "极客时间"
}
var myObj = new CreateObj()

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

  • 首先创建了一个空对象 tempObj;
  • 接着调用 CreateObj.call 方法,并将 tempObj 作为 call 方法的参数,这样当 CreateObj 的执行上下文创建时,它的 this 就指向了 tempObj 对象;
  • 然后执行 CreateObj 函数,此时的 CreateObj 函数执行上下文中的 this 指向了 tempObj 对象;
  • 最后返回 tempObj 对象。 为了直观理解,我们可以用代码来演示下:
var tempObj = {} 
CreateObj.call(tempObj) 
return tempObj

这样,我们就通过 new 关键字构建好了一个新对象,并且构造函数中的 this 其实就是新对象本身。

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

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

var myObj = {
  name : "极客时间", 
  showThis: function(){
    console.log(this)//this指向的是myObj对象
    function bar(){
        console.log(this) //this指向的是全局window对象
       }
    bar()
  }
}
myObj.showThis()

小技巧解决上面的迷惑问题。

比如在 showThis 函数中声明一个变量 self 用来保存 this,然后在 bar 函数中使用 self,代码如下所示:

var myObj = {
  name : "极客时间", 
  showThis: function(){
    console.log(this)
    var self = this
    function bar(){
      self.name = "极客邦"
    }
    bar()
  }
}
myObj.showThis()
console.log(myObj.name)
console.log(window.name)

这个方法的的本质是把 this 体系转换为了作用域的体系

也可以使用ES6箭头函数来解决这个问题,结合下面代码:

var myObj = {
  name : "极客时间", 
  showThis: function(){
    console.log(this)
    var bar = ()=>{
      this.name = "极客邦"
      console.log(this)
    }
    bar()
  }
}
myObj.showThis()
console.log(myObj.name)
console.log(window.name)

执行这段代码,你会发现它也输出了我们想要的结果,也就是箭头函数 bar 里面的 this 是指向 myObj 对象的。这是因为 ES6 中的箭头函数并不会创建其自身的执行上下文,所以箭头函数中的 this 取决于它的外部函数

通过上面的讲解,你现在应该知道了 this 没有作用域的限制,这点和变量不一样,所以嵌套函数不会从调用它的函数中继承 this,这样会造成很多不符合直觉的代码。要解决这个问题,你可以有两种思路:

  • 第一种是把 this 保存为一个 self 变量,再利用变量的作用域机制传递给嵌套函数。
  • 第二种是继续使用 this,但是要把嵌套函数改为箭头函数,因为箭头函数没有自己的执行上下文,所以它会继承调用函数中的 this。

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

上面我们已经介绍过了,在默认情况下调用一个函数,其执行上下文中的 this 是默认指向全局对象 window 的。

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

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

参考文档