Note of optimization for zdlive web sites
前言
zdlive 是我大四实习的一家公司,当时在那边负责前端模块,写了蛮多的笔记,现在整理出来~=)
相比起后台的优化,前台的优化一般都会被忽视,但其实在用户发起一个页面请求,到页面最终展现给用户,前端所占的比重是非常大的。看了《高性能网站建设指南》,还有《构建高性能 Web 站点》的部分章节,针对 zdlive 的页面,可以做如下优化尝试。
优化的指导方针共有 13 条,结合 zdlive 讲。
减少 HTTP 请求
一般我们的网页会有如下三种,图片、css 文件、js 文件和 html 文件。当用户在输入输入网址的时候,浏览器会向服务端发起一个 http 请求。当这个 html 文件被下载到客户端之后,其他的组件才开始下载,我们以 zdlive.com 为例。
可以看到,所有的组件下载,都是等到第一个文件载入完毕后再发起的,而这里的每一项都是代表着一个 http 请求。
对于一个页面来说,多个 http 头并没有什么关系,但对于响应速度和稳定性要求高的,比如 zdlive 的首页,尤其是 gprs 这样坑爹的网络下,多个 http 头,意味着速度慢,请求失败的几率会很大,页面显示不完整等等问题。
那我们要做的就是减少 http 请求了。
方法有如下几种:
- 减少图片。目前比较常见的做法是把多张图片整合到一张图片里面,然后利用 css 的技术,比如 background 的定位来做到共享图片的目的,这种技术叫做 CSS Sprites。 坏处有 2,定位麻烦和单张图片的大小会很大,这样用户请求到完成的时间就会比单张小图片要久。
对于我们的首页来说,这种做法还需要实际测试下。 - 内联图片。对图片进行 base64 位的编码,这样图片就可以和 html 文档一起下载到客户端了。
坏处是 Base64 位压缩以后,图片的大小会变大。但结合服务端的 gzip 压缩,减少客户端的 8 个请求,还是可以尝试下的。 - 合并脚本和样式表。
对于一个文件来说,通常会有多个脚本文件和样式文件,分开是模块管理的需要,但有时候也可以结合在一起,合并下载。但这个单个文件大小,和多个 http 请求头之间的怎么去权衡,我还不是很清楚。 ## 使用内容分发网络 内容分发网络的意思是说,假设我们公司现在有多台服务器,一些专门用来跑应用支持,一些专门用来给用户下载文件的,比如 img 文件、mp3 文件等等。而这里,内容分发网络就是指这些用来下载的服务器了。只是,他们现在都在北京这个地方,如果这些被用户用来下载文件的服务器能部署在全国各地,那对于用户来说,更近的服务器,就意味着更短的相应时间和更快的下载速度。
拿 douban 的豆瓣 FM 来说,当我发起访问的时候,实际上我听的歌曲并不是在北京那边的服务器给过来的,倒是从广州这边的服务器下载的。
具体测试,可以用下抓包工具查看 ip 地址信息,海富推荐用 fiddler2。
添加 Expires 头
一般浏览器都会缓存页面请求的组件,比如图片、js 文件和 css 文件,这样能够减少客户端发起无谓的请求,加快页面响应速度。
但问题来了,浏览器怎么知道这些组件已经过期了呢?原来,浏览器和服务器之间是有协议的,这个协议是通过访问 Http 头来达到合作的目的。一般,当客户端发起一个请求的时候 (走 http1.1 的协议),http 里面就会带有很多的信息,其中有 3 个 http 头 Expires、last modified、Cache-Control 就是两者用来沟通缓存信息的,而我们要做的就是如下三点:
- 增加 last modified 头,减少服务器的传输成本。
- 增加 Expires 头,让浏览器本地可以直接访问资源,减少客户端不必要的请求。
- 增加 CaChe-Control 头,程序或 apache 的配置文件中设置 (静态资源),使用 max-age。
下面,我们以一个交互过程说明下。假设当前用户的本地缓存里面没有我们网站的信息。
客户端
字段说明:
- Accepttext/html,application/xhtml+xml,application/xml;q=0.9,/;q=0.8 告诉服务器自己接收什么类型的文件,比如 html、xml 等文件
- Accept-Encoding:gzip,deflate 告诉服务器,浏览器自身支持 gzip、deflate 的压缩格式,可以发送这两种压缩格式的压缩包过来。
- Accept-Language:zh-cn,zh;q=0.5 告诉服务器自己接受中文
- Connection:keep-alive 保持长久连接 - Cookie:… 一般如果这个网站有 cookie 的话,浏览器也会并着请求一起发送过去。
- Host:zdlive.com:8080 就是我们服务器的主机地址了 - User-Agent:Mozilla/5.0 … 对于这个字段,表明自己的浏览器类型,每个字段都有特殊的含义,可以用来做机型适配等工作。http://www.useragentstring.com/index.php
服务端
当服务器收到客户端的请求后,返回相关的相应头。
对应的字段简单说下:
- Connection:keep-Alive 保持传输 http 数据的 tcp 连接,当客户端再次向服务器请求的时候,沿用这个连接。 - Content-Type:text/html 告诉服务器返回的文件类型是什么,这里我们看到是 html 类型。
- Date:wed,… 这里是标识请求访问的日期。
- Keep-Alive: timeout=5,max=100 5 秒后无连接的话就会关闭连接,最大连接次数 100 次
- Server:Apache… 告诉客户端,服务器的类型,运行脚本的类型,这里是 Apache,还有 php
- Transfer-Encoding:chunked 表示 Content Body 将用 Chunked 编码传输内容。
这样一个来回后,用户请求的页面文档已经下载到本地了,然后其他组件才开始下载。
大家可以看到,在这里,初次访问时,浏览器都是发起 GET 请求,服务器对请求的资源进行相应,返回 200 标识资源请求成功。
好了,那当用户第 2 次访问我们网页的时候,会发生什么事情呢?慢着,这里面要分情况了。
假设我们在 firefox 里面,而且我们的服务器,比如 Apache 配置了静态文件压缩、对 Expires 的支持等。
firefox 浏览器刷新的方式共有 3 种,分别是 Ctrl+F5,F5,“转到” 三种,而这 3 种刷新方式,都对应着不同的缓存协议。 - Ctrl+F5 的方式,强制浏览器刷新,所有组件全部重新请求,忽略和服务端的缓存协议,即忽略 expires 头,last modified 等。
看到 200 状态码和大小了吗,这里浏览器还是从服务器那里重新下载了所有的资源,完全忽视了本地的缓存。 - F5 的方式,使用缓存协议,如果存在 last modified 的话,向服务器发起 if since modified 的 get 请求,对于动态内容,服务器需要做对应的请求响应。而静态内容,可以在 Apache 的配置文件里,写上对应的文件类型即可。
- “转到” 方式,即一般在 url 输入栏里输入地址,回车进行访问,这个时候,浏览器会对请求文件使用更加完善的缓存协议,包括检查 f5 的方式之外,还会检测文件的 expires 属性,如果当前时间仍在 expires 允许的范围内的话,就不会发起 get 请求,直接使用缓存的内容。
上面的 2、3 的请求和响应动作,其实都是基于服务器的主动缓存工作,这块内容有很多,书中提到的几点是针对静态内容的缓存: - 增加 last modified 头,这样能让浏览器发起一个条件 get 请求,减少重复的下载。
- 因为 last modified 头只是指定一个固定时间,而且存在客户端和服务器时钟同步的问题,针对这种情况,http1.1 提出了 max-age 和 mod_expires 的概念, max-age 会在组件被请求时间的基础上进行叠加。
通常形式如下:Cache-Control:max-age=315370000
而在 Apache 中,可以这样配置 Expires:
<ifModule mod_expires.c>ExpiresActive onExpiresByType img/png "access plus 24 hour"ExpiresByType text/css "now plus 2 day"ExpiresByType text/x-javascript "now plus 2 day"ExpiresByType text/html "now plus 1 day"ExpiresDefault "now plus 1 day"</ifModule>
而对于动态内容,其实我们也可以做类似这样的缓存操作,比如在 php 中,我们可以让内容在 1 个小时内保持有效,代码如下:
<?php$modified_time=$_SERVER[‘HTTP_IF_MODIFIED_SINCE’];If(strtotime($modified_time)+3600>time()){ Header(“HTTP/1.1 304”); exit(1);}header(“Last-Modified:”.gmdate(“D,d M Y H:i:s).” GMT”);?>
使用上面的缓存技术一个坏处就是如果我们想更新的时候,用户的缓存可能还没过期。
对于更新静态缓存,作者提出了修改文档组件名字的方式,具体还是要结合我们自己的实践。
压缩组件
目前采用两种方式,一种是对文件本身进行 “瘦身”,另外一种就是在服务器端开启 mod_gzip 模块,对特定类型的文件进行压缩,比如我们现在要用到的 css、js 和 html 文件。
- 针对 js 文件,目前是采用 YUI 的压缩工具,从压缩效率来看,比 JSmin 要高。
- 针对 css 文件,也是采用 YUI 的压缩工具,比其他的压缩工具优秀的地方在于,它把所有的代码整理成一行,而其他的压缩工具都是多行,在大小上可以节省不少。 压缩工具地址: 传送门
- 服务器端的 gzip 压缩可以参考服务器的配置文档。
将样式表放在顶部
对于 IE6-8,如果我们把样式表放在页面底部的话,浏览器会一直等待,直到底部的样式文件都被下载下来了,才会显示页面出来。这段时间,用户只能看着白屏,然后看到页面瞬间跑出来。这个原因是因为,ie 浏览器的渲染引擎认为,如果你的样式文件放在底部,我一开始就渲染会浪费资源,不如等到你的样式文件都下到本地了,我再渲染也不迟。
而另外一个原因是因为,其他非 ie 的浏览器,在底部的样式文件下载下来之前,会进行页面的渲染工作,假设这样一种情况,我们的全局样式文件 100s 之后才下载下来,这个时候,其实页面早就显示了,只是很丑陋,没有任何样式,这个是页面逐步呈现的结果。
针对这两种情况,作者才这样建议把样式放在顶部,既避免丑陋的页面出现,又能利用页面默认逐步呈现出来的效果,给到用户一种页面在加载的感觉,而这样的视觉提示是非常有用的,可以缓解用户等待的焦虑感。
关于页面从请求成功,到渲染完毕,这个过程有点复杂,不过可以参考如下几篇文章和书籍:
将脚本放在底部
将脚本放在代码底部的原因是因为,js 代码的运行,会阻塞后续资源的请求。而这个也是浏览器干的好事。现代浏览器认为,你的 js 代码里面可能有些代码会重写页面的 DOM,比如 $(“download_img”).remove ();,那么如果浏览器在你运行 js 脚本的同时去请求资源的话,有可能你就改掉了那个 img 文件了,这样浏览器就不会去请求资源,而是等到 js 执行完毕之后再去进行这个操作。
对于这种情况,玉伯总结过一些规律,如下:
- JS 有可能会修改 DOM. 典型的,可能会有 document.write. 这意味着,在当前 JS 加载和执行完成前,后续所有资源的下载有可能是没必要的。这是 JS 阻塞后续资源下载的根本原因。
- JS 的执行有可能依赖最新样式。比如,可能会有 var width = $(‘#id’).width (). 这意味着,JS 代码在执行前,浏览器必须保证在此 JS 之前的所有 css(无论外链还是内嵌)都已下载和解析完成。这是 CSS 阻塞后续 JS 执行的根本原因。
- 现代浏览器很聪明,会进行 prefetch 优化。性能是如此重要,现代浏览器在竞争中,在 UI update 线程之外,还会开启另一个线程,对后续 JS 和 CSS 提前下载(注意,仅提前下载,并不执行)。有了 prefetch 优化,这意味着,在不存在任何阻塞的情况下,理论上 JS 和 CSS 的下载时机都非常优先,和位置无关。
现代的浏览器,比如 firefox,其实会对页面的组件提前进行下载 prefetch,而不管这个文件是在什么一个位置。当然了,只是 firefox 这样做了,其他浏览器还有待测试,只是通过这点可以知道,书是不可靠的 =.=~,动手测试才是硬道理!
避免 CSS 表达式
Css 表达式是 ie 系列特有的,它会调用 js 里面的函数,从而达到动态样式的效果,不过有很多 bug,所以作者说避免使用 css 表达式。
使用外部 js 和 css 文件
一般我们都会把 html 文档里面的,js 和 css 文件用标签加载进来,这样的好处是,代码容易管理,但坏处是,如果这些文件非常多的话,一个页面就会发起多个 http 请求,对于网络不稳定的 GPRS,多个 http 请求意味着有可能丢失,或者请求失败这些情况。
如何平衡好这些分离的问题,可以从下面三个标准去看。
- 每个用户会话的页面查看次数。如果用户每次查看的页面数量多的话,可以考虑把文件 js 和 css 文件抽离出来,让浏览器进行缓存。
- 组件重用的程度。每个页面的 js 和 css 文件都有重叠的部分,对于重用率高的代码抽离出来,可以让所有页面共享浏览器缓存。但这种行为要视乎用户访问我们页面的行为,如果每次会话,跨页访问的频率高的话,就可以这样做。当然了,还有其他的一些情况。
- 用户访问我们页面的空缓存和完整缓存的比例。Yahoo 的开发团队做个一个测试,就是用户访问 Yahoo 首页的时候,用户浏览器的 cache 情况,结果如下图:
这个图里面,单看黄色线,发现大部分用户访问的时候,cache 都是空的。好了,如果是针对这种情况,那加快首页速度的方法就是使用内联的样式表和脚本了。当然,为了减少整体页面大小,可以对内联的脚本和 css 文件再进行压缩。
减少 DNS 查找
DNS 查询的意思是,当我们输入 zdworks.com 的时候,这个域名需要被解析成 ip 地址,请求才能到达我们的服务器,而这个去查询域名 ip 地址的时间,是需要时间的。
对于这种情况,浏览器和 pc 都有缓存 dns 记录的习惯,而我们可以做的就是增加我们域名在用户的两个缓存记录里面的 ttl 值,ttl 即 “生存时间(Time To Live)”。 ## 精简 js 就是我们之前说到的用 JSMIN 和 YUI 的压缩工具进行压缩了。
避免重定向
重定向会延缓用户打开页面的时间,应该尽量减少。书中提到一点很重要,缺少结尾的斜线,这样的域名会导致重定向,而这个不是我们预期的。
比如,m.clock.zdworks.com/index 会引起服务器的重定向,重新定位到 m.clock.zdworks.com/index/,然后根据我们的配置,掉落到 index.html.
移除重复脚本
作者提到在大型项目这样的情况可能会比较多,重复脚本对性能的影响也是有的。处理的方式很多,作者提到的方式是在后台,采用函数去判定加载到当前页面的 js 文件是否已经存在。
配置 Etag
Etag 是实体标签 (Entity Tag,ETag) 是服务器和浏览器用于确认缓存组件的有效性的一种机制。
在使用和配置上面还是有许多问题,详细还是要看下配置文档。
使用 Ajax 缓存
对 Ajax 请求的资源,返回 Expires 等 http 头,让浏览器缓存数组。在 zdlive 的首页中,就是通过在页面加载完毕后,发起异步请求,把图片先加载下来,最后再插入到页面中的。而在这个插入的过程中,是给了 Img.src=”” 赋值,而这个动作非常关键,因为一旦执行,页面就会发起 http 请求,这个时候,因为之前 ajax 请求的图片资源带有 expires 头,浏览器就会去判断当前时间是否超过了图片的 expires 头规定的时间,如果没超过的话,就利用缓存的数据,从达到异步请求的效果。
这方面的书籍也很多,推荐《Ajax in action》、《Head.First.Ajax》.
页面组件分离,分摊到多个域名
浏览器为了保护用户的正常使用,对单个页面的并行下载数量是有限制的。比如在桌面版的 firefox,并行下载数量就是 8 个,当然这个我们可以通过 about:config 那边去进行设置。
对于这种限制,我们可以通过把组件分离,分发到不同的域名服务器下面,从而绕过这个限制。当然了,这个对于静态文件还是比较好做的,但对于动态生成的文件就不好弄了。
HTTP 降级
HTTP1.1 中建议主机最大并行下载 4 个组件,而 HTTP1.0 协议的话,好像不存在这样的情况。那我们要做的就是,对 http1.1 的请求进行降级,绕过这个协议的限制。