变量提升得从执行上下文栈说起

566 阅读10分钟

变量提升现象大家应该不陌生

function fun(){
    console.log(number1)// undefined
    var number1 = 10
    console.log(number1)// 10


    console.log(number2)// ReferenceError: Cannot access 'number2' before initialization
    let number2 = 10
    console.log(number2)// 10
}

fun()

顺序执行?

如果要问到 JavaScript 代码执行顺序的话,想必写过 JavaScript 的开发者都会有个直观的印象,那就是顺序执行,毕竟:

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

foo()  // foo1

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

foo() // foo2

然而去看这段代码:


function foo() {
    console.log('foo1')
}

foo()  // foo2

function foo() {
    console.log('foo2')
}

foo() // foo2

打印的结果却是两个 foo2

刷过面试题的都知道这是因为 JavaScript 引擎并非一行一行地分析和执行程序,而是一段一段地分析执行。当执行一段代码的时候,会进行一个“准备工作”,比如第一个例子中的变量提升,和第二个例子中的函数提升。

但是本文真正想让大家思考的是:这个“一段一段”中的“段”究竟是怎么划分的呢?

到底JavaScript引擎遇到一段怎样的代码时才会做“准备工作”呢?

可执行代码

这就要说到 JavaScript 的可执行代码(executable code)的类型有哪些了?

其实很简单,就三种,全局代码、函数代码、eval代码。

举个例子,当执行到一个函数的时候,就会进行准备工作,这里的“准备工作”,让我们用个更专业一点的说法,就叫做"执行上下文(execution contexts)"。

执行上下文栈

接下来问题来了,我们写的函数多了去了,如何管理创建的那么多执行上下文呢?

所以 JavaScript 引擎创建了执行上下文栈(Execution context stack,ECS)来管理执行上下文

为了模拟执行上下文栈的行为,让我们定义执行上下文栈是一个数组:

    ECStack = []

试想当 JavaScript 开始要解释执行代码的时候,最先遇到的就是全局代码,所以初始化的时候首先就会向执行上下文栈压入一个全局执行上下文,我们用 globalContext 表示它,并且只有当整个应用程序结束的时候,ECStack 才会被清空,所以 ECStack 最底部永远有个 globalContext:

    ECStack = [
        globalContext
    ]

现在 JavaScript 遇到下面的这段代码了:

function fun3() {
    console.log('fun3')
}

function fun2() {
    fun3()
}

function fun1() {
    fun2()
}

fun1()

当执行一个函数的时候,就会创建一个执行上下文,并且压入执行上下文栈,当函数执行完毕的时候,就会将函数的执行上下文从栈中弹出。知道了这样的工作原理,让我们来看看如何处理上面这段代码:

// 伪代码

// fun1()
ECStack.push(<fun1> functionContext);

// fun1中竟然调用了fun2,还要创建fun2的执行上下文
ECStack.push(<fun2> functionContext);

// 擦,fun2还调用了fun3!
ECStack.push(<fun3> functionContext);

// fun3执行完毕
ECStack.pop();

// fun2执行完毕
ECStack.pop();

// fun1执行完毕
ECStack.pop();

// javascript接着执行下面的代码,但是ECStack底层永远有个globalContext

执行上下文

上面已经介绍了用来管理 执行上下文 的 执行上下文栈。 那么执行上下文是什么呢?

对于每个执行上下文,都有三个重要属性:

  • 变量对象(Variable object,VO)
  • 作用域链(Scope chain)
  • this

可以看一下 冴羽 大佬的文章 《JavaScript深入之变量对象》《JavaScript深入之作用域链》《JavaScript深入之从ECMAScript规范解读this》

这里只介绍与本文相关的 变量对象

变量对象

变量对象是与执行上下文相关的数据作用域,存储了在上下文中定义的变量和函数声明。

因为不同执行上下文下的变量对象稍有不同,所以我们来聊聊全局上下文下的变量对象和函数上下文下的变量对象。

全局上下文

我们先了解一个概念,叫全局对象。在 W3C school 中也有介绍:

全局对象是预定义的对象,作为 JavaScript 的全局函数和全局属性的占位符。通过使用全局对象,可以访问所有其他所有预定义的对象、函数和属性。

在顶层 JavaScript 代码中,可以用关键字 this 引用全局对象。因为全局对象是作用域链的头,这意味着所有非限定性的变量和函数名都会作为该对象的属性来查询。

例如,当JavaScript 代码引用 parseInt() 函数时,它引用的是全局对象的 parseInt 属性。全局对象是作用域链的头,还意味着在顶层 JavaScript 代码中声明的所有变量都将成为全局对象的属性。

如果看的不是很懂的话,容我再来介绍下全局对象:

1.可以通过 this 引用,在客户端 JavaScript 中,全局对象就是 Window 对象。

console.log(this)

2.全局对象是由 Object 构造函数实例化的一个对象。

console.log(this instanceof Object)

3.预定义了一堆,嗯,一大堆函数和属性。

// 都能生效
console.log(Math.random())
console.log(this.Math.random())

4.作为全局变量的宿主。

var a = 1
console.log(this.a)

5.客户端 JavaScript 中,全局对象有 window 属性指向自身。

var a = 1
console.log(window.a)

this.window.b = 2
console.log(this.b)

花了一个大篇幅介绍全局对象,其实就想说:

全局上下文中的变量对象就是全局对象呐!

函数上下文

在函数上下文中,我们用活动对象(activation object, AO)来表示变量对象。

活动对象和变量对象其实是一个东西,只是变量对象是规范上的或者说是引擎实现上的,不可在 JavaScript 环境中访问,只有到当进入一个执行上下文中,这个执行上下文的变量对象才会被激活,所以才叫 activation object 呐,而只有被激活的变量对象,也就是活动对象上的各种属性才能被访问。

活动对象是在进入函数上下文时刻被创建的,它通过函数的 arguments 属性初始化。arguments 属性值是 Arguments 对象。

变量生命周期

当 js 引擎处理变量时, 它的生命周期由以下阶段组成

声明阶段(Declaration phase):是在作用域中注册一个变量。

初始化阶段(Initialization phase):是分配内存并为作用域中的变量创建绑定。在此步骤中,变量将使用 undefined 自动初始化。

赋值阶段(Assignment phase):是为初始化的变量赋值。

声明阶段的变量不能被 js 调用,否则会抛出一个 ReferenceError 异常。

执行过程

执行上下文的代码会分成两个阶段进行处理:分析和执行,我们也可以叫做:

  1. 进入执行上下文
  2. 代码执行

进入执行上下文

当进入执行上下文时,这时候还没有执行代码,

变量对象会包括:

  1. 函数的所有形参 (如果是函数上下文)

    • 由名称和对应值组成的一个变量对象的属性被创建
    • 没有实参,属性值设为 undefined
  2. 函数声明

    • 由名称和对应值(函数对象(function-object))组成一个变量对象的属性被创建
    • 如果变量对象已经存在相同名称的属性,则完全替换这个属性
  3. 变量声明

    • 由名称被当作一个变量对象的属性被创建;

    • 如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性

    • 如果变量由 var 关键字定义,属性会被初始化,如果由 let 或者 const 定义,属性则不会初始化, 而是保持声明阶段。

举个例子:

function foo(a) {
  var b = 2
  function c() {}
  var d = function() {}

  b = 3
  
  let e = 1

}

foo(1)

在进入执行上下文后,这时候的 AO 是:

AO = {
    arguments: {
        0: 1,
        length: 1
    },
    a: 1,
    b: undefined,
    c: reference to function c(){},
    d: undefined,
    e: 创建阶段(Declaration phase)
}

代码执行

在代码执行阶段,会顺序执行代码,根据代码,修改变量对象的值

还是上面的例子,当代码执行完后,这时候的 AO 是:

AO = {
    arguments: {
        0: 1,
        length: 1
    },
    a: 1,
    b: 3,
    c: reference to function c(){},
    d: reference to FunctionExpression "d",
    e: 1
}

到这里变量对象的创建过程就介绍完了,让我们简洁的总结我们上述所说:

  1. 全局上下文的变量对象初始化是全局对象
  2. 函数上下文的变量对象初始化只包括 Arguments 对象
  3. 在进入执行上下文时会给变量对象添加形参、函数声明、变量声明等初始的属性值
  4. 在代码执行阶段,会再次修改变量对象的属性值

回归正题

看向我们一开始的题目

function fun(){
    console.log(number1)// undefined
    var number1 = 10
    console.log(number1)// 10


    console.log(number2)// ReferenceError: Cannot access 'number2' before initialization
    let number2 = 10
    console.log(number2)// 10
}

fun()

如果你有认真的阅读上面的内容, 我想你已经知道如何严谨的解释这个现象了。

我们依然开看一下执行过程

  1. 首先是全局执行上下文被初始化,并且进入执行上下文栈

    globalContext.AO.fun = reference to function fun(){}
    
    ECStack = [
        globalContext
    ]
    
  2. 然后执行 fun() , 初始化 fun 的执行上下文,fun 的执行上下文进入执行上下文栈。

    functionContext.AO = {} // 因为 fun 的 Arguments 对象为空,所以最初AO也为空
    
    ECStack.push(<fun> functionContext);
    
  3. 进入 fun 的执行上下文, 此时 fun 的执行上下文为

    {
        number1: undefined,
        number2: Declaration phase, // 此时 number2 还是创建状态
    }
    
  4. 代码执行

    console.log(number1)// undefined
    
    var number1 = 10 // 变量对象中的 number1 被赋值为 10
    
    console.log(number1)// 10
    
    // 因为变量对象中的 number2 还处于 Declaration phase 阶段,不能被访问
    console.log(number2)// 抛出 ReferenceError 异常
    // 如果不捕获上面的异常, 程序不会再执行了,所以我们假设异常已经被捕获
    
    let number2 = 1 // 变量对象中的 number2 被初始化并且赋值为 10
    
    console.log(number2)// 10
    
  5. 执行上下文退出执行上下文栈,程序结束

    ECStack.pop()
    ECStack.pop()
    

总结

执行上下文

执行上下文中包含一段 可执行代码 的执行环境

对于每个执行上下文,都有三个重要属性:

  • 变量对象(Variable object,VO)
  • 作用域链(Scope chain)
  • this

其中 变量对象 中存储的是代码中的参数、变量和函数

js变量生命周期

当 js 引擎处理变量时, 它的生命周期由以下阶段组成

声明阶段(Declaration phase) :是在作用域中注册一个变量。

初始化阶段(Initialization phase) :是分配内存并为作用域中的变量创建绑定。在此步骤中,变量将使用 undefined 自动初始化。

赋值阶段(Assignment phase) :是为初始化的变量赋值。

声明阶段的变量不能被 js 调用,否则会抛出一个 ReferenceError 异常。

代码执行过程

当一段 可执行代码 执行时,会像创建并初始化其执行上下文,将其压入执行上下文栈中,然后进入其执行上下文

当进入执行上下文时,这时候还没有执行代码,变量对象会包括:

  1. 函数的所有形参 (如果是函数上下文)

    • 由名称和对应值组成的一个变量对象的属性被创建
    • 没有实参,属性值设为 undefined
  2. 函数声明

    • 由名称和对应值(函数对象(function-object))组成一个变量对象的属性被创建
    • 如果变量对象已经存在相同名称的属性,则完全替换这个属性
  3. 变量声明

    • 由名称被当作一个变量对象的属性被创建;
    • 如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性
    • 如果变量由 var 关键字定义,属性会被初始化,如果由 let 或者 const 定义,属性则不会初始化, 而是保持声明阶段。

最后执行代码