06 浏览器中的js执行机制

242 阅读12分钟

1. 变量提升

开始前我们先看一段代码:

showName()
console.log(myName);
var myName = 'cuifan'
var showName = function() {
  console.log(1);
}

function showName() {
  console.log(2);
}
showName()

这段代码的输出结果为:

2
undefined
1

上述代码可以看作:

// 变量提升
function showName = function() {
    console.log(2)
} 
var myName = undefined; 
var showName = undefined; 

showName()
console.log(myName)
myName = 'cuifan'
showName = function() {
    console.log(1)
}
showName()

要解释这这种问题,你必须了解js的变量提升以及执行上下文的概念。

变量提升指在 JavaScript 代码执行过程中,JavaScript 引擎把变量的声明部分函数的声明部分提升到代码开头的“行为”。

然而JavaScript并不会移动你的代码,所以JavaScript中“变量提升”并不是真正意义上的“提升”。

JavaScript是单线程语言,所以执行肯定是按顺序执行。但是并不是逐行的分析和执行,而是一段一段地分析执行,会先进行编译阶段然后才是执行阶段。 image.png 从上图可以看出,输入一段代码,经过编译后,会生成两部分内容:执行上下文 (Execution context)和可执行代码。

执行上下文是 JavaScript 执行一段代码时的运行环境,比如调用一个函数,就会进入这个 函数的执行上下文,确定该函数在执行期间用到的诸如 this、变量、对象以及函数等。

但是letconst这种行为就有些怪异了。

console.log(cuifan)
let cuifan = 20 // ReferenceError: Cannot access 'cuifan' before initialization
console.log(cuifan)
const cuifan = 20 // ReferenceError: Cannot access 'cuifan' before initialization

是因为letconst的声明没有被提升吗?

事实上所有的声明(function, var, let, const, class)都会被“提升”。但是只有使用var关键字声明的变量才会被初始化undefined值,而letconst声明的变量则不会被初始化值

两段代码:

console.log(name)
var name;
name = 'cuifan'

JS引擎在编译这段代码时候,会把name加入到变量环境中:

执行上下文中var声明的变量存放在变量环境中;let、const声明的变量存在于词法环境中。

// 编译
variableEnvironment = {
  name:undefined
}
// 代码执行到第三行
variableEnvironment = {
  name:'cuifan'
}
console.log(name)
let name;
name = 'cuifan'

JS引擎同样会把let和const声明的变量进行提升,但是却不会被初始化值,所以在console.log(name)的时候会报错

// 编译
variableEnvironment = {
  name:<uninitialized>
}
// 代码执行到一行
console.log(name) // ReferenceError: Cannot access 'cuifan' before initialization

只有在执行阶段JavaScript引擎在遇到他们的词法绑定(赋值)时,他们才会被初始化。这意味着在JavaScript引擎在声明变量之前(出现let字样),无法访问该变量。这就是我们所说的Temporal Dead Zone,即变量创建和初始化之间的时间跨度,它们无法访问。

如果JavaScript引擎在letconst变量被声明的地方还找不到值的话,就会被赋值为undefined或者返回一个错误(const的情况下)。

let name
console.log(name) // undefined
name = 1
// 编译(变量提升)
lexicalEnvironment = {
  name:<uninitialized>
}
// 执行
// let声明的地方没有值,初始化为undefined[const抛出错误]
lexicalEnvironment = {
  name:undefined
}

我们可以在let声明之前使用这个变量,只要代码不是在声明之前执行。

一个例子:

function foo() {
    console.log(name)
}
let name = 'John Doe' 
foo()   // 'John Doe'
function foo = function() {
    console.log(name) 
}
let name
name = 'John Doe'
foo()

2. 调用栈

一段代码被执行的时候,JS引擎会先对其进行编译、并创建上下文,但是哪些情况下的代码才算是一段代码,才会在执行前为其创建上下文呢?大概有下列三种情况:

  1. 当 JavaScript 执行全局代码的时候,会编译全局代码并创建全局执行上下文,而且在整个页面的生存周期内,全局执行上下文只有一份。
  2. 当调用一个函数的时候,函数体内的代码会被编译,并创建函数执行上下文,一般情况下,函数执行结束之后,创建的函数执行上下文会被销毁。
  3. 当使用 eval 函数的时候,eval 的代码也会被编译,并创建执行上下文。 JS引擎在编译JS代码的时候会先创建全局上下文执行和全局执行代码,执行全局全局代码遇到函数调用的时候,又会对该函数进行编译创建函数执行上下文和可执行代码,JS通过调用栈来管理这些创建的执行上下文。

下面以一段代码为例分析JS引擎在执行全局执行代码时候调用栈的状态信息。

var a = 2

function add(b, c) {
  return b + c
}

function addAll(b, c) {
  var d = 10
  result = add(b, c)
  return a + result + d
}
addAll(3, 6)
  1. 首先创建全局执行上下文,将其压入栈底

image.png

  1. 执行全局代码,执行到addAll(3.6)创建执行上下文压入栈、紧接着调用addAll的可执行代码再创建add执行上下文,执行完其可执行代码后出栈

image.png

image.png

image.png

image.png

image.png

可以在代码中输入console.trace()查看调用栈情况

3. 作用域

ES6之前JavaScript只有函数作用域和全局作用域,没有块级作用域,因此带来了很多问题。比如变量提升所导致的变量覆盖和销毁的问题。

接下来,我将在执行上下文角度简单阐述下JS是如何支持块级作用域的。

首先看一段代码:

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

接下来我们来分析一下let关键字是如何影响执行上下文的。

  1. JS引擎编译并创建上下文(仅以foo执行上下文为例):

image.png

我们发现:

  1. 函数内部用var声明的变量,编译阶段都保存在变量环境中。
  2. 函数内部用let声明的变量,编译阶段都保存在词法环境中。
  3. 函数内部作用域,用let声明的变量没有被放到词法变量中
  1. 执行可执行代码

image.png

词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量压到栈顶;当作用域执行完成之后,该作用域的信息就会从栈顶弹出。

变量查找方式:沿着词法环境的栈顶向下查询,如果在词法环境中的某个块中查找到了,就直接返回给 JavaScript 引擎,如果没有查找到,那么继续在变量环境中查找。

函数只会在第一次执行的时候被编译,编译时变量环境和词法环境最顶层数据已经确定了。当执行到块级作用域的时候,块级作用域中通过let和const申明的变量会被追加到词法环境中,当这个块执行结束之后,追加到词法作用域的内容又会销毁掉。

4. 作用域链和闭包

我们首先看一段代码:

function bar() {
  console.log(myName)
}

function foo() {
  var myName = "极客时间"
  bar()
}

var myName = "极客帮"
foo()

代码的输出结果为:

极客帮

执行到bar函数内部的时候,调用栈为: image.png

在每个上下文的变量环境中其实都存在一个outer,用来指向外部的上下文。当一段代码使用了一个变量的时候,JS引擎首先会在当前执行上下文中查找该变量,如果没找到,会在outer所指向的执行上下文中查找。我们把这条查找顺序叫做作用域链

image.png

了解了什么是作用域链了之后,你可能会好奇,为什么bar的outer是全局执行上下文而不是foo?要回答这个问题,你需要再了解一下词法作用域。

词法作用域是静态的作用域,是由代码中函数声明的位置来决定的,是在编译阶段就已经确定的,和代码如何调用没有关系。

了解了词法作用域的概念之后,下面我们来看一个例子:

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

最后的输出结果为:

1

下图是打印test时的调用栈:

image.png

了解了作用域链了之后,我们接下来来聊聊闭包。

先看一段代码:

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

下图为执行到return innerBar时候的调用栈:

image.png

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

image.png

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

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

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

闭包是如何使用的呢? 执行到上述代码的myName = "极客邦"的时候:

image.png

JavaScript 引擎会沿着“当前执行上下文–>foo 函数闭包–> 全局执行上下文”的顺序来查找 myName 变量。

理解了闭包之后,我们再来谈谈闭包的销毁。

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

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

关于垃圾回收,后续文章再做介绍。

5. this

基于上面词法作用域所带来的弊端,即对象内部的方法不能访问对象内部的属性(当这个方法来自于外部),JavaScript引入了this.

开始之前,稍做强调。this和作用域链是两套不一样的系统。

之前我们提及的执行上下文,它的完整结构应该如下图:

image.png

图中可以看出,this是和上下文绑定的,即每个上下文都有一个this.

前面提到过,上下文主要分为三种:全局执行上下文、函数执行上下文、eval执行上下文。所以对应的this也有三种:全局执行上下文中的 this、函数中的 this 和 eval 中的 this。

下面重点介绍全局执行上下文中的 this和函数执行上下文中的 this。

全局执行上下文中的this指向window对象。

这里和作用域链有个交叉,即作用域链的最低端包含了window对象。

那么函数执行上下文中的this呢?

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

我们发现这里的this也是window对象,这说明在默认情况下调用函数,其执行上下文中的this也是指向window 的。

那能不能修改执行上下文中的this指向呢?通常情况下,有三种方式可以修改this的指向:

  1. 通过bind方法设置:
let obj = {

    name:'崔帆',

    age:20

}

function foo() {

    this.name = '李嘉敏'

}

foo.call(obj) // 调用函数foo,把this指向obj

console.log(obj.name)

console.log(obj.age)

类似的方法还有bind,apply不再过多赘述。

  1. 通过对象调用设置
let obj = {
  uname: 'cuifan',
  sayHello() {
    console.log(this.uname);
  }
}

obj.sayHello()

使用对象来调用这个方法,方法内部的this就指向调用方法的对象。

可以理解为执行了obj.sayHello.call(obj)

  1. 通过构造函数设置
function Person(name,age) {
  this.name = name
  this.age = age
}

let cuifan = new Person('cuifan',20)

new相当于在Person函数内部执行了:

cuifan = {}
Person.call(cuifan)
// 赋值操作...
return cuifan

讲完了this的设置,下面来谈谈this这样设计存在的缺陷。

  1. 嵌套函数的this不会从外层函数继承
let cuifan = {
  uname: 'cuifan',
  testFunc: function() {
    console.log(this.uname);
    let testThis = function() {
      console.log(this.uname);
    }
    testThis()
  }
}
cuifan.testFunc()

输出为:

cuifan
undefined

可见,函数内部的函数中的this指向的是window对象。

针对这种问题有两种解决办法,一是保留this的状态,二是使用箭头函数(箭头函数在执行的时候不会创建执行上下文,不会产生新的this

let cuifan = {
  uname: 'cuifan',
  testFunc: function() {
    console.log(this.uname);
    (function(self) {
      console.log(self.uname);
    })(this)
  }
}

cuifan.testFunc()
let cuifan = {
  uname: 'cuifan',
  testFunc: function() {
    console.log(this.uname);
    let testThis = () => {
      console.log(this.uname);
    }
    testThis()
  }
}
cuifan.testFunc()
  1. 普通函数中的this指向全局对象window 这个问题可以通过设置 JavaScript 的“严格模式”来解决。在严格模式下,默认执行一个 函数,其函数的执行上下文中的 this 值是 undefined