前端性能优化:字节码缓存

2,259 阅读6分钟

在文章的最开始,请先思考哪种方式执行js的速度更快,是内联脚本还是外联脚本?是将js合并成一个文件,还是拆成多个文件?解答这两个问题,需要先了解js的执行机制。

JIT即时编译

JIT(just in time) Compilation 即时编译包含两种执行模式:解释执行编译优化执行
javascript是一种动态类型语言,在定义变量时不需要声明类型,在运行时解释器一边根据赋值推导出类型并编译成字节码,一边把字节码编译成机器码并执行,这就是解释执行
在编译成字节码的过程中,解释器监视代码的执行次数,调用编译器将执行次数较多的热点代码编译成机器码,后续执行热点代码时可以跳过编译直接执行,提升代码的执行速度,这就是编译优化执行。下面是chrome v8引擎JIT编译流程:

image.png
  1. 解析器 Parserjs代码转成ast
  2. 解释器 Ignitionast一边编译成字节码,一边编译成机器码并执行
  3. 解释器 Ignition监视执行情况,调用编译器 TurboFan将热点字节码编译成机器码

编译优化执行与字节码缓存无关,文章不展开介绍。

javascript是一种跨平台语言,需要根据操作系统编译成中间代码字节码,并且字节码的设计会根据CPU的计算模型,所以字节码编译成机器码效率会更高。

image.png

为了减少js编译成字节码的耗时,提高js的执行效率,v8引入了字节码缓存.

字节码缓存

chrome有两级缓存,isolate(memory) cachedisk(共享) cacheisolate cache只作用于同一个tab,而disk cache可以在不同tab中共享,它们都可用于缓存字节码,流程如下:

code run

首次执行jschrome从网络上下载代码提供给v8编译,并将代码缓存起来。

image.png

上图是使用performance工具记录code run的行为,编译缓存状态是【脚本不符合条件】。

warm run

第二次执行jschrome从缓存中获取代码再次提供给v8编译,并将编译结果由v8序列化后,作为元数据附加到该代码的缓存

image.png

warm run阶段,编译缓存状态还是【脚本不符合条件】,编译脚本多了两个步骤【编译代码】和【缓存脚本代码】,【编译代码】是指从缓存中加载脚本代码编译成字节码,【缓存脚本代码】是指将字节码序列化成元数据并附加到代码缓存中。

hot run

第三次执行jschrome从缓存中获取代码和元数据,并把两者交给v8v8反序列化元数据后,可以跳过编译直接执行

image.png

hot run阶段,跳过编译,直接执行字节码,编译缓存状态是【已从缓存加载脚本】。

image.png

综上,字节码缓存至少需要在第三次执行时才可能生效

官方文档提到,在同一tab中,第一次执行js会将字节码缓存放在key为代码的hashtable中,第二次执行js如果在hashtable中找到编译好的字节码,就可以直接跳过编译,也就是在同一个tab第二次进入相同页面,就可以利用字节码缓存。但是在验证时,也至少要三次才能利用字节码缓存。并且官方文档用tracing工具跟踪缓存,也没有展示在isolate cache的表现。如果有理解不正确的地方请帮忙指出。

缓存策略

字节码缓存是浏览器的默认行为,开发者需要怎么组织代码,才能充分利用呢?可以参考以下策略:

  1. 请求地址与请求内容不变
  2. js文件必须大于1Kb
  3. 不能是内联脚本
  4. 只有相同文件的代码才会缓存
  5. 执行过的函数才可能被缓存
  6. 异步调用的函数无法缓存

第一点和网络缓存策略基本一致,第二、第三点也比较好理解,重点解释一下最后三点。

只有相同文件的代码才会缓存

可以理解为,函数的定义与调用如果在不同的js文件,调用部分无法缓存字节码

// 假设页面中先后同步加载1.js和2.js
// 1.js
function Module() {
    // 一些耗时操作
}

// 2.js
// 调用1.js文件中的function,无法利用字节码缓存,每次执行都需要重新编译
Module()

image.png

上图中,在2.js中调用1.js定义的Module函数,无法缓存字节码,需要重新编译。

首次执行的代码才被缓存

由于函数只在运行期间编译,当代码中存在一些逻辑分支时,只缓存首次执行的那一个分支,另一个分支在后续执行时需要重新编译。

function ModuleA(){
// 一些耗时操作
}
function ModuleB(){
// 一些耗时操作
}
const enable = location.search.includes('enable=1')
if(enable) {
    // 假如首次执行ModuleA,后续执行该分支都可以利用字节码缓存
    ModuleA()
} else {
    // 假如首次不执行ModuleB,后续执行该分支需要重新编译字节码
    ModuleB()
}

image.png

上图是ModuleA()已经缓存字节码,调用ModuleB()的表现。首次执行url带上enable=1参数,命中条件执行ModuleA()并缓存了字节码,后续执行ModuleB()时没有字节码缓存,需要重新编译。

无法缓存宏任务

// 1.js
function Module() {
    // 一些耗时操作
}

// 宏任务:定时器调用Module
setTimeout(Module)
image.png

在定时器宏任务中调用Module()无法缓存字节码,需要重新编译。在实际验证中,微任务是可以缓存字节码的,这点与官方文档描述不一致,推测是文档编写的时间较早,后续Chrome已经做了支持。

最佳实践

只有相同文件的代码才会缓存,可能与业界比较主流的做法相违背,包括webpack4splitChunksPlugin,默认会把node_modules打成一个vendor chunk文件,剩下的业务modules打成一个文件。因为node_modules的改动频率较小,相对业务modules更加稳定,当项目发版时,如果node_modules没有修改,构建后vendor chunkhash值与上一个版本保持一致,提高了缓存命中率,从而提升网络加载速度。但在实践过程中,由于node_modules业务modules封装在不同的js文件,业务modules调用node_modules的方法,无法利用字节码缓存,从而影响了js的执行速度,两者合并后整体的性能数据有所下降。
比较推荐的做法,是把首屏初始化流程,这种高优先级的代码合并成一个首屏js,一些优先级较低的逻辑,比如非首屏渲染交互、数据上报的代码可以做成动态import,这样首屏js既可以控制包体积,又可以优先加载执行,让页面尽快地响应交互,提升用户体验。
不过浏览器对每个js的缓存体积是有限制的,通过本机测试,memory cache大概是377Mdisk cache大概是25M,这两个数据并不精确,仅供参考。

image.png image.png

最后

回答最开始的问题,由于内联脚本、拆分成多个js,这两种方式都无法利用字节码缓存,相反外联脚本、合并js执行速度更快。

参考文档:
v8.js.cn/blog/code-c…
medium.com/dailyjs/und…