关于V8延迟解析和闭包实现

4 篇文章 0 订阅
3 篇文章 0 订阅

V8执行JavaScript代码,需要经过编译和执行两个阶段,其中编译过程是指V8将JavaScript代码转换为字节码或者二进制代码的阶段,而执行阶段则是指解释执行字节码,或者CPU执行二进制机器代码的阶段,总的流程可以参考下图

img

在编译 JavaScript 代码的过程中,V8并不会一次性将所有的JavaScript解析为中间代码,这主要是基于以下两点

  • 首先,如果一次解析和编译所有的JavaScript代码,过多的代码会增加编译时间,这会严重影响到首次执行 JavaScript 代码的速度,让用户感觉到卡顿,因为有时候一个页面的JavaScript代码都有10m,如果将所有代码一次性解析编译完成,那么会大大增加用户的等待时间
  • 其次,解析完成的字节码和编译之后的机器代码都会存放到内存中, 如果一次性解析和编译所有JavaScript代码,那么这些中间代码和机器代码将会一直占用内存,特别是在手机普及的年代,内存是非常宝贵的资源

基于以上的原因,所有主流的 JavaScript 虚拟机都实现了惰性解析,所谓惰性解析是指解析器在解析的过程中,如果遇到函数声明,那么会跳过函数内部的代码,并不为其生成 AST和字节码,而仅仅生成顶层代码的AST和字节码

惰性解析的过程

关于惰性解析,我们可以结合下面这几个例子来分析下

function foo(a,b) {
    var d = 100
    var f = 10
    return d + f + a + b;
}
var a = 1
var c = 4
foo(1, 5)

当把这段代码交给V8处理的时候,V8会从上到下解析这段代码,在解析过程中首先会遇到foo函数,由于这只是一个函数声明语句,V8在这个阶段只需要将该函数转换为函数对象,如下图所示

img

注意这里只是将该函数声明转换为函数对象,但是并没有解析和编译函数内部的代码,所以也不会为foo函数的内部代码生成抽象语法树

然后继续往下解析,由于后续的代码都是顶层代码,所以V8会为它们生成抽象语法树,最终生成的结果如下

img

代码解析完成之后,V8便会按照顺序自上而下执行代码,首先会执行 a=1c=4 这两个赋值表达式(变量声明语句在生成ast阶段时创建作用域时执行,这就是所谓的变量提升),接下来执行foo函数的调用,过程是从foo函数对象中取出函数代码,然后和编译顶层代码一样,v8会先编译foo函数的代码,编译同样需要先将其编译为抽象语法树和字节码,然后在解释执行

好了,上面就是惰性解析的一个大致过程,看上去是不是很简单,不过在V8实现惰性解析的过程中,需要支持JavaScript种的闭包特性,这会使V8的解析过程变得异常复杂

为什么闭包会让 V8 解析代码的过程变得复杂呢?要解答这个问题,我们先来拆解闭包的特性,然后再来分析为什么闭包影响到了 V8 的解析流程。

拆解闭包———JavaScript的三个特性

JavaScript 中的闭包有三个基础特性。

第一,JavaScript 语言允许在函数内部定义新的函数,代码如下所示:

function foo() {
    function inner() {
    }
    inner()
}

这和其他的流行语言有点差异,在其他的大部分语言中,函数只能声明在顶层代码中,而 JavaScript 中之所以可以在函数中声明另外一个函数,主要是因为 JavaScript 中的函数即对象,你可以在函数中声明一个变量,当然你也可以在函数中声明一个函数。

第二,可以在内部函数中访问父函数中定义的变量,代码如下所示:

var d = 20
//inner函数的父函数,词法作用域
function foo() {
    var d = 55
    //foo的内部函数
    function inner() {
      return d+2
    }
    inner()
}

由于可以在函数中定义新的函数,所以很自然的,内部的函数可以使用外部函数中定义的变量,注意上面代码中的 inner 函数和 foo 函数,inner 是在foo函数内部定义的,我们就称inner函数是foo函数的子函数,foo函数是inner函数的父函数,这里的父子关系是针对词法作用域而言的,因为词法作用域在函数声明时就决定了,比如 inner 函数是在 foo 函数内部声明的,所以inner函数可以访问foo函数内部的变量,比如inner就可以访问foo函数中的变量d

但是如果在foo函数内部,也定义一个变量d。那么当inner函数访问该变量的时候,到底是访问哪个变量呢

每个函数都有自己的词法作用域,该函数中定义的变量都存在于该作用域中,然后V8会将这些作用域按照此法的位置,也就是代码的位置关系,将这些作用域穿成一个链,这就是词法作用域链,查找变量的时候会沿着词法作用域链的路径来查找

所以inner函数在自己的作用域中没有找到变量d,就接着在foo函数的作用域中查找。再找不到才会查找顶层作用域中的变量,所以inner函数中使用的变量d就是foo函数中的变量d

第三,因为函数是一等公民,所以函数可以作为返回值,我们可以看下面这段代码

function foo() {
    return function inner(a, b) {
        const c = a + b 
        return c
    }
}
const f = foo()

观察上面这段代码,我们将inner函数作为了foo函数的返回值,也就是说,当调用foo函数时,最终会返回inner函数给调用者,比如上面我们将inner函数返回给了全局变量f,接下来就可以在外部像调用inner函数一样调用f了

以上就是JavaScript闭包相关的三个重要特性

  • 可以JavaScript函数内部定义新的函数
  • 内部函数中访问父函数中定义的变量
  • 因为JavaScript中的函数是一等公民,所以函数可以作为另一个函数的返回值

这也是 JavaScript 过于灵活的一个原因,比如在 C/C++ 中,你就不可以在一个函数中定义另外一个函数,所以也就没了内部函数访问外部函数中变量的问题了。

闭包给惰性解析带来的问题

好了,了解了JavaScript的这三个特性之后,下面我们就来使用三个特性组装一段经典代码

function foo() {
    var d = 20
    return function inner(a, b) {
        const c = a + b + d
        return c
    }
}
const f = foo()

观察上面这段代码,我们在foo函数中定义了inner函数,并返回了inner函数,同时在inner函数中访问了foo函数中的变量d

我们可以分析下面这段代码的执行过程

  • 当调用foo函数时,foo函数会将它的内部函数inner返回给全局变量f
  • 然后foo函数执行结束,执行上下文被V8销毁
  • 虽然foo函数的执行上下文被销毁了,但是依然存活的inner函数引用了foo函数作用域中的变量d

我们可以分析上面这段代码的执行过程

  • 当调用 foo 函数时,foo 函数会将它的内部函数 inner 返回给全局变量 f
  • 然后 foo 函数执行结束,执行上下文被 V8 销毁;
  • 虽然 foo 函数的执行上下文被销毁了,但是依然存活的 inner 函数引用了 foo 函数作用域中的变量 d。

按照通常的做法,d 已经被 V8 销毁了,但是由于存活的函数inner依然引用了foo函数中的变量d,这样机会带来两个问题

  • foo 执行结束时,变量 d 该不该被销毁?如果不应该被销毁,那么应该采用什么策略?
  • 如果采用了惰性解析,那么当执行到 foo 函数时,V8 只会解析 foo 函数,并不会解析内部的 inner 函数,那么这时候 V8 就不知道 inner 函数中是否引用了 foo 函数的变量 d

这么讲可能有点抽象,下面我们就来看一下上面这段代码的执行流程,我们上节分析过了,JavaScript 是一门基于堆和栈的语言,当执行 foo 函数的时候,堆栈的变化如下图所示:

img

从上图可以看出来,在执行全局代码时,V8 会将全局执行上下文压入到调用栈中,然后进入执行 foo 函数的调用过程,这时候 V8 会为 foo 函数创建执行上下文,执行上下文中包括了变量 d,然后将 foo 函数的执行上下文压入栈中,foo 函数执行结束之后,foo 函数执行上下文从栈中弹出,这时候 foo 执行上下文中的变量 d 也随之被销毁。但是这时候,由于 inner 函数被保存到全局变量中了,所以 inner 函数依然存在,最关键的地方在于 inner 函数使用了 foo 函数中的变量 d,按照正常执行流程,变量 d 在 foo 函数执行结束之后就被销毁了。

但是这时候,由于 inner 函数被保存到全局变量中了,所以 inner 函数依然存在,最关键的地方在于 inner 函数使用了 foo 函数中的变量 d,按照正常执行流程,变量 d 在 foo 函数执行结束之后就被销毁了。

所以正常的处理方式应该是 foo 函数的执行上下文虽然被销毁了,但是 inner 函数引用的 foo 函数中的变量却不能被销毁,那么 V8 就需要为这种情况做特殊处理,需要保证即便 foo 函数执行结束,但是 foo 函数中的 d 变量依然保持在内存中,不能随着 foo 函数的执行上下文被销毁掉。

那么怎么处理呢?

在执行foo函数的阶段,虽然采取了惰性解析,不会解析和执行foo函数中的inner函数,但是V8还是需要判断inner函数是否引用了foo函数中的变量,负责处理这个任务的模块叫做预解析器

预解析器如何解决闭包所带来的问题

V8 引入预解析器,比如当解析顶层代码的时候,遇到了一个函数,那么预解析器并不会直接跳过该函数,而是对该函数做一次快速的预解析,其主要目的有两个。

第一,是判断当前函数是不是存在一些语法上的错误,如下面这段代码:

function foo(a, b) {
  {/} //语法错误
}
var a = 1
var c = 4
foo(1, 5)

在预解析过程中,预解析器发现了语法错误,那么就会向 V8 抛出语法错误,比如上面这段代码的语法错误是这样的:

Uncaught SyntaxError: Invalid regular expression: missing /

第二,除了检查语法错误之外,预解析器另外的一个重要的功能就是检查函数内部是否引用了外部变量,如果引用了外部的变量,预解析器会将栈中的变量复制到堆中,在下次执行到该函数的时候,直接使用堆中的引用,这样就解决了闭包所带来的问题。

总结

今天我们主要介绍了 V8 的惰性解析,所谓惰性解析是指解析器在解析的过程中,如果遇到函数声明,那么会跳过函数内部的代码,并不会为其生成 AST 和字节码,而仅仅生成顶层代码的 AST 和字节码。

利用惰性解析可以加速 JavaScript 代码的启动速度,如果要将所有的代码一次性解析编译完成,那么会大大增加用户的等待时间。

由于 JavaScript 是一门天生支持闭包的语言,由于闭包会引用当前函数作用域之外的变量,所以当 V8 解析一个函数的时候,还需要判断该函数的内部函数是否引用了当前函数内部声明的变量,如果引用了,那么需要将该变量存放到堆中,即便当前函数执行结束之后,也不会释放该变量

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值