浏览器的加载与页面性能优化

本文将探讨浏览器渲染的 loading 过程,主要有 2 个目的:

  • 了解浏览器在 loading 过程中的实现细节,具体都做了什么
  • 研究如何根据浏览器的实现原理进行优化,提升页面响应速度

由于 loading 和 parsing 是相互交织、错综复杂的,这里面有大量的知识点,为了避免过于发散本文将不会对每个细节都深入研究,而是将重点放在开发中容易控制的部分(Web 前端和 Web Server),同时由于浏览器种类繁多且不同版本间差距很大,本文将侧重一些较新的浏览器特性

现有知识

提升页面性能方面已经有很多前人的优秀经验了,如 Best Practices for Speeding Up Your Web Site 和 Web Performance Best Practices

本文主要专注其中加载部分的优化,总结起来主要有以下几点:

  • 带宽
    • 使用 CDN
    • 压缩 js、css,图片优化
  • HTTP 优化
    • 减少转向
    • 减少请求数
    • 缓存
    • 尽早 Flush
    • 使用 gzip
    • 减少 cookie
    • 使用 GET
  • DNS 优化
    • 减少域名解析时间
    • 增多域名提高并发
  • JavaScript
    • 放页面底部
    • defer/async
  • CSS
    • 放页面头部
    • 避免 @import
  • 其它
    • 预加载

接下来就从浏览器各个部分的实现来梳理性能优化方法

 

network

首先是网络层部分,这方面的实现大部分是通过调用操作系统或 gui 框架提供的 api

DNS

为了应对 DNS 查询的延迟问题,一些新的浏览器会缓存或预解析 DNS,如当 Chrome 访问 google 页面的搜索结果时,它会取出链接中的域名进行预解析

当然,Chrome 并不是每次都将页面中的所有链接的域名都拿来预解析,为了既提升用户体验又不会对 DNS 造成太大负担,Chrome 做了很多细节的优化,如通过学习用户之前的行为来进行判断

Chrome 在启动时还会预先解析用户常去的网站,具体可以参考 DNS Prefetching,当前 Chrome 中的 DNS 缓存情况可以通过 net-internals 页面来察看

为了帮助浏览器更好地进行 DNS 的预解析,可以在 html 中加上以下这句标签来提示浏览器

  1. <link rel="dns-prefetch" href="//HOSTNAME.com"

除此之外还可以使用 HTTP header 中的 X-DNS-Prefetch-Control 来控制浏览器是否进行预解析,它有 on 和 off 两个值,更详细的信息请参考 Controlling DNS prefetching

CDN

本文不打算详细讨论这个话题,感兴趣的读者可以阅读 Content delivery network

在性能方面与此相关的一个问题是用户可能使用自定义的 DNS,如 OpenDNS 或 Google 的 8.8.8.8,需要注意对这种情况进行处理

link prefetch

由于 Web 页面加载是同步模型,这意味着浏览器在执行 js 操作时需要将后续 html 的加载和解析暂停,因为 js 中有可能会调用 document.write 来改变 dom 节点,很多浏览器除了 html 之外还会将 css 的加载暂停,因为 js 可能会获取 dom 节点的样式信息,这个暂停会导致页面展现速度变慢,为了应对这个问题,Mozilla 等浏览器会在执行 js 的同时简单解析后面的 html,提取出链接地址提前下载,注意这里仅是先下载内容,并不会开始解析和执行

这一行为还可以通过在页面中加入以下标签来提示浏览器

  1. <link rel="prefetch" href="http://"
但这种写法目前并没有成为正式的标准,也只有 Mozilla 真正实现了该功能,可以看看Link prefetching FAQ

WebKit 也在尝试该功能,具体实现是在 HTMLLinkElement 的 process 成员函数中,它会调用 ResourceHandle::prepareForURL() 函数,目前从实现看它是仅仅用做 DNS 预解析的,和 Mozilla 对这个属性的处理不一致

对于不在当前页面中的链接,如果需要预下载后续内容可以用 js 来实现,请参考这篇文章 Preload CSS/JavaScript without execution

预下载后续内容还能做很多细致的优化,如在 Velocity China
2010
 中,来自腾讯的黄希彤介绍了腾讯产品中使用的交叉预下载方案,利用空闲时间段的流量来预加载,这样即提升了用户访问后续页面的速度,又不会影响到高峰期的流量,值得借鉴

预渲染

预渲染比预下载更进一步,不仅仅下载页面,而且还会预先将它渲染出来,目前在 Chrome(9.0.597.0)中有实现,不过需要在 about:flags 中将’Web Page Prerendering’开启

不得不说 Chrome 的性能优化做得很细致,各方面都考虑到了,也难怪 Chrome 的速度很快

http

在网络层之上我们主要关注的是 HTTP 协议,这里将主要讨论 1.1 版本,如果需要了解 1.0 和 1.1 的区别请参考Key Differences between HTTP/1.0 and HTTP/1.1

header

首先来看 http 中的 header 部分

header 大小

header 的大小一般会有 500 多字节,cookie 内容较多的情况下甚至可以达到 1k 以上,而目前一般宽带都是上传速度慢过下载速度,所以如果小文件多时,甚至会出现页面性能瓶颈出在用户上传速度上的情况,所以缩小 header 体积是很有必要的,尤其是对不需要 cookie 的静态文件上,最好将这些静态文件放到另一个域名上

将静态文件放到另一个域名上会出现的现象是,一旦静态文件的域名出现问题就会对页面加载造成严重影响,尤其是放到顶部的 js,如果它的加载受阻会导致页面展现长时间空白,所以对于流量大且内容简单的首页,最好使用内嵌的 js 和 css

header 的扩展属性

header 中有些扩展属性可以用来保护站点,了解它们是有益处的

  • X-Frame-Options
    • 这个属性可以避免网站被使用 frame、iframe 的方式嵌入,解决使用 js 判断会被 var location; 破解的问题,IE8、Firefox3.6、Chrome4 以上的版本都支持
  • X-XSS-Protection
    • 这是 IE8 引入的扩展 header,在默认情况下 IE8 会自动拦截明显的 XSS 攻击,如 query 中写 script 标签并在返回的内容中包含这项标签,如果需要禁止可以将它的值设为 0,因为这个 XSS 过滤有可能导致问题,如IE8 XSS Filter Bug
  • X-Requested-With
    • 用来标识 Ajax 请求,大部分 js 框架都会加入这个 header
  • X-Content-Type-Options
    • 如果是 html 内容的文件,即使用 Content-Type: text/plain; 的 header,IE 仍然会识别成 html 来显示,为了避免它所带来的安全隐患,在 IE8 中可以通过在 header 中设置 X-Content-Type-Options: nosniff 来关闭它的自动识别功能

使用 get 请求来提高性能

首先性能因素不应该是考虑使用 get 还是 post 的主要原因,首先关注的应该是否符合 HTTP 中标准中的约定,get 应该用做数据的获取而不是提交

之所以用 get 性能更好的原因是有测试表明,即使数据很小,大部分浏览器(除了 Firefox)在使用 post 时也会发送两个 TCP 的 packet,所以性能上会有损失

连接数

在 HTTP/1.1 协议下,单个域名的最大连接数在 IE6 中是 2 个,而在其它浏览器中一般 4-8 个,而整体最大链接数在 30 左右

而在 HTTP/1.0 协议下,IE6、7 单个域名的最大链接数可以达到 4 个,在 Even Faster Web Sites 一书中的 11 章还推荐了对静态文件服务使用 HTTP/1.0 协议来提高 IE6、7 浏览器的速度

浏览器链接数的详细信息可以在 Browserscope 上查到

使用多个域名可以提高并发,但前提是每个域名速度都是同样很快的,否则就会出现某个域名很慢会成为性能瓶颈的问题

cache

主流浏览器都遵循 http 规范中的 Caching in HTTP 来实现的

从 HTTP cache 的角度来看,浏览器的请求分为 2 种类型:conditional requests 和 unconditional requests

unconditional 请求是当本地没有缓存或强制刷新时发的请求,web server 返回 200 的 heder,并将内容发送给浏览器

而 conditional 则是当本地有缓存时的请求,它有两种:

  1. 使用了 Expires 或 Cache-Control,如果本地版本没有过期,浏览器不会发出请求
  2. 如果过期了且使用了 ETag 或 Last-Modified,浏览器会发起 conditional 请求,附上 If-Modified-Since 或 If-None-Match 的 header,web server 根据它来判断文件是否过期,如果没有过期就返回 304 的 header(不返回内容),浏览器见到 304 后会直接使用本地缓存中的文件

以下是 IE 发送 conditional requests 的条件,从 MSDN 上抄来

  • The cached item is no longer fresh according to Cache-Control or Expires
  • The cached item was delivered with a VARY header
  • The containing page was navigated to via META REFRESH
  • JavaScript in the page called reload on the location object, passing TRUE
  • The request was for a cross-host HTTPS resource on browser startup
  • The user refreshed the page

简单的来说,点击刷新按钮或按下 F5 时会发出 conditional 请求, 而按下 ctrl 的同时点击刷新按钮或按下 F5 时会发出 unconditional 请求

需要进一步学习请阅读:

前进后退的处理

浏览器会尽可能地优化前进后退,使得在前进后退时不需要重新渲染页面,就好像将当前页面先 “暂停” 了,后退时再重新运行这个 “暂停” 的页面

不过并不是所有页面都能 “暂停” 的,如当页面中有函数监听 unload 事件时,所以如果页面中的链接是原窗口打开的,对于 unload 事件的监听会影响页面在前进后时的性能

在新版的 WebKit 里,在事件的对象中新增了一个 persisted 属性,可以用它来区分首次载入和通过后退键载入这两种不同的情况,而在 Firefox 中可以使用 pageshow 和 pagehide 这两个事件

unload 事件在浏览器的实现中有很多不确定性因素,所以不应该用它来记录重要的事情,而是应该通过定期更新 cookie 或定期保存副本(如用户备份编辑文章到草稿中)等方式来解决问题

具体细节可以参考 WebKit 上的这 2 篇文章:

cookie

浏览器中对 cookie 的支持一般是网络层库来实现的,浏览器不需要关心,如 IE 使用的是 WinINET

需要注意 IE 对 cookie 的支持是基于 pre-RFC Netscape draft spec for cookies 的,和标准有些不同,在设定 cookie 时会出现转义不全导致的问题,如在 ie 和 webkit 中会忽略 “=”,不过大部分 web 开发程序(如 php 语言)都会处理好,自行编写 http 交互时则需要注意

p3p 问题

在 IE 中默认情况下 iframe 中的页面如果域名和当前页面不同,iframe 中的页面是不会收到 cookie 的,这时需要通过设置 p3p 来解决,具体可以察看微软官方的文档,加上如下 header 即可

  1. P3P:CP="IDC DSP COR ADM DEVi TAIi PSA PSD IVAi IVDi CONi HIS OUR IND CNT" 

这对于用 iframe 嵌入到其它网站中的第三方应用很重要

编码识别

页面的编码可以在 http header 或 meta 标签中指明,对于没有指明编码的页面,浏览器会根据是否设置了 auto detect 来进行编码识别(如在 chrome 中的 View-Encoding 菜单)

关于编码识别,Mozilla 开源了其中的 Mozilla Charset Detectors 模块,感兴趣的可以对其进行学习

建议在 http
header 中指定编码,如果是在 meta 中指定,浏览器在得到 html 页面后会首先读取一部分内容,进行简单的 meta 标签解析来获得页面编码,如 WebKit 代码中的 HTMLMetaCharsetParser.cpp,可以看出它的实现是查找 charset 属性的值,除了 WebKit 以外的其它浏览器也是类似的做法,这就是为何 HTML5 中直接使用如下的写法浏览器都支持

  1. <meta charset="utf-8"

需要注意不设定编码会导致不可预测的问题,应尽可能做到明确指定

chunked

浏览器在加载 html 时,只要网络层返回一部分数据后就会开始解析,并下载其中的 js、图片,而不需要等到所有 html 都下载完成才开始,这就意味着如果可以分段将数据发送给浏览器,就能提高页面的性能,这就是 chunked 的作用,具体协议细节请参考 Chunked Transfer Coding

在具体实现上,php 中可以通过 flush 函数来实现,不过其中有不少需要注意的问题,如 php 的配置、web server、某些 IE 版本的问题等,具体请参考 php 文档及评论

注意这种方式只适用于 html 页面,对于 xml 类型的页面,由于 xml 的严格语法要求,浏览器只能等到 xml 全部下载完成后才会开始解析,这就意味着同等情况下,xml 类型的页面展现速度必然比 html 慢,所以不推荐使用 xml

即使不使用这种 http 传输方式,浏览器中 html 加载也是边下载边解析的,而不需等待所有 html 内容都下载完才开始,所以实际上 chunked 主要节省的是等待服务器响应的时间,因为这样可以做到服务器计算完一部分页面内容后就立刻返回,而不是等到所有页面都计算都完成才返回,将操作并行

另外 Facebook 所使用的 BigPipe 实际上是在应用层将页面分为了多个部分,从而做到了服务端和浏览器计算的并行

keepalive

keepalive 使得在完成一个请求后可以不关闭 socket 连接,后续可以重复使用该连接发送请求,在 HTTP/1.0 和 HTTP/1.1 中都有支持,在 HTTP/1.1 中默认是打开的

keepalive 在浏览器中都会有超时时间,避免长期和服务器保持连接,如 IE 是 60 秒

另外需要注意的是如果使用阻塞 IO(如 apache),开启 keepalive 保持连接会很消耗资源,可以考虑使用 nginx、lighttpd 等其它 web server,具体请参考相关文档,这里就不展开描述

pipelining

pipelining 是 HTTP/1.1 协议中的一个技术,能让多个 HTTP 请求同时通过一个 socket 传输,注意它和 keepalive 的区别,keepalive 能在一个 socket 中传输多个 HTTP,但这些 HTTP 请求都是串行的,而 pipelining 则是并行的

可惜目前绝大部分浏览器在默认情况下都不支持,已知目前只有 opera 是默认支持的,加上很多网络代理对其支持不好导致容易出现各种问题,所以并没有广泛应用

SPDY

SPDY 是 google 提出的对 HTTP 协议的改进,主要是目的是提高加载速度,主要有几点:

  • Mutiplexed streams
    • 可以在一个 TCP 中传输各种数据,减少链接的耗时
  • Request prioritization
    • 请求分级,便于发送方定义哪些请求是重要的
  • HTTP header compression
    • header 压缩,减少数据量

frame

从实现上看,frame 类(包括 iframe 和 frameset)的标签是最耗时的,而且会导致多一个请求,所以最好减少 frame 数量

resticted

如果要嵌入不信任的网站,可以使用这个属性值来禁止页面中 js、ActiveX 的执行,可以参考 msdn 的文档

  1. <iframe security="restricted" src=""></iframe

javascript

加载

对于 html 的 script 标签,如果是外链的情况,如:

  1. <script src="a.js"></script

浏览器对它的处理主要有 2 部分:下载和执行

下载在有些浏览器中是并行的,有些浏览器中是串行的,如 IE8、Firefox3、Chrome2 都是串行下载的

执行在所有浏览器中默认都是阻塞的,当 js 在执行时不会进行 html 解析等其它操作,所以页面顶部的 js 不宜过大,因为那样将导致页面长时间空白,对于这些外链 js,有 2 个属性可以减少它们对页面加载的影响,分别是:

  • async
    • 标识 js 是否异步执行,当有这个属性时则不阻塞当前页面的加载,并在 js 下载完后立刻执行
    • 不能保证多个 script 标签的执行顺序
  • defer
    • 标示 js 是否延迟执行,当有这个属性时 js 的执行会推迟到页面解析完成之后
    • 可以保证多个 script 标签的执行顺序

下图来自 Asynchronous and deferred JavaScript execution explained,清晰地解释了普通情况和这 2 种情况下的区别

 

需要注意的是这两个属性目前对于内嵌的 js 是无效的

而对于 dom 中创建的 script 标签在浏览器中则是异步的,如下所示:

  1. var script = document.createElement('script');  
  2. script.src = 'a.js';  
  3. document.getElementsByTagName('head')[0].appendChild(script);  

为了解决 js 阻塞页面的问题,可以利用浏览器不认识的属性来先下载 js 后再执行,如 ControlJS 就是这样做的,它能提高页面的相应速度,不过需要注意处理在 js 未加载完时的显示效果

document.write

document.write 是不推荐的 api,对于标示有 async 或 defer 属性的 script 标签,使用它会导致不可预料的结果,除此之外还有以下场景是不应该使用它的:

  • 使用 document.createElement 创建的 script
  • 事件触发的函数中,如 onclick
  • setTimeout/setInterval

简单来说,document.write 只适合用在外链的 script 标签中,它最常见的场景是在广告中,由于广告可能包含大量 html,这时需要注意标签的闭合,如果写入的内容很多,为了避免受到页面的影响,可以使用类似 Google AdSense 的方式,通过创建 iframe 来放置广告,这样做还能减少广告中的 js 执行对当前页面性能的影响

另外,可以使用 ADsafe 等方案来保证嵌入第三方广告的安全,请参考如何安全地嵌入第三方 js – FBML/caja/sandbox/ADsafe 简介

script 标签放底部

将 script 标签放底部可以提高页面展现给用户的速度,然而很多时候事情并没那么简单,如页面中的有些功能是依赖 js 的,所以更多的还需要根据实际需求进行调整

  • 尝试用 Doloto 分析出哪些 JS 和初始展现是无关的,将那些不必要的 js 延迟加载
  • 手工进行分离,如可以先显示出按钮,但状态是不可点,等 JS 加载完成后再改成可点的

传输

js 压缩可以使用 YUI Compressor 或 Closure Compiler

gwt 中的 js 压缩还针对 gzip 进行了优化,进一步减小传输的体积,具体请阅读 On Reducing the Size of Compressed Javascript

css

比起 js 放底部,css 放页面顶部就比较容易做到

@import

使用 @import 在 IE 下会由于 css 加载延后而导致页面展现比使用 link 标签慢,不过目前几乎没有人使用 @import,所以问题不大,具体细节请参考 don’t use @import

selector 的优化

浏览器在构建 DOM 树的过程中会同时构建 Render 树,我们可以简单的认为浏览器在遇到每一个 DOM 节点时,都会遍历所有 selector 来判断这个节点会被哪些 selector 影响到

不过实际上浏览器一般是从右至左来判断 selector 是否命中的,对于 ID、Class、Tag、Universal 和 Page 的规则是通过 hashmap 的方式来查找的,它们并不会遍历所有 selector,所以 selector 越精确越好,google page-speed 中的一篇文档 Use efficient CSS selectors 详细说明了如何优化 selector 的写法

另一个比较好的方法是从架构层面进行优化,将页面不同部分的模块和样式绑定,通过不同组合的方式来生成页面,避免后续页面顶部的 css 只增不减,越来越复杂和混乱的问题,可以参考 Facebook 的静态文件管理

工具

以下整理一些性能优化相关的工具及方法

Browserscope

之前提到的 http://www.browserscope.org 收集了各种浏览器参数的对比,如最大链接数等信息,方便参考

Navigation Timing

Navigation Timing 是还在草案中的获取页面性能数据 api,能方便页面进行性能优化的分析

传统的页面分析方法是通过 javascript 的时间来计算,无法获取页面在网络及渲染上所花的时间,使用 Navigation Timing 就能很好地解决这个问题,具体它能取到哪些数据可以通过下图了解(来自 w3c)

 

 

目前这个 api 较新,目前只在一些比较新的浏览器上有支持,如 Chrome、IE9,但也占用一定的市场份额了,可以现在就用起来

boomerang

yahoo 开源的一个页面性能检测工具,它的原理是通过监听页面的 onbeforeunload 事件,然后设置一个 cookie,并在另一个页面中设置 onload 事件,如果 cookie 中有设置且和页面的 refer 保持一致,则通过这两个事件的事件来衡量当前页面的加载时间

另外就是通过静态图片来衡量带宽和网络延迟,具体可以看 boomerang

检测工具

reference

 

本文出自 “百度技术博客” 博客,请务必保留此出处 http://baidutech.blog.51cto.com/4114344/746830

原文地址:https://www.cnblogs.com/xiaochechang/p/5872036.html