在日前的 PerfMatters 2019 大会上,Addy Osmani 发表了《JavaScript 性能开销》的演讲,本文整理内容如下。
原演讲视频连接:https://youtu.be/X9eRLElSW1c
过去几年来,浏览器解析和编译脚本的速度已经有了显著提升,这也改变了 JavaScript 的性能开销结构。到了 2019 年,处理脚本的主要性能开销体现在了脚本下载和 CPU 执行时间上。
当浏览器的主线程忙于执行 JavaScript 脚本时可能会拖累用户交互操作,因此加快脚本执行速度并消除网络瓶颈能明显改善用户体验。
实用的高层级指南
对 Web 开发者来说上述事实意味着什么?首先,解析和编译工作不像以前那么慢了。现在开发者做优化时,针对 JavaScript 包需要关注三大重点:
减少下载时间
控制 JavaScript 包的大小,面向移动设备时尤其要注意。较小的包可提升下载速度、降低内存使用率并减少 CPU 开销。
不要只做一个大包;如果你的包大小超过 50-100kB,就把它拆分成几个小包。(通过 HTTP/2 多路复用可以同时传输多个请求和响应消息,从而减少额外请求的开销。)
在移动设备上尽量缩减包的大小,这主要是考虑到网络带宽,同时也有助于降低内存使用率。
缩短执行时间
尽量避免持续占用主线程、影响页面响应速度的长任务。现在脚本下载后的执行时间是主要的性能开销之一。
避免使用大型内联脚本
因为它们仍需在主线程上解析和编译。可以参考一条经验法则:如果脚本超过 1kb 就不用内联(这也是因为超过 1kB 时针对外部脚本的代码缓存就会启动了)。
为什么要关注下载和执行时间?
为什么我们应该关注下载和执行时间的优化工作?因为在低端网络中下载时间是影响很大的指标。尽管全球范围 4G(甚至 5G)网络正在普及,但很多人的有效连接类型依旧存在很多起伏;很多时候我们出门在外会感到网速下滑到 3G(甚至更糟)的水平上。
JavaScript 执行时间在低端手机上也有很大的影响。不同手机的 CPU、GPU 和散热限制差异巨大,所以低端和高端手机之间有着显著的性能差距,严重影响 JS 这种 CPU 密集任务的性能表现。
数据显示,在 Chrome 之类的浏览器中加载页面时,JS 的执行时间可以占到加载总耗时的最多 30%。下图是一台高端桌面 PC 从具有典型负载的网站(Reddit.com)中加载页面的性能分析:
在移动端,典型的中端手机(Moto G4)执行 Reddit 的 JS 脚本耗时足足是高端手机(Pixel 3)的 3-4 倍之久,而低端手机(售价低于 100 美元的阿尔卡特 1x)的耗时更是有 6 倍之久:
注意:Reddit 的桌面和移动端版本不一样,所以两个平台的性能表现无法直接比较。
如果你要着手优化 JS 脚本的执行时间,请留意可能长时间独占 UI 线程的长任务。就算页面看起来已经准备就绪了,这些长任务也可能拖累关键任务的执行。你可以把这些长任务拆分开来,并安排好各个小任务的加载优先级,这样就能加快页面响应并降低输入延迟。
V8 引擎的解析/编译改进
相比 Chrome 60 版本,现在 V8 引擎的 JS 解析速度提高了两倍。Chrome 还做了一些优化工作让解析和编译工作并行化,现在这部分性能开销已经不再是影响体验的关键因素了。
V8 将解析和编译任务转到了 worker 线程上,将主线程上的解析和编译工作量平均减少了 40%(Facebook 上为 46%,Pinterest 为 62%),最高达到 81%(YouTube) 。这是在已有的改进工作基础上得到的性能提升数字。
还可以对比不同版本 V8 引擎的性能表现。可以看到 Chrome 61 解析完 Facebook 的 JS 脚本时,Chrome 75 已经解析完 Facebook 和 6 个 Twitter 的 JS 脚本了。
下面来深入了解一下这些优化的细节。简而言之,现在脚本资源可以在 worker 线程上流式解析和编译,这意味着:
V8 可以在不阻塞主线程的情况下解析并编译 JavaScript。
当整个 HTML 解析器遇到
<script>
标记后就开始流式处理。遇到阻塞解析器的脚本时 HTML 解析器暂停,遇到异步脚本时继续。实际使用中,大多数网络条件下 V8 的脚本解析速度都比下载更快,所以脚本下载完毕后几毫秒之内 V8 也完成了解析和编译工作。
具体来说,较老版本的 Chrome 会在脚本下载完毕之后才会开始解析,这种方法很简单,但并没有充分利用 CPU 能力。从 41 到 68 版,Chrome 会在下载开始时立即在单独的线程上解析异步和延迟脚本。
到了 Chrome 71,我们改成了基于任务的设置方案,让调度程序同时解析多个异步/延迟脚本。于是主线程解析时间缩短了约 20%,在真实网站上测得的 TTI/FID 总体上提高了约 2%。
在 Chrome 72 中,我们开始使用流式传输处理主要的解析任务:现在常规的同步脚本(内联脚本除外)也会流式处理。当主线程需要基于任务的解析时,我们也不再取消这些解析操作了,从而减少了不必要的重复劳动。
旧版 Chrome 支持流式解析和编译,其中来自网络的脚本源数据必须在转发到流传输器之前进入 Chrome 的主线程。
结果经常出现的一种情况是,虽然数据已经从网络传输过来了,但是主线程忙于其他任务(如 HTML 解析、布局或 JavaScript 执行等),来不及处理这些数据,所以数据还没有转发到流任务上,流解析器只能干等。
现在我们正尝试在预加载时开始解析,以前主线程反弹会阻碍这种操作。
Leszek Swirski 在 BlinkOn 10 上的演讲介绍了相关细节。
DevTools 中的改进
此外 DevTools 中也存在一个问题,它在呈现整个解析任务时会表明自己正在占用 CPU(完全阻塞),但不管解析器是否需要数据(数据需要通过主线程)都会阻塞。当我们从单个流线程转向多个流传输任务时这个问题变得非常明显。下图是 Chrome 69 中的情况。
DevTools 呈现解析任务时表明自己正在占用 CPU(完全阻塞)
如图,“解析脚本”任务需要 1.08 秒时间。但是解析 JavaScript 其实没那么慢才对!大部分时间都是在干等数据通过主线程而已。
Chrome 76 显示的内容就不一样了:
在 Chrome 76 中,解析工作被分解为多个较小的流任务
一般来说,DevTools 性能窗格非常适合从宏观层面分析你的页面。如果你需要了解更具体的 V8 性能指标(如 JavaScript 解析和编译时间),我们建议使用 Chrome 跟踪和运行时调用统计(RCS,https://v8.dev/docs/rcs)。在 RCS 结果中,Parse-Background 和 Compile-Background 会告诉你在主线程之外解析和编译 JavaScript 所花费的时间,而 Parse 和 Compile 是针对主线程的指标。
这些改进对现实应用有多大影响?
下面来看一些真实网站的示例以及脚本流的效果。
Reddit.com 有几个超过 100kB 的 JS 包,它们包装在外部函数中,为主线程带来了大量懒编译操作。如上图所示,主线程耗时会严重影响交互体验。Reddit 的大部分时间都花在了主线程上,而 worker/后台线程的使用率很低。
想要做优化的话,他们可以将一些大包拆分成一些不用包装的小包(比如每个包 50KB),这样每个包可以分别流解析和编译,并在载入期间减少主线程的解析和编译时间。
然后是Facebook.com。Facebook 使用了 292 个请求,加载了大约 6MB 的压缩 JS 脚本,其中一些是异步的,一些是预加载的,还有一些是低优先级的。他们的许多脚本都不大,粒度也很小,所以能并行流解析和编译,改善 Background/Worker 线程上的整体并行化表现。
但要注意的是,像 Facebook 或 Gmail 这样的老牌应用在桌面端使用这么多脚本还比较合理,但你的网站可能并不是这种情况。不管怎样还是要尽量简化 JS 包,没什么必要的就不要加载了。
虽然大多数 JavaScript 解析和编译工作都可以在后台线程上流式处理,但有些工作还是要跑在主线程上。主线程繁忙时页面就无法响应用户输入了。请密切关注下载和执行代码的操作对用户体验的影响。
注意:目前,并非所有 JavaScript 引擎和浏览器都实现了脚本流这个加载优化方案。但我们仍然相信本文能帮助大家提升整体的应用体验。
解析 JSON 的开销
JSON 语法比 JavaScript 简单很多,所以前者的解析效率也要高得多。基于这一点,web 应用可以提供大型的类似 JSON 的对象字面量(诸如内联 Redux 存储),取代将数据内联为 JS 对象字面量的做法来提升加载速度,如下所示:
……它可以用 JSON 字符串形式表示,然后在运行时进行 JSON 解析:
只要 JSON 字符串仅被评估一次,那么相比 JavaScript 对象字面量,JSON.parse 方法就要快得多,冷加载时尤其明显。
将普通对象字面量用于大量数据时还会带来一种风险:它们可以被解析两次!
字面量预解析时是第一次。
字面量被懒解析时是第二次。
第一次解析是必须的,可以将对象字面量放在顶层或PIFE中来避免第二次解析。
重复访问时的解析/编译情况
V8 的(字节)代码缓存优化可以改善重复访问时的体验。首次请求脚本时,Chrome 会下载脚本并将其提供给 V8 编译,同时将文件存储在浏览器的磁盘缓存中;当第二次请求 JS 文件时,Chrome 从浏览器缓存中获取该文件,并再次将其提供给 V8 编译。但这次编译的代码被序列化,并作为元数据附加到缓存的脚本文件中。
V8 中的代码缓存工作原理示意图
第三次请求脚本时,Chrome 从缓存中获取脚本文件和文件的元数据,并将两者都交给 V8 引擎。V8 会反序列化元数据来跳过编译步骤。如果前两次访问间隔小于 72 小时,代码缓存就会启动。如果使用服务 worker 缓存脚本,Chrome 也会主动启用代码缓存。详细信息可以参阅 web 开发者的代码缓存指南。
总结
到了 2019 年,加载脚本的主要瓶颈在于下载和执行脚本的时间开销。你可以为页面的顶层内容安排一个较小的同步(内联)脚本包,其余内容则使用一个或多个延迟脚本。可以把较大的包拆分成许多小包来按需加载。这样一来就能充分利用 V8 的并行化能力。
在移动设备上,为了减少网络、内存和 CPU 需求,你需要尽量减少脚本的数量。此外还应仔细调整缓存策略,让解析和编译任务尽量在主线程外执行。
参考资料
英文原文:https://v8.dev/blog/cost-of-javascript-2019
评论