# 高性能网站建设指南

只有 10%~20% 的最终用户相应时间花在了下载 HTML 文档上。其余的 80%~90% 时间花在了下载也米娜中所有组件上。 —— 性能黄金法则

# 减少 HTTP 请求

图片地图(Image Map):允许在一个图片上关联多个超链接,目标 URL 取决于用户点击了图片上的哪个位置。

图片地图包含两种类型:

  • 服务器图片地图:将所有的点击提交到同一个目标 URL,并向其传递点击的横纵坐标,应用程序针对坐标映射到适当的操作。
  • 客户端图片地图:将用户的点击直接映射到适当的操纵,映射实现可以借助 HTML 的 Map 标签。

CSS 雪碧图(CSS Sprites):将多个图片合并到一个单独的图片,然后使用 CSS 的 background-position 属性来将背景图片放置到期望的位置。

内联图片:通过使用 data:URL 模式可以在 Web 页面中包含图片但无需额外的 HTTP 请求。

  • 由于内联在页面中,在跨越不同页面时不会被缓存。

合并脚本和样式表:通常来说,使用外部脚本和样式表对性能更有利,但是拆分成太多的文件也会增加请求量,所以合理的合并一些文件会缩短最终用户相应时间。

# 使用内容发布网络

内容发布网络(CDN)是一组分布在不同地理位置的 Web 服务器,用于更加有效地想用户发布内容。

无论如何不要使用 HTTP 重定向来将用户指向到本地服务器,这会使 Web 页面反应速度变慢。

# 添加 Expires 头

浏览器使用缓存来减少 HTTP 请求的数量,并减少 HTTP 相应的大小,使得 Web 页面的加载更快。

服务器可以返回一个 Expires 字段(表示组件的过期时间),浏览器将其和相应的组件一起保存到缓存中,如果没有过期就不会在为此发起请求。

Expires: Sat, 04 Aug 2029 13:24:00 GMT

一旦过期,就可以更新组件并返回一个新的过期日期。

# Max-Age 和 mode_expires

另一种方式,在 HTTP1.1 中提供了 Cache-Control 头通过 max-age 指令以秒为单位来指定组件应该被缓存多久。

Cache-Control: max-age=315360000

如果从组件被请求开始过去的描述少于指定的时间,那么浏览器就使用缓存的版本。

它突破了 Expires 需要客户端和服务端时钟严格同步的限制,如果两者同时指定的话,后者将被覆盖。

通过使用 Expires-Default 指令可以让 Expires 头像 max-age 那样以相对的方式设置日期,这也是跨浏览器改善缓存的最佳方案。

  • HTML 文档不应该使用长久的 Expires 头,因为它包含了动态内容,这些内容在每次用户请求时都将被更新。
  • 在理想情况下,页面中所有的组件都应该具有长久的 Expires 头。
  • 如果一些组件经常更新而无法设置缓存,期望可以看到 Last-Modified 日期。
Last-Modified: Mon, 07 Nov 2016 07:51:11 GMT

# 修订文件名

当我们为组件设置了长期缓存期间想要更新时,怎么做呢?在所有 HTML 中修改组件的文件名。

修改文件名也就修改了其链接,这样,全新的请求就会从服务器中下载最新的内容。为此,给组件的文件名设置为变量是一种不错的实现——比如在生成过程中将版本号嵌入到组件名中。

嵌入版本号不仅可以改变文件名,还能在调试时更容易准确的找到源代码文件。

# 压缩组件

HTTP1.1 开始浏览器可以通过 Accept-Encoding 头来表示对压缩的支持。

Accept-Encoding: gzip, deflate

服务器接受到信息后通过 Content-Encoding 来通知客户端采用的压缩方式。

Content-Encoding: gzip

图片和 PDF 不应该被压缩,因为它们已经被压缩过了,继续压缩只会浪费 CPU 资源,甚至适得其反。

# 代理缓存

在使用代理的情况下,如果一个不支持解压缩的浏览器请求了代理服务器,那么代理服务器会将从 Web 服务器获取的内容进行缓存,然后将内容返回给客户端浏览器,当第二个支持解压缩的浏览器请求代理服务器时(同一个 URL),他将会得到之前缓存的结果,而不管它是否支持解压缩。

反过来,如果第一个发起请求的是支持解压缩的浏览器,那么缓存的结果会是压缩过的,那么后续不支持解压缩的浏览器请求代理服务器时也会得到缓存的压缩后的内容,这就悲剧了。

为了解决上面的问题,就是在 Web 服务器的响应中添加 Vary 头,其告诉代理根据一个或多个请求头来改变缓存的响应。

因为压缩的决定是居于 Accept-Encoding 请求头的,所以在设置 Vary 头时要将其包含。

Vary: Accept-Encoding

这样,代理会缓存多个版本,并根据 Accept-Encoding 头的值进行返回相应的内容。

使用 Vary: * 或 Cache-Control: private 会为所有浏览器设置禁用代理缓存。

# 将样式表放在顶部

将样式表放在底部时,浏览器为避免当样式变化时重绘顶部的元素,会阻塞内容逐步呈现。为了避免 “白屏”,需将样式放在文档顶部的 HEAD 中。

@import 放置在顶部也会出现白屏,因为它的加载顺序并非是它出现的位置,因此应当避免使用。

白屏(延迟呈现,直到所有的样式表都下载完成,期间可能呈现白屏)是浏览器对 FOUC 问题(样式表放在底部时,页面逐步加载显示,在样式表加载完成后就会导致重绘)的弥补。

# 将脚本放在底部

在 HTTP1.1 的规范中,建议浏览器从每个主机名并行地下载两个组件。因此也可以使用 DNS 来将组件分别放到多个主机名中以加快速度,但并非越多越好。

加载脚本时,对于所有位于脚本之下的内容,逐步呈现将被阻塞,也就是说脚本阻塞了并行下载。

事实上,在加载脚本时并行下载是被禁用的:

  • 脚本可能会修改页面的内容,浏览器需要等待以确保恰当的渲染。
  • 保证脚本按照正确的顺序执行。

放置脚本的最佳位置就是页面的底部,也就是在 body 闭合标签前。

# 避免使用 CSS 表达式

在低版本的 IE 浏览器中支持在 CSS 中使用 JavaScript 表达式来设置样式的值。

通常,其中表达式的的计算频率远远大于人们的预期,导致性能下降,有时候更会影响页面的加载时间。

以往前辈们使用一次性表达式的方式来避免这样的问题,表达式只在第一次进行计算,然后将计算结果赋予给表达式所在属性来进行重写。

结合事件处理器,我们可以通过 resize 等事件来设置恰当的样式,以避免成千上万次不必要的求值。

配合节流或防抖函数来进一步改善性能。

总之,使用表达式是很危险的,最好不要。

# 使用外联的 JavaScript 和 CSS

在样式表和脚本内容较少时,使用外联的脚本会增加额外的 HTTP 请求;而过多的内容会导致一个 HTTP 请求的数据流过大,使用外联的方式则可以让 JavaScript 和 CSS 有机会被浏览器缓存起来。

通常,每个用户产生的页面浏览量越好,内联 JavaScript 和 CSS 的优势就比较强;而使用外部文件带来的收益则是会随着用户每月的页面浏览量或每用户没会话产生的浏览量的增长而增加。

如果你的网站本质上能够为每个用户带来高完整缓存,使用外部文件的收益会更好。

如果你的多个页面使用了相同的 JavaScript 和 CSS,在非极端的情况下,将你的页面分成几种页面类型,然后为每种类型创建单独的脚本和样式表更好。

# 加载后加载

通过 onload 事件,我们可以为一个页面(通常是首页)内联 JavaScript 和 CSS,同时又能为后续页面浏览提供外部文件。

在页面呈现完毕之后(可以在 onload 事件处理器上再加上一定的延迟),通过创建对应的 DOM 元素(script、link)并赋予指定的 URL 来实现。

# 动态内联

服务器通过 cookies 存在与否,自动在内联或外联文件直接做出选择。

当用户第一次访问页面时,服务器发现没有 cookie,于是生成一个内联了组件的页面,然后服务器添加 JavaScript 来在页面加载后动态下载外部文件(并设置 cookie)。

再一次访问页面时,服务器看到了 cookie 就会生成一个使用外部组件的页面。

# 减少 DNS 查找

DNS 查找可以被缓存起来以提高性能。

DNS 缓存可以发生在 ISP 或局域网中一台特殊的缓存服务器上,而就发生在独立用户计算机上的缓存而言,用户请求一个主机名之后,NDS 信息就会留在操作系统的 DNS 缓存中。

通常来将,当用户请求一个主机名时:

  1. 浏览器会先检查自己的 DNS 缓存,它和操作系统的 DNS 缓存相隔离
  2. 操作系统查看自身缓存回应
  3. 查找本机的 hosts 文件
  4. 路由器缓存
  5. ISP DNS 服务器(检查缓存、是否配置了转发)
  6. 根服务器 ...

最后,请求返回的信息不仅包括了 IP 地址,还有一个 TTL 值,指定了客户端可以对该记录缓存多久(浏览器通常会忽略该值进行自定义,而且 keep-alive 字段也会覆盖 TTL 和浏览器自己设置的限制)。

减少唯一主机名可以减少 DNS 查找当也会潜在减少并行下载的数量,一个好的权衡是将组件放在至少两个但不超过四个的主机下。

# 精简 JavaScript

精简是从代码中移除不必要的字符(包括空白字符和注释等)以减少其大小,进而改善加载时间的实践。

和精简相似的一种处理就是混淆,后者不但精简了代码,还会改写代码以达到抵制反向工程的目的,比如将函数名和变量名转换为更短的字符。

精简和混淆一样可以很好的工作,再经过 gzip 压缩后两者之间的差距将会很小,不过前者不会带来混淆的风险(混淆修改代码可能会出错)。

能够精简的不仅是 JavaScript 还包括 CSS,而且更好的做法还可以是优化 CSS(比如合并相同的类、移除不使用的类等)。

# 避免重定向

重定向是将用户从一个 URL 重新路由到另一个 URL(比如常见的 301、302)。

当 Web 服务器向浏览器返回一个重定向时,响应中会包括一个范围在 3XX 的状态码和一个 Location 头,浏览器会自动将用户带到这个字段所给出的 URL。

特别是将重定向用到初始页面时,重定向将会严重延迟页面渲染,因为在 HTML 文档到达之前,用户看不到任何内容。

缺少结尾斜线是一种典型的重定向浪费(主机名后面缺少斜杠不会产生重定向-浏览器在进行 GET 请求时必须指定路径,如果没有将会简单使用文档根(/))。

另外,产生重定向的可能还包括连接网站、跟踪内部流量、跟踪出站流量、美化 URL 等,不过它们都有可以替代的方案。

# 删除重复脚本

导致脚本重复有两大因素:团队大小和脚本数量。

重复脚本会增加 HTTP 请求数量和脚本执行事件。

避免重复脚本的一种方法是,在你的模板系统中实现一个脚本管理模板。

# 配置 ETag

实体标签(Entity Tag, Etag),是 Web 服务器和浏览器用于确认缓存组件有效性的一种机制,在 HTTP1.1 中引入,是一个标识了一个组件的一个特定版本的字符串

当设置了 Expires 头之后,在缓存期间浏览器会直接从磁盘中读取内容,而当缓存过期后,浏览器在重用它之前会检查它是否有效,这称作为一个条件 GET 请求

服务器接受到确认请求后进行确认,如果没有发生改变则会返回一个状态码为 304 的响应。确认的方式包括:比较最新修改时间或比较实体标签。

对于 Etag 而言,服务器先针对一个组件在返回的响应头中添加了 Etag 头,在后面浏览器验证一个组件时会使用 If-None-Match 头将 Etag 传回给服务器,如果匹配则返回 304。

通常,我们会使用组件的某些属性来构造它,这些属性对于特定的、寄宿了网站的服务器来说是唯一的。当浏览器从一个服务器获取组件后,又向另一个服务器发起条件 GET 请求,此时 Etag 是不会匹配的。

(If-None-Match 的优先级高于 If-Modifid-Since)

# 使 Ajax 可缓存

Ajax 将 Web 体验从“浏览页面”转变为了“与应用程序进行交互”,它在 UI 和 Web 服务器之间添加了一层,参与与服务器进行交互以获得请求的信息,然后再以此来更新那些必要的组件,而不必重绘整个页面。

使用 Cache-Control: no-store 这个头之后,响应就不会被写入磁盘中(恶意的缓存可能不会遵守该规定,更好的方法是使用安全通信协议,如:SSL)。

# 备注

  • 客户端可以发送 Connection 字段来请求保持链接,如果服务器支持则可以返回相应的字段,最后两方都可以发送 Connection: close 头来关闭链接。
  • 客户端发送 If-Modifid-Since 字段进行确认从指定日期后是否有更改,如果没有改变,服务端返回一个 “304 Not Modified”,并包含有 Last-Modified 字段。

此文为 高性能网站建设指南 (opens new window) 的读书笔记。

# 扩展阅读