JavaScript执行机制的知识点整理(很详细)

890 阅读15分钟

变量提升

变量的声明和赋值

var name = '变量提升';

以上代码可以看作

var name = undefined; // 声明部分
name = '变量提升'; // 赋值部分

函数的声明和赋值

function foo() {
  console.log('foo')
}
var bar = function () {
  console.log('bar')
}

第一个函数 foo  是一个完整的函数声明,也就是说没有涉及到赋值操作;第二个函数是先声明变量 bar ,再把 function(){console.log('bar')} 赋值给 bar 。

image.png

  • 在执行过程中,若使用了未声明的变量,那么 JavaScript 执行会报错。
  • 在一个变量定义之前使用它,不会出错,但是该变量的值会为 undefined ,而不是定义时的值。
  • 在一个函数定义之前使用它,不会出错,且函数能正确执行。

变量提升小结

  • 所谓的变量提升,是指在 JavaScript 代码执行过程中,JavaScript 引擎把变量和函数的声明部分提升到代码开头的“行为”。变量被提升后,会给变量设置默认值 undefined 。
  • 可以在定义之前使用变量或者函数的原因:函数和变量在执行之前都提升到了代码开头
  • 函数中的变量无论是在哪里声明的,在编译阶段都会被提取到执行上下文的变量环境中,所以这些变量在整个函数体内部的任何地方都是能被访问的。
  • 函数提升优于变量提升

JavaScript 代码的执行流程

实际上变量和函数声明在代码里的位置是不会改变的,而且是在编译阶段被 JavaScript 引擎放入内存中。

image.png

JavaScript 的执行机制是先编译,再执行

  • 编译阶段,变量和函数会被存放到变量环境中,变量的默认值会被设置为 undefined ;
  • 代码执行阶段,JavaScript 引擎会从变量环境中去查找自定义的变量和函数。
  • 如果在编译阶段,存在两个相同的函数,那么最终存放在变量环境中的是最后定义的那个,这是因为后定义的会覆盖掉之前定义的

编译阶段

从上图可以看出,输入一段代码,经过编译后,会生成两部分内容:执行上下文(Execution context)和可执行代码执行上下文是 JavaScript 执行一段代码时的运行环境,比如调用一个函数,就会进入这个函数的执行上下文,确定该函数在执行期间用到的诸如 this 、变量、对象以及函数等。

哪些情况下代码才算是“一段”代码,才会在执行之前就进行编译并创建执行上下文。一般说来,有三种情况:

  • 当 JavaScript 执行全局代码的时候,会编译全局代码并创建全局执行上下文,而且在整个页面的生存周期内,全局执行上下文只有一份。
  • 当调用一个函数的时候,函数体内的代码会被编译,并创建函数执行上下文,一般情况下,函数执行结束之后,创建的函数执行上下文会被销毁。
  • 当使用 eval 函数的时候, eval 的代码也会被编译,并创建执行上下文。

执行阶段

JavaScript 引擎开始执行“可执行代码”,按照顺序一行一行地执行。 实例 代码1:

function showName() {
    console.log('极客邦');
}
showName();
function showName() {
    console.log('极客时间');
}
showName(); 

以上代码都是输出 极客时间 。

  1. 首先是编译阶段。
    1. 遇到了第一个 showName 函数,会将该函数体存放到变量环境中。
    2. 接下来是第二个 showName 函数,继续存放至变量环境中,但是变量环境中已经存在一个 showName 函数了,此时,第二个 showName 函数会将第一个 showName 函数覆盖掉。这样变量环境中就只存在第二个 showName 函数了。
  2. 接下来是执行阶段。
    1. 先执行第一个 showName 函数,但由于是从变量环境中查找 showName 函数,而变量环境中只保存了第二个 showName 函数,所以最终调用的是第二个函数,打印的内容是 极客时间
    2. 第二次执行 showName 函数也是走同样的流程,所以输出的结果也是 极客时间

代码2

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

以上代码分别输出 1 、 2 ;等同于

// 编译阶段
var showName = undefined;
function showName() {
  console.log(1)
}
// 执行阶段
showName(); // 1
// 重新赋值
showName = function () {
  console.log(2)
}
showName(); // 2

调用栈

调用栈概念

调用栈就是用来管理函数调用关系的一种数据结构

JavaScript 引擎正是利用栈的这种结构来管理执行上下文的(追踪函数执行)。在执行上下文创建好后,JavaScript 引擎会将执行上下文压入栈中,通常把这种用来管理执行上下文的栈称为执行上下文栈,又称调用栈;遵循后进先出的顺序

浏览器查看调用栈

可以利用浏览器断点查看调用栈的信息:Sources中的 Call Stack 就是调用栈的情况。

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

image.png

栈溢出(Stack Overflow)

调用栈是有大小的,当入栈的执行上下文超过一定数目,JavaScript 引擎就会报错,这种错误叫做栈溢出

例如执行没有任何终止条件的递归函数,一直创建新的函数执行上下文,并反复将其压入栈中,但栈是有容量限制的,超过最大数量后就会出现栈溢出的错误。

块级作用域

由于 JavaScript 存在变量提升这种特性,从而导致了很多与直觉不符的代码,这也是 JavaScript 的一个重要设计缺陷。ES6已经通过引入块级作用域并配合 let 、 const 关键字,来避开这种设计缺陷,但是由于 JavaScript 需要保持向下兼容,所以变量提升在相当长一段时间内还会继续存在。

作用域(scope)

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

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

  • 全局作用域中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。
  • 函数作用域就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。 在ES6新增了块级作用域
  • 块级作用域就是使用一对大括号包裹的一段代码,比如函数、判断语句、循环语句或单独的一个 {} 都可以被看作是一个块级作用域
    • 如果一种语言支持块级作用域,那么其代码块内部定义的变量在代码块外部是访问不到的
    • 并且等该代码块中的代码执行完成之后,代码块中定义的变量会被销毁

变量提升所带来的问题

由于变量提升,使用 JavaScript 来编写和其他语言相同逻辑的代码,都有可能会导致不一样的执行结果。

  • 变量容易在不被察觉的情况下被覆盖掉
var name = "极客时间"
function showName() {
  console.log(myname);
  if (0) {
    var myname = "极客邦"
  }
  console.log(myname);
}
showName();

以上输出的都是 undefined ;来看这段代码的调用栈:

image.png

有两个 myname 变量:

  • 一个在全局执行上下文中,其值是“极客时间”;
  • 另外一个在 showName 函数的执行上下文中,其值是 undefinedif 语句里面的语句不会执行,但是 var 声明的变量会提升) 执行这段代码需要使用变量 myname,会优先使用函数执行上下文里面的变量,即 undefined 。
  • 本应销毁的变量没有被销毁
function foo() {
  for (var i = 0; i < 7; i++) {
  }
  console.log(i);
}
foo()

for 循环结束之后, i 的值并未被销毁,所以最后打印出来的是 7 。这同样也是由变量提升而导致的,在创建执行上下文阶段,变量 i 就已经被提升了,所以当 for 循环结束之后,变量 i 并没有被销毁。

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

ES6 引入了 let 和 const 关键字,从而使 JavaScript 也能像其他语言一样拥有了块级作用域块级作用域块内声明的变量不影响块外面的变量

function letTest() {
  let x = 1;
  if (true) {
    let x = 2; // 不同的变量
    console.log(x); // 2
  }
  console.log(x); // 1
}

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

第一步是编译并创建执行上下文

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 引擎也就同时支持了变量提升和块级作用域了

作用域链和闭包

作用域链

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

所以查找变量的过程是这样的:

  1. 当一段代码使用了一个变量时,JavaScript 引擎首先会在“当前的执行上下文”中查找该变量;
  2. 如果在当前的变量环境中没有查找到,那么 JavaScript 引擎会继续在 outer  所指向的执行上下文中查找。 这个查找的链条就称为作用域链

如以下代码的查找过程如下图所示:

function bar() {
  console.log(myName)
}
function foo() {
  var myName = "极客邦"
  bar()
}
var myName = "极客时间"
foo()

image.png

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

foo 函数调用的 bar 函数,那为什么 bar 函数的外部引用是全局执行上下文,而不是 foo 函数的执行上下文?要回答这个问题,需要知道什么是词法作用域。这是因为在 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();

要想得出其执行结果,那就得站在作用域链和词法环境的角度来分析下其执行过程。

image.png

首先是在 bar 函数的执行上下文中查找,但因为 bar 函数的执行上下文中没有定义 test 变量,所以根据词法作用域的规则,下一步就在 bar 函数的外部作用域中查找,也就是全局作用域。即最后输出的是全局作用域中 test 的值 1

闭包

通过变量环境、作用域链和词法作用域来理解闭包

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 是一个对象,包含了 getNamesetName 的两个方法(通常把对象内部的函数称为方法)。可以看到,这两个方法都是在 foo 函数内部定义的,并且这两个方法内部都使用了 myNametest1 两个变量。
  • 根据词法作用域的规则,内部函数 getNamesetName 总是可以访问它们的外部函数 foo 中的变量。所以当 innerBar 对象返回给全局变量 bar 时,虽然 foo 函数已经执行结束,但是 getName 和 setName 函数依然可以使用 foo 函数中的变量 myNametest1。所以当 foo 函数执行完成之后,其整个调用栈的状态如下图所示:

image.png

  • 从上图可以看出, foo 函数执行完成之后,其执行上下文从栈顶弹出了,但是由于返回的 setNamegetName 方法中使用了 foo 函数内部的变量 myNametest1,所以这两个变量依然保存在内存中。

所以闭包的定义如下:

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

比如外部函数是 foo,那么这些变量的集合就称为 foo 函数的闭包。

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

image.png

  • 从图中可以看出, setName 的执行上下文中没有 myName 变量, foo 函数的闭包中包含了变量 myName,所以调用 setName 时,会修改 foo 闭包中的 myName 变量的值。同样的流程,当调用 bar.getName 的时候,所访问的变量 myName 也是位于 foo 函数闭包中的。

闭包是怎么回收的

如果闭包使用不正确,会很容易造成内存泄漏的,关注闭包是如何回收可以正确地使用闭包。

  • 通常,如果引用闭包的函数是一个全局变量,那么闭包会一直存在直到页面关闭;但如果这个闭包以后不再使用的话,就会造成内存泄漏
  • 如果引用闭包的函数是个局部变量,等函数销毁后,在下次 JavaScript 引擎执行垃圾回收时,判断闭包这块内容如果已经不再被使用了,那么 JavaScript 引擎的垃圾回收器就会回收这块内存。 所以在使用闭包的时候,要尽量注意一个原则:如果该闭包会一直使用,那么它可以作为全局变量而存在,不适用后手动回收;但如果使用频率不高,而且占用内存又比较大的话,那就尽量让它成为一个局部变量

this

在对象内部的方法中使用对象内部的属性是一个非常普遍的需求。但是 JavaScript 的作用域机制并不支持这一点,基于这个需求,JavaScript 搞出来另外一套 this 机制。

this 是和执行上下文绑定的,执行上下文中还包含了变量环境、词法环境、外部环境。

全局执行上下文中的 this

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

函数执行上下文中的 this

有下面三种方式来设置函数执行上下文中的 this 值:

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

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

  • 嵌套函数中的 this 不会从外层函数中继承
var myObj = {
  name: "极客时间",
  showThis: function () {
    console.log(this); // myObj
    function bar() {
      console.log(this); // window
    }
    bar()
  }
}
myObj.showThis();

打印的结果分别是 myObj 和 window 对象,即函数 bar 中的 this 指向的是全局 window 对象,而函数 showThis 中的 this 指向的是 myObj 对象。

解决办法:

  1. 可以在函数中声明一个变量用来保存 this,然后在嵌套函数中使用该变量,代码如下所示:
var myObj = {
  name: "极客时间",
  showThis: function () {
    console.log(this); // myObj
    let self = this;
    function bar() {
      console.log(self); // myObj
    }
    bar()
  }
}
myObj.showThis();

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

  1. 也可以使用 ES6 中的箭头函数来解决这个问题
var myObj = {
  name: "极客时间",
  showThis: function () {
    console.log(this); // myObj
    let self = this;
    var bar = () => { console.log(this) }
    bar()
  }
}
myObj.showThis();

即解决嵌套函数中的 this 不会从外层函数中继承这个问题有两种思路:

  1. 第一种是把 this 保存为一个 self 变量,再利用变量的作用域机制传递给嵌套函数。
  2. 第二种是继续使用 this,但是要把嵌套函数改为箭头函数,因为箭头函数没有自己的执行上下文,所以它会继承调用函数中的 this
  • 普通函数中的 this 默认指向全局对象 window 在实际工作中,我们有时候并不希望函数执行上下文中的 this 默认指向全局对象,因为这样会打破数据的边界,造成一些误操作,解决这个问题有两种方法:
  1. 如果要让函数执行上下文中的 this 指向某个对象,最好的方式是通过 call 方法来显示调用。
  2. 这个问题可以通过设置 JavaScript 的“严格模式”来解决。在严格模式下,默认执行一个函数,其函数的执行上下文中的 this 值是 undefined

总结

  • JavaScript代码的执行流程分为编译阶段和执行阶段
    • 在编译阶段会提升变量和函数,即提取到执行上下文的变量环境
    • 在执行阶段会运行可执行代码,要用到的变量和函数会根据作用域链依次查找调用
  • 作用域是变量与函数的可访问范围,有全局作用域、函数作用域和块级作用域
    • 全局作用域中的对象在代码中的任何地方都能访问
    • 函数作用域定义的变量或者函数只能在函数内部被访问
    • 块级作用域是ES6新增的概念,通过 let 和 const 实现,其声明的变量不影响块外面的变量
  • 执行上下文是指JavaScript 执行一段代码时的运行环境,有变量环境和词法环境
    • 变量环境存储通过 var 声明的变量,在变量环境中实现了变量提升
    • 词法环境存储通过 let 和 const 声明的变量,通过词法环境的栈结构实现了块级作用域
  • 作用域链是一条查找引用变量的链条
    • 每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文
    • 词法作用域是指根据代码中函数声明的位置来决定的作用域
  • 闭包是定义在函数内部的函数,通过闭包可以访问函数内部的变量