Skip to content

V8是如何查找变量的

JavaScript 的继承是基于原型链的,原型链将一个个原型对象串起来,从而实现对象属性的查找,作用域链和原型链类似

什么是作用域链

  • 作用域链就是将一个个作用域串起来,实现变量查找的路径。讨论作用域链,实际就是在讨论按照什么路径查找变量的问题。
  • 我们知道,作用域就是存放变量和函数的地方,全局环境有全局作用域,全局作用域中存放了全局变量和全局函数。每个函数也有自己的作用域,函数作用域中存放了函数中定义的变量。
  • 当在函数内部使用一个变量的时候,V8 便会去作用域中去查找。

根据代码理解作用域链

js
var name = 'xxxx'
var type = 'global'


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


function bar(){
    var name = 'bar'
    var type = 'function'
    foo()
}
bar()
var name = 'xxxx'
var type = 'global'


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


function bar(){
    var name = 'bar'
    var type = 'function'
    foo()
}
bar()
  • 在这段代码中,我们在全局环境中声明了变量 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,而是在全局环境中和调用 foo 函数的 bar 函数中分别定义了变量 type,那么这时候的问题来了,你觉得 foo 函数中打印出来的变量 type 是 bar 函数中的,还是全局环境中的呢?
  • 要解释清楚这个问题,我们需要从作用域的工作原理讲起。
  • 每个函数在执行时都需要查找自己的作用域,我们称为函数作用域,在执行阶段,在执行一个函数时,当该函数需要使用某个变量或者调用了某个函数时,便会优先在该函数作用域中查找相关内容。
  • 我们再来看一段代码:
js
var x = 4
var test
function test_scope() {
    var name = 'foo'
    console.log(name)
    console.log(type)
    console.log(test)
    var type = 'function'
    test = 1
    console.log(x)
}
test_scope()
var x = 4
var test
function test_scope() {
    var name = 'foo'
    console.log(name)
    console.log(type)
    console.log(test)
    var type = 'function'
    test = 1
    console.log(x)
}
test_scope()
  • 在上面的代码中,我们定义了一个 test_scope 函数,那么在 V8 执行 test_scope 函数的时候,在编译阶段会为 test_scope 函数创建一个作用域,在 test_scope 函数中定义的变量和声明的函数都会丢到该作用域中,因为我们在 test_scope 函数中定了三个变量,那么常见的作用域就包含有这三个变量。
  • 你可以通过 Chrome 的控制台来直观感受下 test_scope 函数的作用域,先打开包含这段代码的页面,然后打开开发者工具,接着在 test_scope 函数中的第二段代码加上断点,然后刷新该页面。当执行到该断点时,V8 会暂停整个执行流程,这时候我们就可以通过右边的区域面板来查看当前函数的执行状态。 作用域链
  • 可以参考图中右侧的 Scope 项,然后点击展开该项,这个 Local 就是当前函数 test_scope 的作用域。在 test_scope 函数中定义的变量都包含到了 Local 中,如变量 name、type,另外系统还为我们添加了另外一个隐藏变量 this,V8 还会默认将隐藏变量 this 存放到作用域中。
  • 另外还需要注意下,第一个 test1,并没有采用 var 等关键字来声明,所以 test1 并不会出现在 test_scope 函数的作用域中,而是属于 this 所指向的对象
  • 那么另一个问题来了,我在 test_scope 函数使用了变量 x,但是在 test_scope 函数的作用域中,并没有定义变量 x,那么 V8 应该如何获取变量 x?
  • 如果在当前函数作用域中没有查找到变量,那么 V8 会去全局作用域中去查找,这个查找的线路就称为作用域链。
  • 全局作用域和函数作用域类似,也是存放变量和函数的地方,但是它们还是有点不一样: 全局作用域是在 V8 启动过程中就创建了,且一直保存在内存中不会被销毁的,直至 V8 退出。 而函数作用域是在执行该函数时创建的,当函数执行结束之后,函数作用域就随之被销毁掉了。
  • 全局作用域中包含了很多全局变量,比如全局的 this 值,如果是浏览器,全局作用域中还有 window、document、opener 等非常多的方法和对象,如果是 node 环境,那么会有 Global、File 等内容。
  • V8 启动之后就进入正常的消息循环状态,这时候就可以执行代码了,比如执行到上面那段脚本时,V8 会先解析顶层 (Top Level) 代码,我们可以看到,在顶层代码中定义了变量 x,这时候 V8 就会将变量 x 添加到全局作用域中。

作用域链是怎么工作的

  • 理解了作用域和作用域链,我们再回过头来看文章开头的那道思考题: “foo 函数中打印出来的变量 type 是 bar 函数中的呢,还是全局环境中的呢?”把这段代码复制到下面:
js
var name = 'xxxx'
var type = 'global'


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


function bar(){
    var name = 'bar'
    var type = 'function'
    foo()
}
bar()
var name = 'xxxx'
var type = 'global'


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


function bar(){
    var name = 'bar'
    var type = 'function'
    foo()
}
bar()
  • 现在,我们结合 V8 执行这段代码的流程来具体分析下。首先当 V8 启动时,会创建全局作用域,全局作用域中包括了 this、window 等变量,还有一些全局的 Web API 接口,创建的作用域如下图所示: 全局作用域
  • V8 启动之后,消息循环系统便开始工作了,这时候,我输入了这段代码,让其执行。
  • V8 会先编译顶层代码,在编译过程中会将顶层定义的变量和声明的函数都添加到全局作用域中,最终的全局作用域如下图所示: 作用域
  • 全局作用域创建完成之后,V8 便进入了执行状态。前面我们介绍了变量提升,因为变量提升的原因,可以把上面这段代码分解为如下两个部分:
js
//======解析阶段--实现变量提升=======
var name = undefined
var type = undefined
function foo(){
    var name = 'foo'
    console.log(name)
    console.log(type)
}
function bar(){
    var name = 'bar'
    var type = 'function'
    foo()
}

//====执行阶段========
name = 'xxxx'
type = 'global'
bar()
//======解析阶段--实现变量提升=======
var name = undefined
var type = undefined
function foo(){
    var name = 'foo'
    console.log(name)
    console.log(type)
}
function bar(){
    var name = 'bar'
    var type = 'function'
    foo()
}

//====执行阶段========
name = 'xxxx'
type = 'global'
bar()
  • 第一部分是在编译过程中完成的,此时全局作用中两个变量的值依然是 undefined,然后进入执行阶段;第二部代码就是执行时的顺序,首先全局作用域中的两个变量赋值“极客时间”和“global”,然后就开始执行函数 bar 的调用了。
  • 当 V8 执行 bar 函数的时候,同样需要经历两个阶段:编译和执行。在编译阶段,V8 会为 bar 函数创建函数作用域,最终效果如下所示: bar函数作用域
  • 然后进入了 bar 函数的执行阶段。在 bar 函数中,只是简单地调用 foo 函数,因此 V8 又开始执行 foo 函数了。
  • 同样,在编译 foo 函数的过程中,会创建 foo 函数的作用域,最终创建效果如下图所示: foo函数作用域
  • 好了,这时候我们就有了三个作用域了,分别是全局作用域、bar 的函数作用域、foo 的函数作用域。
  • 现在我们就可以将刚才提到的问题转换为作用域链的问题了:foo 函数查找变量的路径到底是什么?
    • 沿着 foo 函数作用域–>bar 函数作用域–> 全局作用域 ;
    • 还是,沿着 foo 函数作用域—> 全局作用域?
  • 因为 JavaScript 是基于词法作用域的,词法作用域就是指,查找作用域的顺序是按照函数定义时的位置来决定的。bar 和 foo 函数的外部代码都是全局代码,所以无论你是在 bar 函数中查找变量,还是在 foo 函数中查找变量,其查找顺序都是按照当前函数作用域–> 全局作用域这个路径来的。
  • 由于我们代码中的 foo 函数和 bar 函数都是在全局下面定义的,所以在 foo 函数中使用了 type,最终打印出来的值就是全局作用域中的 type。
  • 因为词法作用域是根据函数在代码中的位置来确定的,作用域是在声明函数时就确定好的了,所以我们也将词法作用域称为静态作用域。
  • 和静态作用域相对的是动态作用域,动态作用域并不关心函数和作用域是如何声明以及在何处声明的,只关心它们从何处调用。换句话说,作用域链是基于调用栈的,而不是基于函数定义的位置的。

总结

  • 作用域就是用来存放变量和函数的地方,全局作用域中存放了全局环境中声明的变量和函数,函数作用域中存放了函数中声明的变量和函数。当在某个函数中使用某个变量时,V8 就会去这些作用域中查找相关变量。沿着这些作用域查找的路径,我们就称为作用域链。
  • 要了解查找路径,我们需要明白词法作用域,词法作用域是按照代码定义时的位置决定的,而 JavaScript 所采用的作用域机制就是词法作用域,所以作用域链的路径就是按照词法作用域来实现的。

经典作用域链题目

js
var a = [];
for(let i = 0;i<10;i++){
  a[i]=function(){
    console.log(i)
  }
};
a[2]();
var a = [];
for(let i = 0;i<10;i++){
  a[i]=function(){
    console.log(i)
  }
};
a[2]();
  • let定义的i会运行for的块级作用域中,每次执行一次循环,都会创建一个块级作用域。
  • 在这个块级作用域中,又定义了一个函数,而这个函数又引用了函数外部的i变量,那么这就产生了闭包,也就是说,所有块级作用域中的i都不会被销毁,你在这里执行了10次循环,那么也就创建了10个块级作用域,这十个块级作用域中的变量i都会被保存在内存中。
  • 那么当再次调用该a[n]()时,v8就会拿出闭包中的变量i,并将其打印出来,因为每个闭包中的i值都不同,所以a[n]()时,打印出来的值就是n,这个就非常符合直觉了。
  • 但是如果将for循环中的i变量声明改成var,那么并不会产生块级作用域,那么函数引用的i就是全局作用域中的了,由于全局作用域中只有一个,那么在执行for循环的时候,i的值会一直被改变,最后是10,所以最终你执行a[n]()时,无论n是多少,打印出来的都是10. 那么这就是bug之源了