前端性能优化一直都是一个值得讨论和深究的问题,上有 Yahoo 经典 14 条优化军规,下有 O’Reilly 出版的两本性能优化圣经《高性能网站建设指南》、《高性能网站建设进阶指南》。它们的出现,让 Web 应用的性能在 HTTP 1.x 时代大放异彩,而即将步入 HTTP 2.0 时代,有哪些新的手段可以采用,又有哪些老的手段将不再适用?本文将从一个前端初学者的角度,整理并分析开来。
前言
雅虎 14 条性能优化原则及其分类:
优化方向 | 优化手段 |
---|---|
请求数量 | 合并脚本和样式表,CSS Sprites,inline image, 拆分初始化负载,划分主域 |
请求带宽 | 开启 Gzip,精简 JavaScript,移除重复脚本,图像优化 |
缓存利用 | 使用 CDN,使用外部 JavaScript 和 CSS,添加 Expires 头, 减少 DNS 查找,配置 ETag,使 AjaX 可缓存 |
页面结构 | 将样式表放在顶部,Javascript 置底,避免空<img> ,标签将脚本放在底部,尽早刷新文档的输出,,延迟加载 JavaScript |
代码校验 | 避免 CSS 表达式,避免重定向 |
优化细则细讲:
- 请求数量:
- 合并脚本和样式表:HTTP 请求需要开销,合并脚本和样式表可以减少请求的次数
- CSS Sprites:CSS 雪碧图,也称 CSS 精灵,原理是把多张图片文件整合至一张文件中,进而减少 HTTP 请求,使用时利用 CSS 的相关属性(
background-image
、background-repeat
、background-position
等)组合进行图片背景定位,即可精确的定位出背景图片的位置 - inline image:利用 Data URIs 方案,将图片数据嵌入到文档里。这会增大 HTML 的体积。你可以将其嵌入到已经缓存的 CSS 里,减少 HTTP 请求的同时,也避免激增了文档的体积。
Data URIs
允许将一个小文件进行编码后嵌入到另外一个文档里(参考:RFC-2397)。参考:Data URIs make CSS sprites obsolete - 拆分初始化负载:目标是将页面一开始加载时不需要执行的资源从所有资源中分离出来,等到需要的时候再加载。
- 划分主域:众所周知,在 HTTP/1.x 协议中「浏览器客户端在同一时间,针对同一域名下的请求有一定数量限制。超过限制数目的请求会被阻塞」(参考:RFC-2616-8.1.4 Practical Considerations)而不同浏览器对该限制的数目也不尽相同(参考:Roundup-on-parallel-connections),划分主域、配置静态资源专用域(static.example.com),目的就是变相的解决浏览器针对同一域名的请求限制阻塞问题。但不易过多,否则 DNS 的查询时间也是个问题。
- 请求带宽:
- 开启 Gzip:服务端启用
Gzip
可以有效的减少数据传输量,Gzip
对于基于文本的文件(CSS、JavaScript、HTML)压缩效果最好,所有现代浏览器都支持Gzip
压缩并将自动请求该压缩 - 精简 JavaScript,移除重复脚本:一张页面上存在者重复的 JavaScript 文件会严重的影响性能,这无疑制造了无意义的 HTTP 请求,浪费了 JavaScript 的执行时间。
- 开启 Gzip:服务端启用
- 缓存利用
- 使用 CDN:利用内容分发网络能够有效的减少资源响应时间,提升用户体验。
- 使用外部 JavaScript 和 CSS:浏览器可以将外部的 JavaScript 和 CSS 文件进行缓存,而如果将其写成内联样式,嵌入至 HTML 文档里,那么每次请求该文档时它们都会被下载。虽然减少了 HTTP 请求,但这无疑也增加了 HTML 文档的体积。另一方面,如果外部 JavaScript 和 CSS 文件已经被浏览器缓存,那么 HTTP 请求也将不再激增。而关键的权衡在于带有外部 JavaScript 和 CSS 文件的 HTML 文档使用频率。如果一次会话中有多个页面要被访问,并且大部分页面都可以共用脚本,那么使用浏览器缓存过的外部 JavaScript 和 CSS 无疑是最佳选择。
- 添加 Expires 头( Cache-Control):通过服务端配置 Cache-control 首部和 Expires 首部,HTTP 让原始服务器向每个文档附加了一个保质期。可以将静态组件设置为永不过期,而动态组件利用合适的 Cache-Control 来设置相对的过期时间。但也面临另外一个挑战:如何更新这些缓存?
- 配置 ETag 实体标签:服务端配置 ETag 是一种有效的 Web 缓存验证机制,并且允许客户端进行缓存协商。这就使得缓存变得更加高效,而且节省带宽。如果资源的内容没有发生改变,服务器就不需要发送一个完整的响应。ETag 也可用于乐观并发控制,作为一种防止资源同步更新而相互覆盖的方法。(参考:HTTP_ETag)
- 使 AjaX 可缓存:难度指数要求比较高,开发中也比较少见,具体详细参考 Facebook 的 BigPipe,也可以参看淘宝搜索团队的一篇 BigPipe 学习研究
- 页面结构:
- 样式表置顶、JavaScript 置底:要了解为何要将样式表置顶以及 JavaScript 置底,首先需要了解「浏览器如何渲染页面」?
- 避免空
<img>
:<img>
的src=""
属性,当设置这个属性为 URL 时,浏览器会发起 Get 请求,从这个 URL 下载图片。空属性同样会造成不必要的浏览器请求。当然在过去不同浏览器有不同的处理方法,(详参:Empty image src can destroy your site) - 延迟加载 JavaScript:默认情况下,JavaScript 执行会阻塞解析器:当浏览器在文档中遇到一个 script,它必须暂停 DOM 构建,移交控制权给 JavaScript 运行时,让脚本先执行,然后才继续处理 DOM。在前面的示例中,我们已经了解内联脚本的情况。事实上,内联脚本始终会阻塞解析器,除非你编写额外代码来推迟它们的执行。(参考:Defer loading javascript、如何延迟加载JS)
代码校验:
避免 CSS 表达式:
CSS 表达式是动态设置 CSS 属性的强大(但危险)方法。Internet Explorer 从第 5个版本开始支持CSS表达式。
下面的例子中,使用CSS表达式可以实现隔一个小时切换一次背景颜色:background-color: expression((new Date()).getHours()%2?"#FFFFFF":"#000000" );
如上所示,expression 中使用了 JavaScript 表达式。CSS 属性根据 JavaScript 表达式的计算结果来设置。expression 方法在其它浏览器中不起作用,因此在跨浏览器的设计中单独针对 Internet Explorer 设置时会比较有用。表达式的问题就在于它的计算频率要比我们想象的多。
不仅仅是在页面显示和缩放时,就是在页面滚动、乃至移动鼠标时都会要重新计算一次。给 CSS 表达式增加一个计数器可以跟踪表达式的计算频率。在页面中随便移动鼠标都可以轻松达到 10000 次以上的计算量。
一个减少 CSS 表达式计算次数的方法就是使用一次性的表达式,它在第一次运行时将结果赋给指定的样式属性,并用这个属性来代替 CSS 表达式。如果样式属性必须在页面周期内动态地改变,使用事件句柄来代替 CSS 表达式是一个可行办法。
如果必须使用 CSS 表达式,一定要记住它们要计算成千上万次并且可能会对你页面的性能产生影响。
节选自 AlloyTeam【高性能前端2】高性能 CSS避免重定向(301 Moved Permanently|302 Found):当页面发生了重定向,就会延迟整个HTML文档的传输。在 HTML 文档到达之前,页面中不会呈现任何东西,也没有任何组件会被下载。
下面简单说明浏览器是如何渲染页面的:
浏览器解析
- 浏览器通过请求的 URL 进行域名解析,获取到地址后向服务器发起请求,接受相关文件(HTML、CSS、JavaScript等)
- 开始加载 HTML,构建
DOM Tree
- CSS 样式加载后,开始解析和构建
CSS Rule Tree
- JavaScript 脚本文件加载后,通过 DOM API 和 CSS 对象模型(CSSOM)API 来操作 DOM Tree 和 CSS Rule Tree
浏览器渲染
- 浏览器引擎通过
DOM Tree
和CSS Rule Tree
构建Rendering Tree
Rendering Tree
并不与DOM Tree
对应,比如像<head>
标签内容或带有display:none;
的元素节点并不包括在Rendering Tree
中。- 通过
CSS Rule Tree
匹配DOM Tree
,进行定位坐标和大小,是否换行,以及Position、overflow、z-index
等属性,这个过程称为Flow
或Layout
- 最终通过调用
Native GUI
的 API 绘制页面的过程称为Paint
当用户在浏览网页时进行交互或通过 js 脚本改变页面结构时,以上的部分操作有可能重复运行,此过程称为 Repaint
或 Reflow
。
Repaint
当元素改变的时候,将不会影响元素在页面当中的位置(比如 background-color, border-color, visibility
),浏览器仅仅会应用新的样式重绘此元素,此过程称为 Repaint
。
Reflow
当元素改变的时候,将会影响文档内容或结构,或元素位置,此过程称为 Reflow
。( HTML 使用的是 flow
based layout
,也就是流式布局,所以,如果某元件的几何尺寸发生了变化,需要重新布局,也就叫 Reflow
。)
Reflow
的成本比Repaint
的成本高得多的多。一个结点的Reflow
很有可能导致子结点,甚至父点以及同级结点的Reflow
。在一些高性能的电脑上也许还没什么,但是如果Reflow
发生在移动设备上,那么这个过程是延慢加载和耗电的。
以下行为将有可能产生 Reflow
- 增加、删除、或改变 DOM 节点
- 增加、删除
class
属性值 - 元素尺寸改变
- 文本内容改变
- 浏览器窗口改变大小或拖动
- 动画效果进行计算和改变 CSS 属性值
- 伪类激活(
:hover
)
当然,我们的浏览器是聪明的,它不会像上面那样,你每改一次样式,它就
Reflow
或Repaint
一次。一般来说,浏览器会把这样的操作积攒一批,然后做一次Reflow
,这又叫异步reflow
或增量异步Reflow
。但是有些情况浏览器是不会这么做的,比如:Resize 窗口,改变了页面默认的字体,等。对于这些操作,浏览器会马上进行Reflow
。
举个例子:
为何要将样式表置顶?
默认情况下,CSS 被视为阻塞渲染的资源,这意味着在 CSSOM
构建完成前,浏览器会暂停渲染任何已处理的内容。确保精减你的 CSS,尽快传送它,并使用媒体类型与媒体查询来解除阻塞。
HTTP 2.0 is Coming
多路复用
多路复用允许同时通过单一的 HTTP/2 连接发起多重的请求-响应消息。因此 HTTP/2 可以很容易的去实现多流并行而不用依赖建立多个 TCP 连接,HTTP/2 把 HTTP 协议通信的基本单位缩小为一个一个的帧,这些帧对应着逻辑流中的消息。并行地在同一个 TCP 连接上双向交换消息。
HTTP/2 相关优化实践:
停止合并文件
在过去的时代合并文件可以让你减少 HTTP 请求,但 2.0 时代多路复用让合并文件不再是一项最佳实践。合并文件的同时也带来代价高昂的缓存失效,一行代码的改变将使得浏览器重新加载整个的 CSS 文件。停止划分主域(拆分域名)
在 HTTP/1.x 协议中「浏览器客户端在同一时间,针对同一域名下的请求有一定数量限制。超过限制数目的请求会被阻塞」
划分主域、配置静态资源专用域(static.example.com),目的就是变相的解决 HTTP 1.x 时代,浏览器针对同一域名的请求限制阻塞问题。
细分域名在 HTTP/2 中应该避免。每个细分的域名都会带来额外的 DNS 查询、TCP 连接和 TLS 握手(假设服务器使用不同的 TLS 证书)。在 HTTP/1.1 中,这个开销通过资源的并行下载得到了补偿。但在 HTTP/2 中,多路复用使得多个资源可以在一个连接中并行下载。同时,类似于资源内联,域名细分破坏了 HTTP/2 的流优先级,因为浏览器不能跨域比较优先级。
浏览器请求优先级与 HTTP 2.0
浏览器在渲染页面时,并非所有资源都具有相同的优先级:
HTML 文档本身对构建 DOM 不可或缺,CSS 对构建 CSSOM 不可或缺,而 DOM 和 CSSOM 的构建都可能受到 JavaScript 资源的阻塞(参见 10.1 节的附注栏 DOM、CSSOM 和 JavaScript),其他资源(如图片)的优先级都可以降低。
为加快页面加载速度,所有现代浏览器都会基于资源的类型以及它在页面中的位置排定请求的优先次序,甚至通过之前的访问来学习优先级模式——比如,之前的渲染如果被某些资源阻塞了,那么同样的资源在下一次访问时可能就会被赋予更高的优先级。
在 HTTP 1.x 中,浏览器极少能利用上述优先级信息,因为协议本身并不支持多路复用,也没有办法向服务器通告请求的优先级。此时,浏览器只能依赖并行连接,且最多只能同时向一个域名发送 6 个请求。
于是,在等连接可用期间,请求只能在客户端排队,从而增加了不必要的网络延迟。
理论上,HTTP 管道可以解决这个问题,只是由于缺乏支持而无法付诸实践。
HTTP 2.0 一举解决了所有这些低效的问题:浏览器可以在发现资源时立即分派请求,指定每个流的优先级,让服务器决定最优的响应次序。这样请求就不必排队了,既节省了时间,也最大限度地利用了每个连接。
节选自《Web 性能权威指南》