工程师视角:Google Chrome的JavaScript优化技巧

发表时间: 2019-07-08 18:53

【摘要】本文为 Google Chrome 团队的开发项目工程师 Addy Osmani 在PerfMatters 2019 网页性能大会发表的“JavaScript性能优化”(https://medium.com/@
addyosmani/the-cost-of-javascript-in-2018-7d8950fbb5d4)的演讲,其分享了处理 JavaScript 的脚本优化
建议,大幅地减少了下载时间和执行时间。

视频地址:
https://youtu.be/X9eRLElSW1c(需科学上网)

作者 | Addy Osmani

译者 | 苏本如 责编 | 屠敏

出品 | CSDN(ID:CSDNnews)

以下为译文:

在过去的几年中,由于浏览器的脚本解析和编译速度的提高,Javascript成本构成发生了巨大的变化。到了2019年,处理Javascript的开销主要体现在脚本下载时间和CPU执行时间上。

如果浏览器的主线程忙于执行Javascript脚本,则用户交互体验可能会受影响,因此,优化脚本执行时间并消除网络瓶颈,会对用户体验产生积极的作用。

高层级的实用指南

这对Web开发人员来说意味着什么?意味着解析(Parse)和编译(Compile)不再像我们曾经想象的那么慢了。所以开发人员在优化Javascript包时,要重点关注以下三大方面:

减少下载时间

  • 确保Javascript包尽可能地小,特别是对于移动设备。较小的包可以提升下载速度、降低内存使用量,并减少CPU开销。

  • 避免只有一个大的Javascript包;如果包大小超过50–100 KB,就将其拆分为几个小包。(借助HTTP/2协议的多路复用机制,多个请求和响应消息可以同时传输,从而减少额外请求的开销。)

  • 对于移动设备上使用的Javascript包更要尽可能地小,一方面因为网络带宽的制约,另一方面需要要尽量减少内存的使用。

缩短执行时间

  • 避免持续占用主线程并影响页面响应时间的长时任务,现在脚本下载后的执行时间成为主要的成本开销。

避免使用大型内联脚本(因为它们仍然需要在主线程上进行解析和编译)。

  • 建议参考一条经验法则:如果一个脚本超过1KB,就不要将其内联(因为当外部脚本大小超过1KB时,就会触发代码缓存)。

为什么下载和执行时间很重要?

为什么优化下载和执行时间对我们很重要?因为对于低端网络而言,下载时间的影响非常之大。尽管4G(甚至5G)在全球范围内增长迅速,但大多数人的有效连接速度仍然远远低于网络的标称速度。有时当我们外出时,会感觉到网速下降到只有3G的速度(甚至更糟)。

JavaScript的执行时间对于CPU较慢的低端手机也非常重要。由于CPU、GPU,和散热限制的不同,高端和低端手机的性能差距巨大。这对JavaScript的性能影响明显,因为它的执行受到CPU性能的制约。

事实上,在Chrome之类的浏览器上,JavaScript的执行时间可以达到页面加载总耗时的30%。下图是一个具有典型工作负载的网站(Reddit.com)在一台高端桌面PC上的页面加载情况分析:

V8引擎下的Javascript处理时间占整个页面加载时间的10-30%

对于移动设备,与高端手机(如Pixel 3)相比,在中端手机(如Moto G4)上执行Reddit的Javascript脚本需要3-4倍的耗时,而在低端手机(价格低于100美元的Alcatel 1X)上执行Reddit的Javascript脚本更是需要6倍以上的耗时:

Reddit的Javascript脚本在几种不同设备(低端、中端和高端)上的执行时间。

注意:Reddit对于桌面和移动网络有不同的体验,因此MacBook Pro的执行结果无法与其他结果进行比较。

当你着手优化JavaScript的执行时间时,你需要留意可能长时间独占界面线程(UI Thread)的长时任务。即使页面看起来已经加载完成,这些长时任务也会拖累关键任务的执行。把长时任务分解成较小的任务。通过拆分代码并确定加载顺序,你可以更快地实现页面交互,并有望降低输入延迟。

独占主线程的长时任务应该拆分。

V8引擎如何提高Javascript解析/编译速度?

自Chrome 版本60以来,V8引擎的原始JS的解析速度增加了2倍。与此同时,Chrome还做了其他工作一些工作使得解析和编译工作并行化,这使得这部分的成本开销对用户体验的影响变得不是那么显著和关键了。

V8引擎通过将解析和编译工作转到worker线程上,使得主线程上的解析和编译工作量平均减少了40%。例如,Facebook降低了46%,Pinterest降低62%,而最大的改进是是YouTube ,降低了81%。这是在现有的非主线程流解析/编译性能改进基础上的进一步提升。

不同版本的V8引擎的解析时间对比

我们还可以图示对比不同Chrome版本的不同V8引擎对CPU处理时间的影响。可以看出,Chrome 61解析Facebook的JS脚本所花费的时间,可以供Chrome 75解析同样的Facebook的JS脚本,和6个Twitter的JS脚本了。

Chrome 61解析Facebook的JS脚本所花费的时间,可以供Chrome 75解析完成同样的Facebook的JS脚本,和6个Twitter的JS脚本了。

让我们深入研究一下这些改进是如何实现的。总的来说,脚本资源可以在worker线程上进行流式解析和编译,这意味着:

  • V8引擎可以在不阻塞主线程的情况下解析和编译JavaScript。

  • 当整个HTML解析器遇到<script>标记时,就开始流式处理。遇到阻塞解析器(parse-blocking)的脚本时,HTML解析器就放弃,而对于异步脚本则继续处理。

  • 在大多数网络连接速度下,V8引擎的解析速度都比下载速度快,因此在最后一个脚本字节被下载后几毫秒的时间内,V8引擎就能完成解析+编译工作。

具体来说,很多老版本的Chrome在开始脚本解析之前,需要将脚本下载完成,这是一种简单的方法,但它没有充分利用CPU的能力。而从版本41到68,Chrome在下载一开始时就立即在单独的线程上解析异步和延迟脚本。

JS脚本以多个块下载。V8引擎看到大于30KB的脚本被下载后就会启动脚本流解析工作。

Chrome 71采用了基于任务(task-based)的设置方案。调度器可以一次解析多个异步/延迟脚本,这一改进使得主线程解析时间缩短了约20%,真实网站上的TTI/FID整体提高了大约2%。

Chrome 71采用了基于任务(task-based)的设置,调度器可以一次解析多个异步/延迟脚本

Chrome 72开始采用流式处理作为主要的解析方式,现在常规的同步脚本(内联脚本除外)也可以采用这种解析方式。如果主线程需要,我们也可以继续采用基于任务的解析,从而减少不必要地重复工作。

旧版的Chrome支持流式解析和编译,其中来自网络的脚本源数据必须先到达Chrome主线程后,再转发给流解析器解析。

这通常会导致这样的情况:脚本数据已经从网络上下载完成,但由于主线程上的其他任务(如HTML解析、排版或者JavaScript执行),阻塞了脚本数据的转发,因此流解析器(streaming parser)不得不空等。

现在我们正尝试在预加载时开始解析,以前主线程反弹会阻碍这种操作。

Leszek Swirski 在 BlinkOn 10 上的演讲介绍了相关细节:
https://youtu.be/D1UJgiG4_NI(需科学上网)

这些改变如何反映到DevTools中?

除上述之外,DevTools中还存在一个问题,它以表明它会独占 CPU(完全阻塞)的方式渲染整个解析器任务。但是,不管解析器是否需要数据(数据需要通过主线程)都会阻塞。当我我们从单个流线程转向多个流传输任务时,这个问题变得非常明显。下面是你在Chrome 69中看到的情况:

DevTools以表明它会独占CPU(完全阻塞)的方式渲染整个解析器任务

如上图示,“解析脚本”任务需要1.08秒。但是解析JavaScript其实并没有那么慢!大部分时间除了等待数据通过主线程之外什么都做不了。

而在Chrome 76中显示的内容就不一样了:

在Chrome 76中,解析工作被分解为多个较小的流任务。

一般来说,DevTools性能窗格非常适合从宏观层面分析你的页面。对于更具体的V8度量指标,如Javascript解析和编译时间,我们建议使用带有运行时调用统计(RCS)的Chrome跟踪工具。在RCS结果中,Parse-Background和Compile-Background会告诉你在主线程外解析和编译Javascript花费了多少时间,而Parse和Compile是针对主线程的度量指标。

这些改变对现实应用的影响是什么?

让我们来看一些真实网站的示例,来了解脚本流(script streaming)是如何工作的。

主线程和worker线程在MacBook Pro上解析和编译Reddit网站的JS所花费的时间对比

Reddit.com网站有几个超过100KB的JS包,它们包装在外部函数中,导致在主线程上需要进行大量的延迟编译(lazy compilation)。如上图所示,主线程耗时才是真正关键的,因为主线程持续繁忙会严重影响交互体验。Reddit的大部分时间花在了主线程上,而worker线程或后台线程的使用率很低。

可以将一些较大的JS包拆分为几个不需要包装的小包(例如每个包50 KB),以最大限度地实现并行化,这样每个包都可以单独进行流解析和编译,并在载入期间减少主线程的解析/编译时间。

主线程和worker线程在MacBook Pro上解析和编译Facebook网站的JS所花费的时间对比

我们再看看像facebook.com这样的网站的情况。Facebook使用了大约292个请求,加载了大约6MB的压缩JS脚本,其中一些是异步的,一些是预加载的,还有一些是低优先级的。它们的许多脚本都非常小,粒度也不大,这有助于后台/workers线程上的整体并行化,因为这些较小的脚本可以同时进行流解析/编译。

值得注意地是,像Facebook或Gmail这样老牌的应用程序的桌面版本上有这么多的脚本可能是合理的。但是你的网站可能和Facebook不一样。不管怎样,尽可能地简化你的JS包,不必要的就不要装载了。

尽管大多数JavaScript解析和编译工作都可以在后台线程上以流式方式进行,但仍有一些工作必须在主线程上进行。而当主线程繁忙时,页面就无法响应用户输入了。所以要密切关注下载和执行代码对用户体验的影响。

注意:目前并不是所有的Javascript引擎和浏览器都实现了脚本流(script streaming)式加载优化。但是我们仍然相信,本文的整体指导会帮助大家全面地提升用户体验

解析JSON的开销

JSON语法比JavaScript语法简单很多,所以JSON的解析效率要比Javascript高得多。基于这一点,Web应用程序可以提供类似于JSON的大型配置对象文本,而不是将数据作为Javascript对象文本进行内联,这样可以大大提高Web应用程序的加载性能。如下所示:

const data = { foo: 42, bar: 1337 }; // 

……它可以用 JSON 字符串形式表示,然后在运行时进行 JSON 解析。如下所示:

const data = JSON.parse('{"foo":42,"bar":1337}'); // 

只要JSON字符串只计算一次,那么相比Javascript对象文本, JSON.parse方法就要快得多,冷加载时尤其明显。

在为大量数据使用普通对象文本时还有一个额外的风险:它们可能会被解析两次!

  1. 第一次是文本预解析时。

  2. 第二次是文本延迟解析时。

第一次解析是必须的,可以将对象文本放在顶层或PIFE中来避免第二次解析。

重复访问时的解析/编译情况如何?

V8引擎的(字节)代码缓存优化可以帮助改善重复访问时的体验。当第一次请求脚本时,Chrome会下载脚本并将其交给V8引擎进行编译。同时将文件存储在浏览器的磁盘缓存中。当第二次请求JS文件时,Chrome会从浏览器缓存中获取该文件,并再次将其交给V8引擎进行编译。然而,这次编译的代码会被序列化,并作为元数据附加到缓存的脚本文件中。

V8引擎的代码缓存示意图

第三次请求脚本时,Chrome从缓存中获取脚本文件和文件的元数据,并将两者都交给V8引擎。V8引擎会反序列化元数据来跳过编译步骤。如果前两次访问间隔小于72小时内,代码缓存就会启动。如果采用service worker来缓存脚本,那么chrome也会主动启动代码缓存。详细信息可以参阅 web 开发者的代码缓存指南。

总结

到了2019年。脚本下载和执行的时间开销已经变成加载脚本的主要瓶颈。所以你应该为你的首屏内容准备一个较小的同步(内联)脚本包,其余部分则使用一个或多个延迟脚本,并且把较大的包拆分成许多小包来按需加载。这样一来就能充分利用 V8 引擎的并行化能力。

在移动设备上,由于网络、内存消耗和CPU执行时间的制约,你需要尽可能地减少脚本的数量,平衡延迟和缓存设置,尽可能地让解析和编译工作在主线程外执行。

原文:
https://v8.dev/blog/cost-of-javascript-2019

本文为 CSDN 翻译,转载请注明来源出处。

【End】