写点什么

2019 年 JavaScript 性能优化解析

2019 年 7 月 01 日

2019年JavaScript性能优化解析

在日前的 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 执行等),来不及处理这些数据,所以数据还没有转发到流任务上,流解析器只能干等。


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


https://youtu.be/D1UJgiG4_NI


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 对象字面量的做法来提升加载速度,如下所示:


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 小时,代码缓存就会启动。如果使用服务 worker 缓存脚本,Chrome 也会主动启用代码缓存。详细信息可以参阅 web 开发者的代码缓存指南


总结


到了 2019 年,加载脚本的主要瓶颈在于下载和执行脚本的时间开销。你可以为页面的顶层内容安排一个较小的同步(内联)脚本包,其余内容则使用一个或多个延迟脚本。可以把较大的包拆分成许多小包来按需加载。这样一来就能充分利用 V8 的并行化能力。


在移动设备上,为了减少网络、内存和 CPU 需求,你需要尽量减少脚本的数量。此外还应仔细调整缓存策略,让解析和编译任务尽量在主线程外执行。


参考资料


https://v8.dev/blog/scanner


https://v8.dev/blog/preparser


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


2019 年 7 月 01 日 19:175847

评论

发布
暂无评论
发现更多内容

从实际案例讲 Deno 的应用场景

keelii

Java typescript deno

实用心理学—没用你打我!

代码制造者

职场 职场搞笑 信息技术 人工

微服务框架 - 模块功能设计篇

superman

2.3.1 理解动态代理 -《SSM深入解析与项目实战》

谙忆

免费DDoS攻击测试工具大合集

陈磊@Criss

Vue项目起步

JackWangGeek

Vue

如何正确认识区块链?

CECBC区块链专委会

区块链价值 区块链应用

【写作群星榜】8.1~8.14 写作平台优秀作者 & 文章排名

InfoQ写作平台官方

写作平台 排行榜

重磅消息,我国数字人民币将在京津冀等具备条件地区试点

CECBC区块链专委会

数字货币 货币

6种快速统计代码执行时间的方法

Bruce Duan

java统计时间 currentTimeMillis nanoTime StopWatch

Django的Models更新时,不触发Signals解决办法

Young先生

django singals 信号机制 update 更新

Windows AD 保姆级配置NTP服务器教程

Young先生

时间 AD ntp Windows Server 2012 R2

英特尔首席架构师Raja:一个“百亿亿次级计算能力惠及每个人”的时代正在到来

飞天鱼2017

NetPerf揭示容器间是高速路还是林荫小路

陈磊@Criss

统一软件开发过程(RUP)的概念和方法

力软.net/java开发平台

项目管理 软件开发流程

为什么会是Docker?

flyer0126

Docker

统一软件开发过程(RUP)的概念和方法

雯雯写代码

Maven的爱恨情仇

xcbeyond

Java maven

Junit执行单元测试用例成功,mvn test却失败的问题和解决方法

陈磊@Criss

LeetCode题解:88. 合并两个有序数组,splice合并数组+sort排序,JavaScript,详细注释

Lee Chen

LeetCode 前端进阶训练营

微服务框架-模块需求篇

superman

MySQL中timestamp和datetime,你用的对么?

xcbeyond

MySQL 数据库 后端

如何写出完美的接口:接口规范定义、接口管理工具推荐

xcbeyond

Java 架构 接口规范

2.3.2 JDK动态代理 -《SSM深入解析与项目实战》

谙忆

SpringBoot系列(五):SpringBoot 日志配置(logback)

xcbeyond

Java 微服务 springboot logback

终于知道Kafka为什么这么快了!

海星

kafka 消息队列

SpringBoot系列(六):SpringBoot 数据库操作(集成MyBatis)

xcbeyond

Java 微服务 mybatis springboot

Swagger 这一个文章就够了

陈磊@Criss

[修复 Webpack 官方 Bug] 提取CSS时的依赖图修正

分一

前端 webpack 编译优化 源码刨析

Facebook开源的数据Mock:Memisis详解

陈磊@Criss

用Ant实现Java项目的自动构建和部署

陈磊@Criss

NLP领域的2020年大事记及2021展望

NLP领域的2020年大事记及2021展望

2019年JavaScript性能优化解析-InfoQ