前瞻
在前面章节介绍了 JavaScript 的继承是基于原型链的,原型链将一个个对象串起来,从而实现对象属性的查找,这篇将会讲述 V8 是如何通过作用域链来查找变量的。
V8 如何通过作用域链来查找变量?
原型链是将一个个对象串起来,同样的,作用域链就是将一个个作用域串起来,实现变量查找的路径。
需要知道的是作用域就是存放变量和函数的地方,全局下有全局作用域,全局作用域中存放全局变量和全局函数。每个函数也有自己的作用域,函数作用域中存放了函数中定义的变量。当在函数内部使用一个变量时,V8 便会去作用域中查找,通过代码来看一下:
在上面代码中,我们在全局环境中声明了变量 name 和 type,同时还定义了 bar 函数和 foo 函数,在 bar 函数中又再次定义了变量 name 和 type,在 foo 函数中再次定义了变量 name。函数的调用关系是:在全局环境中调用 bar 函数,在 bar 函数中调用 foo 函数,在 foo 函数中打印出变量 name 和 type 的值。
当执行到 foo 函数时,首先需要打印出变量 name 的值,但我们在三个地方都定义了变量 name,那么究竟应该使用哪个变量呢?
在 foo 函数中使用了变量 name,那么 V8 就应该先使用 foo 函数内部定义的变量 name,这符合正常的直觉,最终的结果也确实如此。
随后,foo 函数继续打印变量 type,但是在 foo 函数内部并没有定义变量 type,而是在全局环境中和 bar 函数中分别定义了变量 type,那么这时 foo 函数打印出的变量 type 是 bar 函数中的,还是全局环境中的呢?
先卖个关子,我们先来了解下什么是函数作用域和全局作用域?
作用域的工作原理:
每个函数在执行时都需要查找自己的作用域,称之为函数作用域,在执行阶段,执行一个函数时,当该函数需要使用某个变量或者调用了某个函数时,便会优先在该函数作用域内查找相关内容。
在上方代码中,我们定义了一个 test_scope 函数,那么在 V8 执行 test_scope 函数时,在编译阶段会为 test_scope 函数创建一个作用域,在 test_scope 函数中定义的变量和声明的函数都会丢到该作用域中,另外,V8 还会默认将隐藏变量 this 存放到作用域中。
在 test_scope 函数中使用了变量 x,但是在 test_scope 函数的作用域中,并没有定义变量 x,那么 V8 应该如何去获取变量 x 呢?显然的是,如果在当前函数作用域中没有查找到变量,那么 V8 会去全局作用域中查找,这个查找的路径就称为作用域链。
全局作用域和函数作用域类似,也是存放变量和函数的地方,但是对于 V8 而言,它们还是有一点不一样:
- 全局作用域是在 V8 启动过程中就创建了,且一直保存在内存中不会被销毁,直至 V8 退出。
- 函数作用域是在执行该函数时创建的,当函数执行结束之后,函数作用域就随之销毁掉了。
不仅仅是销毁时机不同,全局作用域中包含了很多全局变量,如果是浏览器,全局作用域中还有 window、document 等非常多的对象和方法,如果是 node 环境,那么会有 Global、File 等对象,当然,无论什么环境下,都会有全局的 this 值。
V8 启动后就会进入正常的消息循环状态,这时就可以执行代码了,如上方代码,V8 就会先解析顶层(Top Level)代码,显而易见的是,在顶层代码中定义了变量 x,这时 V8 就会将变量 x 添加到全局作用域中了。
作用域链是如何工作的?
回到开头的问题,来看看作用域链究竟是如何运作的。
首先,当 V8 启动时,会进入正常的消息循环状态,执行相应的代码,并且会创建全局作用域,全局作用域中包括了 this、window、document 等变量。V8 会先编译顶层代码,在编译过程中会将顶层定义的变量和声明的函数都添加到全局作用域中,如下图:
(注意图中并不是一一对应的关系,只是为了好看排整齐而已…)
全局作用域创建完成后,V8 便进入了执行状态,在前面章节说过变量提升,(当然这里提一嘴:var 定义的变量才会有变量提升,而 let 和 const 定义的变量不会有),因为变量提升的原因,可以将之前的代码分解为以下部分:
第一部分是在编译阶段完成的,此时全局作用域中两个变量的值依然是 undefined,然后进入执行阶段;第二部分就是执行时的顺序,首先全局作用域中的两个变量分别赋值”licodeao”和”global”,然后就开始执行函数 bar 的调用了。
当V8 执行函数 bar时,同样需要经历两个阶段:编译和执行。在编译阶段,V8 会为 bar 函数创建函数作用域,如下图:
然后进入了 bar 函数的执行阶段,在 bar 函数中,只是简单地调用了 foo 函数,因此 V8 又开始执行 foo 函数了。
同样地,在编译 foo 函数的过程中,V8 会为 foo 函数创建函数作用域,如下图:
最终,我们得到了三个作用域了,分别是全局作用域,bar 函数的函数作用域以及 foo 函数的函数作用域。
将开头的问题转换为作用域链的问题,foo 函数查找变量的路径到底是怎样的?
- foo 函数作用域 -> bar 函数作用域 -> 全局作用域
- foo 函数作用域 -> 全局作用域
词法作用域是指,查找作用域的顺序是按照函数定义时的位置来决定的。
由于作用域是在声明函数时就已经确定好了,所以也可以将词法作用域称为静态作用域。
和静态作用域相对的是动态作用域,其并不关心函数和作用域是如何声明以及在何处声明的,只关心它们从何处调用。换句话说,在动态作用域中,作用域链是基于调用栈的,而不是基于函数定义的位置的。
因为 JavaScript 是基于词法作用域的,bar 和 foo 函数的外部代码都是全局代码,所以无论是在 bar 函数中查找变量,还是在 foo 函数中查找变量,其查找顺序都是按照当前函数作用域 -> 全局作用域这个路径来的。
至此,我们就能够解决开头的问题了。由于代码中的 foo 函数和 bar 函数都是在全局下面定义的,所以在 foo 函数中使用了 type,最终打印出来的值就是全局作用域中的 type。
总结
- 作用域链就是将一个个作用域串起来,实现变量查找的路径。
- 作用域的工作原理:每个函数在执行时都需要查找自己的作用域,称之为函数作用域,在执行阶段,执行一个函数时,当该函数需要使用某个变量或者调用了某个函数时,便会优先在该函数作用域内查找相关内容。
- 词法作用域是指,查找作用域的顺序是按照函数定义时的位置来决定的。