持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第4天,点击查看活动详情
变量与函数声明提升
我们知道,我们可以在 var
定义一个变量前打印该变量而不会报错,或是在通过 function
定义函数前调用该函数执行,因为 js 中存在着变量提升 (Hoisting) 和函数声明提升,那么其背后的原理,到底是什么呢?
var 声明的变量
先研究为什么 var
声明的变量会存在变量提升。
console.log(singer) // undefined
var singer = 'Jay'
- 编译阶段
代码在从 js 源码到解析成 AST 的过程中(也就是编译阶段),V8 引擎 内部会创建一个全局的对象 GlobalObject(简称 GO),里面放的都是一些全局对象,比如String
、Date
、Number
、setTimeout
和console
等。在浏览器环境下,还有个window
属性,这个window
指向的就是 GlobalObject 本身,所以我们可以console.log(window.window)
得到就是全局对象。除了这些,我们写在全局作用域里的变量singer
,也会被放入 GlobalObject 中,但此时代码还没执行,变量singer
还没被赋值,所以此时它的值是undefined
。
- 执行阶段
V8 为了执行代码,V8 引擎内部会创建一个函数调用栈 —— 执行上下文栈(Execution Context Stack,ECStack)。当某段代码真正执行的时候,需要放入 ECStack 中,比如现在要执行全局的代码,那么就需要把全局代码放入 ECStack 中。为了全局代码能正常的执行,V8 会在全局代码需要执行时创建全局执行上下文(Global Execution Context,GEC)。而根据 ECMA 规范(es5 之前)每一个执行上下文,都会关联个东西叫做 VO(variable object 变量对象),对于全局执行上下文而言,VO 实际上指向的就是 GO(GlobalObject):
当执行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。
- 执行阶段
代码执行到第 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 创建完成开始执行函数体。
当函数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
;添加属性fn1
和fn2
,值为它们各自的函数对象的内存地址。函数对象内保存着函数体和父级作用域等信息,所以说父级作用域在函数定义时就确定了,此例中fn1
和fn2
的父级作用域都是全局作用域(GO)。至于在函数fn2
内部定义的fn3
,则不会在此时编译(这里只会做预编译),而是等到fn2
执行时编译。 - 执行阶段
执行完第 1 行,将 GO 中
a
赋值为1
; 接着执行第 13 行调用fn2()
,将fn2
函数上下文压入执行上下文栈 ECStack,在执行前生成 AO(如下图):
然后开始执行fn2
。执行到第 10 行发现要调用fn3
,则生成fn3
的 FEC 并入栈。在执行fn3
前生成对应的 AO,里面也有个属性a
,值为3
。fn3
的 FEC 的父级作用域为fn2
的作用域,也就是fn2
对应的 VO。
执行完fn3
,则fn3
的 FEC 出栈。继续执行调用fn1
,同样创建fn1
的 FEC 入栈并在执行前创建fn1
的 AO,执行fn1
需要打印a
属性,就会通过作用域寻找a
,首先在 fn1 本身的作用域里寻找,也就是 fn1 对应的那个 VO 里找,发现没有a
。于是就往其父作用域中寻找,此处则是指全局作用域(GO)里找,发现a
为1
,故而最终执行打印结果为 1。
One More Thing
事实上,VO 这种提法,也就是 variable object,是在 ES5 之前的版本才有的,如下图这段描述,就是我从 ECMA-262, 3rd edition 的 PDF 文档中截得,这个版本发布于 1999 年的 12 月份:
而在 ES5 及之后的版本中,相关表述已经改了,用了 VariableEnvironment 来替代 VO 的概念,如下图截取自发布于 2011 年 6 月份的 ECMA-262 5.1 edition :
VariableEnvironment 绑定着变量、函数声明和函数的参数信息,称为 Environment Record 而不是 AO。