3. JavaScript 的执行

3.1 执行上下文

(有关 JavaScript 执行上下文及解析的内容,《你不知道的 JavaScript》讲得比较细致。)

变量提升(hoisting)

1
var name = 'Wayne'

这段代码可以把它看成是两行代码组成的:

1
2
var name;	// 声明部分
name = 'Wayne'; // 赋值部分

再来看看函数的声明和赋值,结合下面这段代码:

1
2
3
4
5
6
7
function foo(){
console.log('foo')
}

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

第一个函数 foo 是一个完整的函数声明,也就是说没有涉及到赋值操作;第二个函数是先声明变量 bar,再把function(){console.log('bar')}赋值给 bar。为了直观理解,你可以参考下图:

img

函数的声明和赋值

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

JavaScript 代码的执行流程

从概念的字面意义上来看,“变量提升”意味着变量和函数的声明会在物理层面移动到代码的最前面,正如我们所模拟的那样。但,这并不准确。实际上变量和函数声明在代码里的位置是不会改变的,而且是在编译阶段被 JavaScript 引擎放入内存中。对,你没听错,一段 JavaScript 代码在执行之前需要被 JavaScript 引擎编译,编译完成之后,才会进入执行阶段。大致流程你可以参考下图:

img

JavaScript 的执行流程图

  • 1.编译阶段:一段代码经过编译后,会生成两部分内容:执行上下文(Execution Context)可执行代码

    执行上下文是 JavaScript 执行一段代码时的运行环境。比如调用一个函数,就会进入这个函数的执行上下文,确定该函数执行期间用到的 this、变量、对象和函数等。执行上下文中存在一个变量环境对象(Variable Environment),用于保存变量提升的内容。

  • 2.执行阶段:实际上,编译阶段和执行阶段都是非常复杂的,包括了词法分析、语法解析、代码优化、代码生成等。

一段代码如果定义了两个相同名字的函数,那么最终生效的是最后一个函数

3.2 调用栈

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

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

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

什么是函数调用

函数调用就是运行一个函数。如

1
2
3
4
5
6
var a = 2
function add(){
var b = 10
return a+b
}
add()

在执行到函数 add() 之前,JavaScript 引擎会为上面这段代码创建全局执行上下文,包含了声明的函数和变量,你可以参考下图:

img

全局执行上下文

从图中可以看出,代码中全局变量和函数都保存在全局上下文的变量环境中。

执行上下文准备好之后,便开始执行全局代码,当执行到 add 这儿时,JavaScript 判断这是一个函数调用,那么将执行以下操作:

  • 首先,从全局执行上下文中,取出 add 函数代码。
  • 其次,对 add 函数的这段代码进行编译,并创建该函数的执行上下文可执行代码
  • 最后,执行代码,输出结果。

完整流程你可以参考下图:

img

函数调用过程

就这样,当执行到 add 函数的时候,我们就有了两个执行上下文了——全局执行上下文和 add 函数的执行上下文。

那么 JavaScript 引擎是如何管理这些执行上下文的呢?通过一种叫栈的数据结构

什么是栈

img

栈示意图

什么是 JavaScript 的调用栈?JavaScript 引擎正是利用栈结构来管理执行上下文的。执行上下文创建后,引擎会将其压入栈中,通常把这种管理执行上下文的栈称为执行上下文栈,又称调用栈

栈溢出(Stack Overflow)

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

3.3 块级作用域

由于 JavaScript 存在变量提升这种特性,从而导致了很多与直觉不符的代码,这也是 JavaScript 的一个重要设计缺陷

虽然 ECMAScript6(以下简称 ES6)已经通过引入块级作用域并配合 let、const 关键字,来避开了这种设计缺陷,但是由于 JavaScript 需要保持向下兼容,所以变量提升在相当长一段时间内还会继续存在。这也加大了你理解概念的难度,因为既要理解新的机制,又要理解变量提升这套机制,关键这两套机制还是同时运行在“一套”系统中的。

作用域(scope)

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

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

  • 全局作用域中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。
  • 函数作用域就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。

在 ES6 之前,JavaScript 只支持这两种作用域,相较而言,其他语言则都普遍支持块级作用域。块级作用域就是使用一对大括号包裹的一段代码,比如函数、判断语句、循环语句,甚至单独的一个{}都可以被看作是一个块级作用域。

变量提升所带来的问题:

  • 1.变量容易在不被察觉的情况下被覆盖掉
  • 2.本应销毁的变量没有被销毁

ES6 引入了 let 和 const 关键字,从而使 JavaScript 也能像其他语言一样拥有了块级作用域,从而解决了上述问题。

那么在同一段代码中,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)
console.log(b)
}
console.log(b)
console.log(c)
console.log(d)
}
foo()

当执行上面这段代码的时候,JavaScript 引擎会先对其进行编译并创建执行上下文,然后再按照顺序执行代码,关于如何创建执行上下文我们在前面的文章中已经分析过了,但是现在的情况有点不一样,我们引入了 let 关键字,let 关键字会创建块级作用域,那么 let 关键字是如何影响执行上下文的呢?

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

第一步是编译并创建执行上下文,下面是我画出来的执行上下文示意图,你可以参考下:

img

刚执行时 foo 函数的执行上下文

通过上图,我们可以得出以下结论:

  • 函数内部通过 var 声明的变量,在编译阶段全都被存放到变量环境里面了。
  • 通过 let 声明的变量,在编译阶段会被存放到**词法环境(Lexical Environment)**中。
  • 在函数内层的作用域块中,通过 let 声明的变量会在执行到该代码块时被压入词法环境。

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

img

执行 foo 函数内部作用域块时的执行上下文

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

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

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

这样一个变量查找过程就完成了,你可以参考下图:

img

变量查找过程

从上图你可以清晰地看出变量查找流程,不过要完整理解查找变量或者查找函数的流程,就涉及到作用域链了,这个我们会在下篇文章中做详细介绍。

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

img

作用域执行完成示意图

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

3.4 作用域链和闭包

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

词法作用域

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

img

词法作用域

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

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

闭包

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

闭包是如何回收的

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

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

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

3.5 this

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

JavaScript 中的 this 是什么

img

执行上下文中的 this

从图中可以看出,this 是和执行上下文绑定的,也就是说每个执行上下文中都有一个 this。

执行上下文主要分为三种——全局执行上下文、函数执行上下文和 eval 执行上下文,所以对应的 this 也只有这三种——全局执行上下文中的 this、函数中的 this 和 eval 中的 this。

全局执行上下文中的 this

在浏览器的传统脚本环境中,全局执行上下文里的 this 指向 window 对象;ES 模块中的顶层 this 则是 undefined。

函数执行上下文中的 this

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

  • 通过函数的 call 方法设置

  • 通过对象调用方法设置:使用对象来调用其内部的一个方法,该方法的 this 是指向对象本身的

  • 通过构造函数中设置

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

  • 嵌套函数中的 this 不会从外层函数中继承。解决方式:

    • 第一种是把 this 保存为一个 self 变量,再利用变量的作用域机制传递给嵌套函数。
    • 第二种是把嵌套函数改为箭头函数。箭头函数没有自己的 this 绑定,会从外层词法作用域捕获 this。
  • 普通函数中的 this 默认指向全局对象 window。解决方式:这个问题可以通过设置 JavaScript 的“严格模式”来解决。在严格模式下,默认执行一个函数,其函数的执行上下文中的 this 值是 undefined,这就解决上面的问题了。