当前位置: 首页 > news >正文

【性能优化实践】优化打包策略提升页面加载速度

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,由于在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]

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.jsstatic/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)
  • HTML5 spec: current-document-readiness
  • Deciphering the Critical Rendering Path
  • Network Analysis Reference

相关文章:

  • #、%和$符号在OGNL表达式中经常出现
  • JavaEE进阶知识学习-----SpringCloud(二)实践准备
  • JavaScript And Ajax(JavaScript 基本示例)
  • linux centos 如何设置swap大小?
  • 【资源共享】RK3288 WiFiBT 开发配置参考说明
  • 仪表运算放大器INA333
  • 5分钟快速了解es6常用特性
  • Hadoop 2.0 NameNode HA和Federation实践
  • 再也不用担心this指向的问题了
  • 快速理解URL重写
  • 12月,1000人,来一场属于敏捷人的重逢吧!
  • 话里话外:成功CEO的用人之道——按需激励
  • 001-ant design pro 页面加载原理及过程,@connect 装饰器
  • 对ASP网站程序的设计
  • “一盘货卖全球”之后,天猫今年将推出国货“出海2.0版”
  • [LeetCode] Wiggle Sort
  • 【5+】跨webview多页面 触发事件(二)
  • Angular 响应式表单 基础例子
  • bootstrap创建登录注册页面
  • Centos6.8 使用rpm安装mysql5.7
  • co模块的前端实现
  • Javascript 原型链
  • JavaScript工作原理(五):深入了解WebSockets,HTTP/2和SSE,以及如何选择
  • Map集合、散列表、红黑树介绍
  • nodejs调试方法
  • VirtualBox 安装过程中出现 Running VMs found 错误的解决过程
  • Web标准制定过程
  • win10下安装mysql5.7
  • 彻底搞懂浏览器Event-loop
  • 从0实现一个tiny react(三)生命周期
  • 服务器之间,相同帐号,实现免密钥登录
  • 少走弯路,给Java 1~5 年程序员的建议
  • 深入体验bash on windows,在windows上搭建原生的linux开发环境,酷!
  • 时间复杂度与空间复杂度分析
  • 适配iPhoneX、iPhoneXs、iPhoneXs Max、iPhoneXr 屏幕尺寸及安全区域
  • 我是如何设计 Upload 上传组件的
  • 系统认识JavaScript正则表达式
  • 想晋级高级工程师只知道表面是不够的!Git内部原理介绍
  • 【干货分享】dos命令大全
  • 蚂蚁金服CTO程立:真正的技术革命才刚刚开始
  • ​3ds Max插件CG MAGIC图形板块为您提升线条效率!
  • ​创新驱动,边缘计算领袖:亚马逊云科技海外服务器服务再进化
  • #define,static,const,三种常量的区别
  • #HarmonyOS:Web组件的使用
  • #我与Java虚拟机的故事#连载05:Java虚拟机的修炼之道
  • #我与Java虚拟机的故事#连载06:收获颇多的经典之作
  • ${factoryList }后面有空格不影响
  • (1)(1.13) SiK无线电高级配置(五)
  • (k8s中)docker netty OOM问题记录
  • (Pytorch框架)神经网络输出维度调试,做出我们自己的网络来!!(详细教程~)
  • (多级缓存)多级缓存
  • (附源码)springboot优课在线教学系统 毕业设计 081251
  • (学习日记)2024.02.29:UCOSIII第二节
  • .net core 3.0 linux,.NET Core 3.0 的新增功能
  • .NET Core6.0 MVC+layui+SqlSugar 简单增删改查