js 变量和函数声明提升的原理,顺带说说作用域链🖇

250 阅读7分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第4天,点击查看活动详情

变量与函数声明提升

我们知道,我们可以在 var 定义一个变量前打印该变量而不会报错,或是在通过 function 定义函数前调用该函数执行,因为 js 中存在着变量提升 (Hoisting) 和函数声明提升,那么其背后的原理,到底是什么呢?

var 声明的变量

先研究为什么 var 声明的变量会存在变量提升。

console.log(singer) // undefined
var singer = 'Jay'
  • 编译阶段
    代码在从 js 源码到解析成 AST 的过程中(也就是编译阶段),V8 引擎 内部会创建一个全局的对象 GlobalObject(简称 GO),里面放的都是一些全局对象,比如 StringDateNumbersetTimeoutconsole 等。在浏览器环境下,还有个 window 属性,这个 window 指向的就是 GlobalObject 本身,所以我们可以 console.log(window.window) 得到就是全局对象。除了这些,我们写在全局作用域里的变量 singer,也会被放入 GlobalObject 中,但此时代码还没执行,变量 singer 还没被赋值,所以此时它的值是 undefined
    image.png
  • 执行阶段 V8 为了执行代码,V8 引擎内部会创建一个函数调用栈 —— 执行上下文栈(Execution Context Stack,ECStack)。当某段代码真正执行的时候,需要放入 ECStack 中,比如现在要执行全局的代码,那么就需要把全局代码放入 ECStack 中。为了全局代码能正常的执行,V8 会在全局代码需要执行时创建全局执行上下文(Global Execution Context,GEC)。而根据 ECMA 规范(es5 之前)每一个执行上下文,都会关联个东西叫做 VO(variable object 变量对象),对于全局执行上下文而言,VO 实际上指向的就是 GO(GlobalObject):
    image.png
    当执行 var singer = 'Jay',就会通过 VO 找到 GO 里的 singer,并赋值成 Jay。 至此,就可以理解为什么用 var 声明的变量存在作用域提升这么一个概念了,就是因为在解析的时候变量都被放入 GO 了。所以我们在第 1 行打印 singer 并不会报错说 'not defined',而是值为 undefined

函数

在全局变量声明的函数又是如何被执行的呢?为什么如下代码中,第 1 行的 fn 可以在定义之前执行,并且得到正确的结果 Jay,而不是像变量那样,在定义变量前打印变量得到的是 undefined 呢?

fn('Jay') // Jay
function fn(name) {
  console.log(age) // undefined
  var age = 40
  console.log(name)
}
  • 编译阶段 编译第 1 行时发现是一个函数执行,此时只是编译阶段,不会执行函数,继续往下编译。来到第 2 行,发现是个函数声明,就往 GO 里添加属性 fn,值则不像上面 var 声明的基本类型变量 singer 那样为 undefined,而是一个内存地址,指向的是在内存空间新开辟的一块区域,用来存储 fn 函数对象。里面保存了函数 fn 的父级作用域和函数的函数体等信息。fn 的父级作用域就是全局作用域,也就是 GO。
    image.png
  • 执行阶段 代码执行到第 2 行时,首先通过 GO(VO)找到 fn 的值,然后通过内存引用地址,找到 fn 函数对象。因为 fn 后面跟了(),代表要执行 fn。执行函数需要创建函数执行上下文(Functional Execution Context,FEC),并推入 ECStack 中。只要是执行上下文,就会关联 VO,此处 VO 可以看成指向的是在函数执行会创建的 AO(Activation Object,活动对象)。函数声明和形参、arguments 等信息会作为 AO 的属性,比如本例中的变量 age 和参数 name,在初始化的时候它们的值都默认为 undefined。这也是为何当代码执行到第 3 行时,age 的值为 undefined 而不是报错未定义的原因。等 AO 创建完成开始执行函数体。
    image.png
    当函数 fn 执行完毕后,函数 fn 的执行上下文(FEC) 就会被弹出调用栈并销毁,而 AO 和保存 fn 函数的对象如果没有在其它地方被引用的话,也会被销毁(具体可参见《V8 引擎的垃圾回收机制》)。
  • 注意 只有函数声明会被作为属性添加到 GO/AO 中,而函数表达式不会。即如果是下面这种函数声明,则可以打印得到 a
function a() {}
console.log(a) // ƒ a() {}

如果是函数表达式,则只能得到 a,而找不到 b

var a = function b() {}
console.log(a) // ƒ b(){}
console.log(b) // Uncaught ReferenceError: b is not defined

作用域链

细心的你可能注意到了,上面的图中,在 FEC 内的 VO 下面,我写了“...”,因为函数执行上下文里,除了 VO 还记录着其它信息,比如 this 和下面要介绍的作用域链(scope chain)。作用域是指程序源代码中定义变量的区域,规定了如何查找变量,即确定当前执行代码对变量的访问权限。我们知道,如下代码,fn2 执行的结果,即打印 a 得到的会是 1

var a = 1
function fn1() {
  console.log(a)
}
function fn2() {
  var a = 2
  function fn3() {
    var a = 3
  }
  fn3()
  fn1()
}
fn2() // 1

其背后的原因,就是作用域链的存在。每个函数的执行上下文中存在着作用域链,它包含着该函数本身的变量对象(VO)及父级环境的作用域。上面的代码从编译到执行的过程大致如下:

  • 编译阶段 在 GO 中添加属性 a,值为 undefined;添加属性 fn1fn2,值为它们各自的函数对象的内存地址。函数对象内保存着函数体和父级作用域等信息,所以说父级作用域在函数定义时就确定了,此例中 fn1fn2 的父级作用域都是全局作用域(GO)。至于在函数 fn2 内部定义的 fn3,则不会在此时编译(这里只会做预编译),而是等到 fn2 执行时编译。
  • 执行阶段 执行完第 1 行,将 GO 中 a 赋值为 1; 接着执行第 13 行调用 fn2(),将 fn2 函数上下文压入执行上下文栈 ECStack,在执行前生成 AO(如下图): image.png
    然后开始执行 fn2。执行到第 10 行发现要调用 fn3,则生成 fn3 的 FEC 并入栈。在执行 fn3 前生成对应的 AO,里面也有个属性 a,值为 3fn3 的 FEC 的父级作用域为 fn2 的作用域,也就是 fn2 对应的 VO。
    image.png
    执行完 fn3,则 fn3 的 FEC 出栈。继续执行调用 fn1,同样创建 fn1 的 FEC 入栈并在执行前创建 fn1 的 AO,执行 fn1 需要打印 a 属性,就会通过作用域寻找 a,首先在 fn1 本身的作用域里寻找,也就是 fn1 对应的那个 VO 里找,发现没有 a。于是就往其父作用域中寻找,此处则是指全局作用域(GO)里找,发现 a1,故而最终执行打印结果为 1。
    image.png

One More Thing

事实上,VO 这种提法,也就是 variable object,是在 ES5 之前的版本才有的,如下图这段描述,就是我从 ECMA-262, 3rd edition 的 PDF 文档中截得,这个版本发布于 1999 年的 12 月份:

image.png

而在 ES5 及之后的版本中,相关表述已经改了,用了 VariableEnvironment 来替代 VO 的概念,如下图截取自发布于 2011 年 6 月份的 ECMA-262 5.1 edition :

image.png

VariableEnvironment 绑定着变量、函数声明和函数的参数信息,称为 Environment Record 而不是 AO。

感谢.gif 点赞.png