浏览器中的JavaScript执行机制

一段JS代码在执行之前需要被JS引擎编译,编译阶段完成之后,才会进入执行阶段

一、编译阶段

Input:JS代码

Handle:

  1. 创建执行上下文
    2. 压入调用栈中
    3. 除声明以外的代码编译为字节码

Output:执行上下文可执行代码

image-20201029114446370

二、执行阶段

JS引擎开始执行可执行代码,按照顺序一行一行的执行。

三、执行上下文(Execution Context)

所谓的执行上下文,就是JS代码执行时的运⾏环境。

该环境包含了执行期间用到的变量环境、词法环境和this等。

什么情况下会创建执行上下文?

  1. 当执⾏全局JS代码的时候,会编译全局代码并创建全局执⾏上下⽂,并且在整个⻚⾯的⽣存周期内,全局执⾏上下⽂只有⼀份。
  2. 当调⽤⼀个函数的时候,函数体内的代码会被编译,并创建函数执⾏上下⽂,⼀般情况下,函数执⾏结束后,创建的函数执⾏上下⽂会被销毁。
  3. 当使⽤eval函数的时候,eval的代码也会被编译,并创建执⾏上下⽂。

1 变量环境和变量提升(Hoisting)

所谓的变量提升,就是指在JS代码执行过程中,JS引擎把变量的声明部分函数的声明部分提升到代码开头的“行为”。变量被提升后,会被设置默认值undefined。

image-20201029121057552

(1) 什么是声明?什么是赋值?

对于变量:var myname = ‘极客时间’

  • 声明:var myname = undefined
  • 赋值:myname = ‘极客时间’

对于函数:function foo() { console.log(‘foo’) }

  • 声明:function foo() { console.log(‘foo’) }

对于匿名函数:var bar = function () { console.log(‘bar’) }

  • 声明:var bar = undefined
  • 赋值:bar = function () { console.log(‘bar’) }

(2) 变量提升的内容存储在哪里?

在执行上下文中存在一个变量环境的对象,该对象中保存了变量提升的内容。

2 词法环境和作用域

(1) 作用域

作用域:指在程序中定义变量的区域,该位置决定了变量的⽣命周期。

通俗地理解,作⽤域就是变量与函数的可访问范围,即作⽤域控制着变量和函数的可⻅性和⽣命周期。

ES6之前,ES只有两种作用域,全局作用域和函数作用域

  • 全局作用域:在代码中的任何地⽅都能访问,其⽣命周期伴随着⻚⾯的⽣命周期。
  • 函数作用域:在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执⾏结束之后,函数内部定义的变量会被销毁。

相较其他语言而言,它们都普遍支持块级作用域

  • 块级作用域:即一对{}包裹的一段代码,如{},if(1){},while(1){},for(let i = 0;i<10;i++){},function foo(){},在块内部定义的变量在块外是访问不到的,并且块中代码执行完成之后,块中定义的变量会被销毁。

(2) 变量提升带来的问题

  • 变量容易在不被察觉的情况下被覆盖掉
  • 本应销毁的变量没有被销毁,造成内存泄漏

(3) ES6是如何解决变量提升带来的缺陷?

通过letconst关键字以及执行上下文中的词法环境栈来支持块级作用域。

(4) ES6是如何做到既要支持变量提升,又要支持块级作用域的?

分析以下代码执行过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function foo() {
var a = 1
let b = 2
{
let b = 3
var c = 4
let d = 5
console.log(a) // 1
console.log(b) // 3
}
console.log(b) // 2
console.log(c) // 4
console.log(d) // reference error
}
foo()

第⼀步,编译并创建函数执⾏上下⽂

  • var 声明的变量,在编译阶段全都被存放到变量环境⾥⾯
  • 通过let声明的变量,在编译阶段会被存放到词法环境栈中,也就是创建变量并提升,但不初始化,所以在声明之前使用会报错,在声明之前使用的这段区域被称之为暂时性死区
  • 在函数内部的块作⽤域中,通过let声明的变量并没有被存放到词法环境中
image-20201029154158392

第⼆步,继续执⾏代码。

变量环境中a的值被设置成了1,词法环境中b 的值被设置成了2。

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

image-20201029154428866

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

image-20201029154814936

当作⽤域块执⾏结束之后,其内部定义的变量就会从词法环境的栈顶弹出。

image-20201029154922359

3 this

this:每个执行上下文中都有个this,所以this和执行上下文是绑定的。

然而,执行上下文分为3种,所以this大致可分为全局执行上下文中的this函数执行上下文中的thiseval中的this。(this没有作⽤域的限制)

(1) 不同情况下的this

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

函数中的this:

  • 默认情况下:调用一个函数,其this是指向window对象

  • 手动设置this的情况:

    1. 函数的方法:call, apply, bind

    2. 对象调用方法:对象调⽤其内部的⽅法,该⽅法的this指向对象本⾝

    3. 构造函数:指向实例对象

(2) this设计的缺陷

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    var myObj = { 
    name : "极客时间",
    showThis: function(){
    console.log(this)
    function bar(){
    console.log(this) // window
    }
    bar()
    }
    }
    myObj.showThis()
    • 可以声明⼀个变量self⽤来保存this(本质上是把this体系转换为了作⽤域的体系)
    • 使⽤ES6中的箭头函数(箭头函数不会创建自己的执行上下文,像是一个块作用域,所以箭头函数中的this取决于它的外部函数)
  2. 普通函数中的this默认指向全局对象window:

    严格模式下,默认执⾏⼀个函数,其函数的执⾏上下⽂中的this值是undefined

四、调用栈(Call Stack)

所谓的调用栈,也叫执行上下文栈,就是JS引擎用来存储并管理执行上下文的一种数据结构,也是JS引擎追踪函数执行的一个机制。

查看调用栈信息:

  1. console.trace()
  2. 利⽤浏览器查看调⽤栈(Call Stack)的信息

调用栈是有大小的,如果压入的执行期上下文超过一定数目就会导致栈溢出。

解决方法:

  1. 将递归形式改造成其他形式
  2. 利用定时器把当前任务拆成很多小任务。

分析以下代码的执行过程:

1
2
3
4
5
6
7
8
9
10
var a = 2 
function add(b,c) {
return b+c
}
function addAll(b,c) {
var d = 10
var result = add(b,c)
return a + result + d
}
addAll(3,6)

第⼀步,创建全局执行上下⽂,并压⼊调用栈。接着JS引擎便开始执⾏全局代码,⾸先执⾏a=2的赋值操作,执⾏该语句会将全局执行上下⽂中的变量环境对象中的a的值设置为2。

第二步,调用addAll函数。当调⽤该函数时,JavaScript引擎会编译该函数,并为其创建⼀个函数执⾏上下⽂,并压⼊栈中。之后开始执⾏阶段,先执⾏d=10的赋值操作,将addAll函数执⾏上下⽂中的d由undefined变成了10。

image-20201029131232174

第三步,当执⾏到add函数调⽤语句时,同样会为其创建函数执⾏上下⽂,并将其压⼊调⽤栈,当add函数返回时,该函数的执⾏上下⽂就会从栈顶弹出,并将result的值设置为add函数的返回值,也就是9。紧接着addAll函数执⾏最后⼀个相加操作后并返回,addAll的执⾏上下⽂也会从栈顶部弹出,此时调⽤栈中就只 剩下全局上下⽂了。

image-20201029131405516

五、词法作用域、作用域链、外部引用以及闭包

1 词法作用域

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

词法作用域决定了作用域链。同时词法作⽤域是代码阶段就决定好的,和函数是怎么调⽤的没有关系。

image-20201029163036898

整个词法作⽤域链的顺序是:foo函数作⽤域—>bar函数作⽤域—>main函数作⽤域—>全局作⽤域

2 作用域链(Scope)和外部引用(outer)

作用域链就是变量查找的作用域链条它是由词法作用域来决定的

在每个执⾏上下⽂的变量环境中,都包含了⼀个外部引⽤(outer),⽤来指向外部的执⾏上下⽂(外部的执行上下文由词法作用域决定)。

当⼀段代码使⽤了⼀个变量时,JavaScript引擎⾸先会在“当前的执⾏上下⽂”中查找该变量,如果在当前的变量环境中没有查找到,那么JavaScript引擎会继续在outer所指向的执⾏上下⽂中查找。

变量查找链条:

  1. 当前执行上下文中沿着词法环境的栈自顶向下查找
  2. 当前执行上下文中变量环境中查找
  3. outer所指向的执行上下文中查找,重复1,2,3,直至找到或找不到为止
1
2
3
4
5
6
7
8
9
function bar() { 
console.log(myName)
}
function foo() {
var myName = "极客邦"
bar()
}
var myName = "极客时间"
foo()
image-20201029164223142
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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()
image-20201029165011976

3 闭包(closure)

闭包:在JavaScript中,根据词法作⽤域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调⽤⼀个外部函数返回⼀个内部函数后,即使该外部函数已经执⾏结束了,但是内部函数引⽤的外部函数的变量依然保存在内存中(堆空间中),我们就把这些变量的集合称为闭包。

闭包产生的核心步骤:

  1. 预扫描内部函数
  2. 把内部函数引用的外部函数的变量保存到堆中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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-20201029165524256

根据词法作用域的规则,内部函数getName和setName总是可以访问他们的外部函数foo中的变量,所以当return innerBar执行并返回给全局变量bar时,虽然foo函数已经执行结束了,但是getName和setName函数依然可以使用foo函数中的变量myName和test1。

当foo函数执行完时,调用栈的状态:

image-20201029165645466

当foo函数执行完成后,其上下文也从调用栈中弹出,由于setName和getName方法中使用了foo函数中的变量myName和test1,所以这两个变量会被保存在一块名为foo(closure)的内存中,同时只能有setName和getName访问。

当执⾏到bar.setName⽅法中的myName = "极客邦"这句代码时,JavaScript引擎会沿着“当前执⾏上下⽂‒>foo函数闭包‒>全局执⾏上下⽂”的顺序查找变量myName:

image-20201029181159646

闭包使用不当会导致内存泄漏,闭包是如何被回收的:

  • 引用闭包的函数是一个全局变量,那闭包会一直存在直到页面关闭
  • 如果引⽤闭包的函数是个局部变量,等函数销毁后,在下次JS引擎执⾏垃圾回收时,判断闭包这块内容如果已经不再被使⽤了,那么JS引擎的垃圾回收器会回收这块内存

参考链接