在文章的最开始,请先思考哪种方式执行js的速度更快,是内联脚本还是外联脚本?是将js合并成一个文件,还是拆成多个文件?解答这两个问题,需要先了解js的执行机制。
JIT即时编译
JIT(just in time) Compilation 即时编译包含两种执行模式:解释执行和编译优化执行。
javascript是一种动态类型语言,在定义变量时不需要声明类型,在运行时解释器一边根据赋值推导出类型并编译成字节码,一边把字节码编译成机器码并执行,这就是解释执行。
在编译成字节码的过程中,解释器监视代码的执行次数,调用编译器将执行次数较多的热点代码编译成机器码,后续执行热点代码时可以跳过编译直接执行,提升代码的执行速度,这就是编译优化执行。下面是chrome v8引擎的JIT编译流程:
解析器 Parser将js代码转成ast解释器 Ignition将ast一边编译成字节码,一边编译成机器码并执行解释器 Ignition监视执行情况,调用编译器 TurboFan将热点字节码编译成机器码
编译优化执行与字节码缓存无关,文章不展开介绍。
javascript是一种跨平台语言,需要根据操作系统编译成中间代码字节码,并且字节码的设计会根据CPU的计算模型,所以字节码编译成机器码效率会更高。
为了减少js编译成字节码的耗时,提高js的执行效率,v8引入了字节码缓存.
字节码缓存
chrome有两级缓存,isolate(memory) cache和disk(共享) cache,isolate cache只作用于同一个tab,而disk cache可以在不同tab中共享,它们都可用于缓存字节码,流程如下:
code run
首次执行js,chrome从网络上下载代码提供给v8编译,并将代码缓存起来。
上图是使用performance工具记录code run的行为,编译缓存状态是【脚本不符合条件】。
warm run
第二次执行js,chrome从缓存中获取代码再次提供给v8编译,并将编译结果由v8序列化后,作为元数据附加到该代码的缓存。
warm run阶段,编译缓存状态还是【脚本不符合条件】,编译脚本多了两个步骤【编译代码】和【缓存脚本代码】,【编译代码】是指从缓存中加载脚本代码编译成字节码,【缓存脚本代码】是指将字节码序列化成元数据并附加到代码缓存中。
hot run
第三次执行js,chrome从缓存中获取代码和元数据,并把两者交给v8。v8反序列化元数据后,可以跳过编译直接执行。
hot run阶段,跳过编译,直接执行字节码,编译缓存状态是【已从缓存加载脚本】。
综上,字节码缓存至少需要在第三次执行时才可能生效。
官方文档提到,在同一tab中,第一次执行js会将字节码缓存放在key为代码的hashtable中,第二次执行js如果在hashtable中找到编译好的字节码,就可以直接跳过编译,也就是在同一个tab第二次进入相同页面,就可以利用字节码缓存。但是在验证时,也至少要三次才能利用字节码缓存。并且官方文档用tracing工具跟踪缓存,也没有展示在isolate cache的表现。如果有理解不正确的地方请帮忙指出。
缓存策略
字节码缓存是浏览器的默认行为,开发者需要怎么组织代码,才能充分利用呢?可以参考以下策略:
- 请求地址与请求内容不变
- js文件必须大于
1Kb - 不能是
内联脚本 - 只有相同文件的代码才会缓存
- 执行过的函数才可能被缓存
- 异步调用的函数无法缓存
第一点和网络缓存策略基本一致,第二、第三点也比较好理解,重点解释一下最后三点。
只有相同文件的代码才会缓存
可以理解为,函数的定义与调用如果在不同的js文件,调用部分无法缓存字节码
// 假设页面中先后同步加载1.js和2.js
// 1.js
function Module() {
// 一些耗时操作
}
// 2.js
// 调用1.js文件中的function,无法利用字节码缓存,每次执行都需要重新编译
Module()
上图中,在2.js中调用1.js定义的Module函数,无法缓存字节码,需要重新编译。
首次执行的代码才被缓存
由于函数只在运行期间编译,当代码中存在一些逻辑分支时,只缓存首次执行的那一个分支,另一个分支在后续执行时需要重新编译。
function ModuleA(){
// 一些耗时操作
}
function ModuleB(){
// 一些耗时操作
}
const enable = location.search.includes('enable=1')
if(enable) {
// 假如首次执行ModuleA,后续执行该分支都可以利用字节码缓存
ModuleA()
} else {
// 假如首次不执行ModuleB,后续执行该分支需要重新编译字节码
ModuleB()
}
上图是ModuleA()已经缓存字节码,调用ModuleB()的表现。首次执行url带上enable=1参数,命中条件执行ModuleA()并缓存了字节码,后续执行ModuleB()时没有字节码缓存,需要重新编译。
无法缓存宏任务
// 1.js
function Module() {
// 一些耗时操作
}
// 宏任务:定时器调用Module
setTimeout(Module)
在定时器宏任务中调用Module()无法缓存字节码,需要重新编译。在实际验证中,微任务是可以缓存字节码的,这点与官方文档描述不一致,推测是文档编写的时间较早,后续Chrome已经做了支持。
最佳实践
只有相同文件的代码才会缓存,可能与业界比较主流的做法相违背,包括webpack4的splitChunksPlugin,默认会把node_modules打成一个vendor chunk文件,剩下的业务modules打成一个文件。因为node_modules的改动频率较小,相对业务modules更加稳定,当项目发版时,如果node_modules没有修改,构建后vendor chunk的hash值与上一个版本保持一致,提高了缓存命中率,从而提升网络加载速度。但在实践过程中,由于node_modules和业务modules封装在不同的js文件,业务modules调用node_modules的方法,无法利用字节码缓存,从而影响了js的执行速度,两者合并后整体的性能数据有所下降。
比较推荐的做法,是把首屏初始化流程,这种高优先级的代码合并成一个首屏js,一些优先级较低的逻辑,比如非首屏渲染交互、数据上报的代码可以做成动态import,这样首屏js既可以控制包体积,又可以优先加载执行,让页面尽快地响应交互,提升用户体验。
不过浏览器对每个js的缓存体积是有限制的,通过本机测试,memory cache大概是377M,disk cache大概是25M,这两个数据并不精确,仅供参考。
最后
回答最开始的问题,由于内联脚本、拆分成多个js,这两种方式都无法利用字节码缓存,相反外联脚本、合并js执行速度更快。