TL;DR
可以考虑基于 HTTP Cache 来定义打包维度, 将 Cache 周期相同的 script 尽量打包在一起, 最大限度利用 Cache;
合并零散的小脚本, 避免触发浏览器并发请求限制后, 资源请求串行, TTFB 叠加等待时间;
注意打包后的资源依赖与资源引入顺序.
1. 引言
性能优化涵盖的范围非常之广, 其中包含的知识也非常繁杂. 从加载性能到渲染性能, 运行时性能, 每个点都有非常多可以学习与实践的知识.
优化问题包含方方面面, 优化手段也依场景和具体问题而定. 因此, 本文并不是一个泛而全的概览文章, 而是以之前的一次对于业务产品的简单优化 (主要是 DOMContentLoaded 时间) 为例, 介绍了如何使用 Chrome Dev Tools 来分析问题, 使用一些策略来缩短 DOMContentLoaded 的时间, 提高加载速度.
2. DOMContentLoaded 事件
W3C 将页面加载分为了许多阶段, DOMContentLoaded(以下简称 DCL)类似的有一些 DOM readState , 它们都会标识页面的加载状态与所处的阶段. 我们接触最多的也就是 readState 中的 interactive,complete(或 load 事件)以及 DCL 事件
简单了解一下它们. 浏览器会基于 html 内容来构建 DOM, 并基于 CSS 构建 CSSOM. 两者构建完成后, 会合并为 Render Tree. 当 DOM 构建完毕后,
document.readyState
状态会变为 interactive .
Render Tree 构建完成就会进入到我们非常熟悉的 Layout ->> Paint ->> Composite 管道.
但是当页面包含 Javascript 时, 这个过程会有些区别.
根据 HTML5 spec https://www.w3.org/TR/html5/syntax.html#the-end , 由于在 Javascript 中可以访问 DOM, 因此当浏览器解析页面遇到 Javascript 后会阻塞 DOM 的解析; 于此同时, 为避免 CSS 与 Javascript 之间的竞态, CSSOM 的构建会阻塞 Javascript 脚本的执行. 不过有一个例外, 如果将脚本设置为 async, 会有一个区别, DCL 的触发不需要等待 async 的脚本被执行.
也就是:
当浏览器完成对于 document 的解析 (parse) 时, 文档状态就会被标记为 interactive . 即 "DOM tree is ready".
当所有普通 (既不是 defer 也不是 async) 与 defer 的脚本被执行, 并且已经没有任何阻塞脚本的样式时, 浏览器就会触发 DOMContentLoaded 事件. 即 "CSSOM is ready".
或者将上面的部分精简一下:
DOM construction can't proceed until JavaScript is executed, and JavaScript can't proceed until CSSOM is available. [1] https://calendar.perfplanet.com/2012/deciphering-the-critical-rendering-path/
3. 排查问题
下面就可以通过 Chrome Dev Tools 来分析问题. 为了内容精简, 以下截图取了在 slow 3G 无缓存模式下的访问情况, 为了保持和线上环境类似(还原浏览器的同源最大请求并发), 在本地搭建对应的服务器放置静态资源. wifi 情况下, 各个时间点大致等比缩短 8~9 倍.
首先看一个整体的 waterfall
在最下面可以看到 DCL 为 17.00s(slow 3G).
p.s. 页面 load 时间也很长. 主要因为业务膨胀后, 页面包含过多资源, 没有使用一些懒加载与异步渲染技术, 这部分也存在很多优化空间, 但由于篇幅不在本文中讨论内.
页面里有一个很明显的请求 block 了 DCL -- common.js. 那么 common.js 是什么呢? 它其实就是项目中一些通用脚本文件的打包合并.
由于 common.js 为同步脚本, 因此等到它其下载并执行完毕后, 才会触发 DCL. 而与此对应的, 其他各个脚本的时间线与其有很大差距. 具体来看 common.js 的 Timing pharse, 耗时 11.44s, 其中 download 花费 7.12s.
4. 分析诊断
download 过长最直接的原因就是文件太大. common.js 的打包合并包含了下面的内容
- 'pkg/common.js': [
- 'static/js/bridge.js', // 业务基础库
- 'static/js/zepto.min.js', // 第三方库
- 'static/js/zepto.touch.min.js', // 第三方库
- 'static/js/bluebird.core.min.js', // 第三方库
- 'static/js/link.interceptor.js', // 业务基础库
- 'static/js/global.js', // 业务基础库
- 'static/js/felog.js', // 业务基础库
- 'widget/utils/*.js' // 业务工具组件
- ]
这里, 我们发现这么打包会存在下面几个问题:
4.1. 文件大小
download 过长最直接的原因: 文件过大.
将这些资源全部打包在一起导致 common.js 较大, 原文件 161KB,gzip 之后为 52.5KB, 单点阻塞了关键渲染路径. 你也可以在 audits 中的 Critical Request Chains 部分发现 common.js 是瓶颈.
4.2. HTTP Cache
zepto/bluebird 这种第三方库属于非常稳定的资源, 几乎不会改动. 虽然代码量较多, 但是通过 HTTP Cache 可以有效避免重复下载. 同时, 上线新版后, 为了避免一些文件走 HTTP Cache, 我们会给静态资源加上 md5.
然而, 当这些稳定的第三方库与一些其他文件打包后, 会因为该打包中某些文件的局部变动导致合并打包后的 hash 变化而缓存失效.
例如, 其中 bridge.js 与 / utils/*.js 容易随着版本上线迭代, 迭代后打包导致 common 的 hash 变化, HTTP Cache 失效, zepto/bluebird 等较大的资源虽然未更改, 但由于打包在了一起, 仍需要重新下载. 每次上线新版本后, 一些加载的性能数据表现都会显著下降, 其中一部分原因在于此.
5. 实施优化手段
结合上面分析的问题, 可以进行一些简单而有效的优化.
5.1. 拆包
考虑将文件的打包合并按照文件的更新频率进行划分. 这样既可以有效缩减 common.js 的大小, 也可以基于不同类型的资源, 更好利用 HTTP Cache.
例如:
将基本不会变动的文件打包为 lib.js, 主要为一些第三方库, 这类文件几乎不会改动, 非常稳定.
将项目依赖的最基础 js 打包为 common.js, 例如本文中的 global.js,link.interceptor.js, 项目中的所有部分都需要它们, 同时也是项目特有的, 相较上一部分的 lib 会有一定量的开发与改动, 但是更新间隔可能会有几个版本.
将项目中变动较为频繁的工具库打包为 util.js, 理论上其中工具由于不作为基础运行的依赖, 是可以异步加载的. 这部分代码是三者之中变动最为频繁的.
- 'pkg/util.js': [
- 'widget/utils/*.js'
- ],
- 'pkg/common.js': [
- 'static/js/link.interceptor.js',
- 'static/js/global.js',
- 'static/js/felog.js'
- ],
- 'pkg/lib.js': [
- 'static/js/zepto.min.js',
- 'static/js/zepto.touch.min.js',
- 'static/js/bluebird.core.min.js'
- ]
- 5.2 Quene Delay
但是在拆分后 DCL 时间几乎没有减少.
这里就不得不提到打包的初衷之一: 减少并发. 我们将 common.js 拆分为三个部分后, 触碰到了同域 TCP 连接数限制, 图中的这四个资源被 chrome 放入了队列(图中白色长条).
Queueing. The browser queues requests when:
There are higher priority requests.
There are already six TCP (Chrome) connections open for this origin, which is the limit. Applies to HTTP/1.0 and HTTP/1.1 only.
The browser is briefly allocating space in the disk cache
我们打包合并资源一定程度上也是为了减少 TCP round trip, 同时尽量规避同域下的请求并发数量限制. 因此在 common.js 拆分时, 也要注意不宜分得过细, 否则过犹不及, 忘了初衷.
从 network waterfall 中也很容易发现, 大部分资源由于 size 较小, 其下载时间其实非常短, 耗时主要是在 TTFB(Time To First Byte), 可以粗略理解为在等待服务器返回数据(图中表现出来就是绿色较多). 所以除了打包项目依赖的 lib.js/common.js/util.js 外, 还可以考虑将部分依赖的组件脚本进行打包合并,
像上图中这四个脚本的耗时都在在 TTFB 上, 而且在同一个 CDN 上, 可以通过打包减小不必要的并发. 将首屏依赖的关键组件进行打包:
- 'pkg/util.js': [
- 'widget/utils/*.js'
- ],
- 'pkg/common.js': [
- 'static/js/bridge.js',
- 'static/js/link.interceptor.js',
- 'static/js/global.js',
- 'static/js/felog.js'
- ],
- 'pkg/lib.js': [
- 'static/js/zepto.min.js',
- 'static/js/zepto.touch.min.js',
- 'static/js/bluebird.core.min.js'
- ],
- 'pkg/homewgt.js': [
- 'widget/home/**.js',
- 'widget/player/*.js',
- ]
优化后的 DCL 变为了 11.20s.
5.3 资源引入顺序
注意, 一些打包工具会自动分析文件依赖关系, 文件打包后会同时替换资源路径. 例如: 在 HTML 中, 引用了
static/js/zepto.min.js
和
static/js/bluebird.core.min.js
两个资源, 在打包后构建工具会将 HTML 中的引用自动替换为 lib.js . 因此需要注意打包后的资源加载顺序.
例如, 原 HTML 中的资源顺序
- <script type="text/javascript" src="//your.cdn.com/static/js/bridge.js"></script>
- <script type="text/javascript" src="//your.cdn.com/static/js/zepto.min.js"></script>
- <script type="text/javascript" src="//your.cdn.com/static/js/bluebird.core.min.js"></script>
- <script type="text/javascript" src="//your.cdn.com/static/js/global.js"></script>
其中 global.js 依赖于 zepto.min.js, 这个在目前看来没有问题. 但是由于打包合并, 构建工具会自动替换脚本文件名. 由于 bridge.js 的位置, 在打包后 common.js 的引入顺序先于 lib.js. 这就导致 global.js 先于 zepto.min.js 引入与执行, 出现错误.
对此, 在不影响原有依赖的情况下, 可以调整脚本顺序
- <script type="text/javascript" src="//your.cdn.com/static/js/zepto.min.js"></script>
- <script type="text/javascript" src="//your.cdn.com/static/js/bluebird.core.min.js"></script>
- <script type="text/javascript" src="//your.cdn.com/static/js/bridge.js"></script>
- <script type="text/javascript" src="//your.cdn.com/static/js/global.js"></script>
输出的结果如下:
6. 验证效果
最终在无缓存的 slow 3G 下 DCL 时间 11.19s, 相比最初的 17.00s, 降低 34%.(wifi 情况下降比例相同, 时间大致同比为 1/8~1/9, 接近 1s). 同时, 相较于之前, 一些静态资源能够更好地去利用 HTTP Cache, 节省带宽, 降低每次新版上线后用户访问站点的静态资源下载量.
7. 写在最后
需要指出, 性能优化也许有一些 "基本准则", 但绝对没有银弹. 无论是多么 "基础与通用" 的优化手段, 亦或是多么 "复杂而有针对性" 的优化手段, 都是在解决特定的具体问题. 因此, 解决性能问题往往都是从实际出发, 通过 "排查问题 --> 分析诊断 --> 实施优化 --> 验证效果" 这样一条不断循环的路径来开展的.
同时, 提升性能的其中一个目的就是更好的用户体验. 用户体验往往是一个宽泛的概念, 涉及方方面面. 相对应的, 性能优化也不能只死盯着某个 "指标", 更应该理解其背后对产品与用户的意义. 从问题出发, 拿数据量化, 找解决方案.
在实际环境下, 面对有限的资源和各种限制, 创造最大的价值. 性能优化更是如此.
参考资料
- HTML5 spec: parse HTML (the end) https://www.w3.org/TR/html5/syntax.html#the-end
- HTML5 spec: current-document-readiness https://www.w3.org/TR/html5/dom.html#current-document-readiness
Deciphering the Critical Rendering Path https://calendar.perfplanet.com/2012/deciphering-the-critical-rendering-path/
Network Analysis Reference https://developers.google.com/web/tools/chrome-devtools/network-performance/reference
来源: https://juejin.im/post/5aed037b6fb9a07aa047e1e1