性能优化有多重要? 国外有很多机构对此做过调查,发现网站的性能对于用户的留存率、转化率有很大的影响,而且非常直接的说提高网站性能就是提高收入。

由于性能优化涉及的知识很多,即使把已经过时的性能优化规则摒弃掉,也有不少的内容。所以我将会用两章的内容来讲解如何做性能优化。

# 性能优化分类

性能优化主要分为两类:

  1. 加载时优化
  2. 运行时优化

例如压缩文件、使用 CDN 就属于加载时优化;减少 DOM 操作,使用事件委托属于运行时优化。

在解决问题之前,必须先找出问题,否则无从下手。所以在做性能优化之前,最好先调查一下网站的加载性能和运行性能。

# 手动检查

# 检查加载性能

一个网站加载性能如何主要看白屏时间和首屏时间。

  • 白屏时间:指从输入网址,到页面开始显示内容的时间。
  • 首屏时间:指从输入网址,到页面完全渲染的时间。

# 检查运行性能

配合 chrome 的开发者工具,我们可以查看网站在运行时的性能。

打开网站,按 F12 选择 performance,点击左上角的灰色圆点,变成红色就代表开始记录了。这时可以模仿用户使用网站,在使用完毕后,点击 stop,然后你就能看到网站运行期间的性能报告。如果有红色的块,代表有掉帧的情况;如果是绿色,则代表帧率高,页面很流畅。

另外,在 performance 标签下,按 ESC 会弹出来一个小框。点击小框左边的三个点,把 rendering 勾出来。

这两个选项,第一个是高亮重绘区域,另一个是显示帧渲染信息。把这两个选项勾上,然后浏览网页,可以实时的看到你网页渲染变化。

# 利用工具检查

# 监控工具

可以部署一个前端监控系统来监控网站性能,上一章中讲到的 sentry 就属于这一类。

# chrome 工具 Lighthouse

如果你安装了 Chrome 52+ 版本,请按 F12 打开开发者工具。

它不仅会对你网站的性能打分,还会对 SEO 打分。

使用 Lighthouse 审查网络应用 (opens new window)

一般来说,如果你觉得应用加载或运行时有稍微的卡顿,就可以考虑做性能优化了。

好了,下面正式开始讲解如何做性能优化,一共有 23 条性能优化规则。

# 加载时性能优化 10 条规则

# 1. 减少 HTTP 请求

一个完整的 HTTP 请求需要经历 DNS 查找,TCP 握手,浏览器发出 HTTP 请求,服务器接收请求,服务器处理请求并发回响应,浏览器接收响应等过程。现在我们来看一个具体的 HTTP 示例:

在这里插入图片描述

这是一个 HTTP 请求,请求的文件大小为 28.4KB。

名词解释:

  • Queueing: 在请求队列中的时间。
  • Stalled: 从 TCP 连接建立完成,到真正可以传输数据之间的时间差,此时间包括代理协商时间。
  • Proxy negotiation: 与代理服务器连接进行协商所花费的时间。
  • DNS Lookup: 执行 DNS 查找所花费的时间,页面上的每个不同的域都需要进行DNS查找。
  • Initial Connection / Connecting: 建立连接所花费的时间,包括TCP握手/重试和协商SSL。
  • SSL: 完成 SSL 握手所花费的时间。
  • Request sent: 发出网络请求所花费的时间,通常为一毫秒的时间。
  • Waiting(TFFB): TFFB 是发出页面请求到接收到应答数据第一个字节的时间。
  • Content Download: 接收响应数据所花费的时间。

从这个例子可以看出,真正下载数据的时间占比为 13.05 / 204.16 = 6.39%,文件越小,这个比例越小,文件越大,比例就越高。这就是为什么要建议将多个小文件合并为一个大文件,从而减少 HTTP 请求次数的原因。

# 2. 使用 HTTP2

HTTP2 相比 HTTP1.1 有如下几个优点:

# 解析速度快

服务器解析 HTTP1.1 的请求时,必须不断地读入字节,直到遇到分隔符 CRLF 为止。而解析 HTTP2 的请求就不用这么麻烦,因为 HTTP2 是基于帧的协议,每个帧都有表示帧长度的字段。

# 多路复用

HTTP1.1 如果要同时发起多个请求,就得建立多个 TCP 连接,因为一个 TCP 连接同时只能处理一个 HTTP1.1 的请求。

在 HTTP2 上,多个请求可以共用一个 TCP 连接,这称为多路复用。同一个请求和响应用一个流来表示,并有唯一的流 ID 来标识。 多个请求和响应在 TCP 连接中可以乱序发送,到达目的地后再通过流 ID 重新组建。

# 首部压缩

HTTP2 提供了首部压缩功能。

例如有如下两个请求:

:authority: unpkg.zhimg.com
:method: GET
:path: /za-js-sdk@2.16.0/dist/zap.js
:scheme: https
accept: */*
accept-encoding: gzip, deflate, br
accept-language: zh-CN,zh;q=0.9
cache-control: no-cache
pragma: no-cache
referer: https://www.zhihu.com/
sec-fetch-dest: script
sec-fetch-mode: no-cors
sec-fetch-site: cross-site
user-agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.122 Safari/537.36
:authority: zz.bdstatic.com
:method: GET
:path: /linksubmit/push.js
:scheme: https
accept: */*
accept-encoding: gzip, deflate, br
accept-language: zh-CN,zh;q=0.9
cache-control: no-cache
pragma: no-cache
referer: https://www.zhihu.com/
sec-fetch-dest: script
sec-fetch-mode: no-cors
sec-fetch-site: cross-site
user-agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.122 Safari/537.36

从上面两个请求可以看出来,有很多数据都是重复的。如果可以把相同的首部存储起来,仅发送它们之间不同的部分,就可以节省不少的流量,加快请求的时间。

HTTP2 在客户端和服务器端使用“首部表”来跟踪和存储之前发送的键-值对,对于相同的数据,不再通过每次请求和响应发送。

下面再来看一个简化的例子,假设客户端按顺序发送如下请求首部:

Header1:foo
Header2:bar
Header3:bat

当客户端发送请求时,它会根据首部值创建一张表:

索引 首部名称
62 Header1 foo
63 Header2 bar
64 Header3 bat

如果服务器收到了请求,它会照样创建一张表。 当客户端发送下一个请求的时候,如果首部相同,它可以直接发送这样的首部块:

62 63 64

服务器会查找先前建立的表格,并把这些数字还原成索引对应的完整首部。

# 优先级

HTTP2 可以对比较紧急的请求设置一个较高的优先级,服务器在收到这样的请求后,可以优先处理。

# 流量控制

由于一个 TCP 连接流量带宽(根据客户端到服务器的网络带宽而定)是固定的,当有多个请求并发时,一个请求占的流量多,另一个请求占的流量就会少。流量控制可以对不同的流的流量进行精确控制。

# 服务器推送

HTTP2 新增的一个强大的新功能,就是服务器可以对一个客户端请求发送多个响应。换句话说,除了对最初请求的响应外,服务器还可以额外向客户端推送资源,而无需客户端明确地请求。

例如当浏览器请求一个网站时,除了返回 HTML 页面外,服务器还可以根据 HTML 页面中的资源的 URL,来提前推送资源。

现在有很多网站已经开始使用 HTTP2 了,例如知乎:

在这里插入图片描述

其中 h2 是指 HTTP2 协议,http/1.1 则是指 HTTP1.1 协议。

# 3. 使用服务端渲染

客户端渲染: 获取 HTML 文件,根据需要下载 JavaScript 文件,运行文件,生成 DOM,再渲染。

服务端渲染:服务端返回 HTML 文件,客户端只需解析 HTML。

  • 优点:首屏渲染快,SEO 好。
  • 缺点:配置麻烦,增加了服务器的计算压力。

下面我用 Vue SSR 做示例,简单的描述一下 SSR 过程。

# 客户端渲染过程

  1. 访问客户端渲染的网站。
  2. 服务器返回一个包含了引入资源语句和 <div id="app"></div> 的 HTML 文件。
  3. 客户端通过 HTTP 向服务器请求资源,当必要的资源都加载完毕后,执行 new Vue() 开始实例化并渲染页面。

# 服务端渲染过程

  1. 访问服务端渲染的网站。
  2. 服务器会查看当前路由组件需要哪些资源文件,然后将这些文件的内容填充到 HTML 文件。如果有 ajax 请求,就会执行它进行数据预取并填充到 HTML 文件里,最后返回这个 HTML 页面。
  3. 当客户端接收到这个 HTML 页面时,可以马上就开始渲染页面。与此同时,页面也会加载资源,当必要的资源都加载完毕后,开始执行 new Vue() 开始实例化并接管页面。

从上述两个过程中可以看出,区别就在于第二步。客户端渲染的网站会直接返回 HTML 文件,而服务端渲染的网站则会渲染完页面再返回这个 HTML 文件。

这样做的好处是什么?是更快的内容到达时间 (time-to-content)

假设你的网站需要加载完 abcd 四个文件才能渲染完毕。并且每个文件大小为 1 M。

这样一算:客户端渲染的网站需要加载 4 个文件和 HTML 文件才能完成首页渲染,总计大小为 4M(忽略 HTML 文件大小)。而服务端渲染的网站只需要加载一个渲染完毕的 HTML 文件就能完成首页渲染,总计大小为已经渲染完毕的 HTML 文件(这种文件不会太大,一般为几百K,我的个人博客网站(SSR)加载的 HTML 文件为 400K)。这就是服务端渲染更快的原因

# 4. 静态资源使用 CDN

内容分发网络(CDN)是一组分布在多个不同地理位置的 Web 服务器。我们都知道,当服务器离用户越远时,延迟越高。CDN 就是为了解决这一问题,在多个位置部署服务器,让用户离服务器更近,从而缩短请求时间。

# CDN 原理

当用户访问一个网站时,如果没有 CDN,过程是这样的:

  1. 浏览器要将域名解析为 IP 地址,所以需要向本地 DNS 发出请求。
  2. 本地 DNS 依次向根服务器、顶级域名服务器、权限服务器发出请求,得到网站服务器的 IP 地址。
  3. 本地 DNS 将 IP 地址发回给浏览器,浏览器向网站服务器 IP 地址发出请求并得到资源。

如果用户访问的网站部署了 CDN,过程是这样的:

  1. 浏览器要将域名解析为 IP 地址,所以需要向本地 DNS 发出请求。
  2. 本地 DNS 依次向根服务器、顶级域名服务器、权限服务器发出请求,得到全局负载均衡系统(GSLB)的 IP 地址。
  3. 本地 DNS 再向 GSLB 发出请求,GSLB 的主要功能是根据本地 DNS 的 IP 地址判断用户的位置,筛选出距离用户较近的本地负载均衡系统(SLB),并将该 SLB 的 IP 地址作为结果返回给本地 DNS。
  4. 本地 DNS 将 SLB 的 IP 地址发回给浏览器,浏览器向 SLB 发出请求。
  5. SLB 根据浏览器请求的资源和地址,选出最优的缓存服务器发回给浏览器。
  6. 浏览器再根据 SLB 发回的地址重定向到缓存服务器。
  7. 如果缓存服务器有浏览器需要的资源,就将资源发回给浏览器。如果没有,就向源服务器请求资源,再发给浏览器并缓存在本地。

# 5. 将 CSS 放在文件头部,JavaScript 文件放在底部

  • CSS 执行会阻塞渲染,阻止 JS 执行
  • JS 加载和执行会阻塞 HTML 解析,阻止 CSSOM 构建

如果这些 CSS、JS 标签放在 HEAD 标签里,并且需要加载和解析很久的话,那么页面就空白了。所以 JS 文件要放在底部(不阻止 DOM 解析,但会阻塞渲染),等 HTML 解析完了再加载 JS 文件,尽早向用户呈现页面的内容。

那为什么 CSS 文件还要放在头部呢?

因为先加载 HTML 再加载 CSS,会让用户第一时间看到的页面是没有样式的、“丑陋”的,为了避免这种情况发生,就要将 CSS 文件放在头部了。

另外,JS 文件也不是不可以放在头部,只要给 script 标签加上 defer 属性就可以了,异步下载,延迟执行。

参考资料:

# 6. 使用字体图标 iconfont 代替图片图标

字体图标就是将图标制作成一个字体,使用时就跟字体一样,可以设置属性,例如 font-size、color 等等,非常方便。并且字体图标是矢量图,不会失真。还有一个优点是生成的文件特别小。

# 压缩字体文件

使用 fontmin-webpack (opens new window) 插件对字体文件进行压缩,可以更进一步的减小字体文件的大小。

# 7. 善用缓存,不重复加载相同的资源

为了避免用户每次访问网站都得请求文件,我们可以通过添加 Expires 或 max-age 来控制这一行为。Expires 设置了一个时间,只要在这个时间之前,浏览器都不会请求文件,而是直接使用缓存。而 max-age 是一个相对时间,建议使用 max-age 代替 Expires 。

  • max-age: 设置缓存存储的最大周期,超过这个时间缓存被认为过期(单位秒)。在这个时间前,浏览器读取文件不会发出新请求,而是直接使用缓存。
  • 指定 no-cache 表示客户端可以缓存资源,每次使用缓存资源前都必须重新验证其有效性。

不过这样会产生一个问题,当文件更新了怎么办?怎么通知浏览器重新请求文件?

可以通过更新页面中引用的资源链接地址,让浏览器主动放弃缓存,加载新资源。

具体怎么做呢?下面我简单的描述一下这个过程:

  1. 使用 webpack 打包时,配置 output 属性,使用摘要算法根据文件内容生成文件名。
  2. 假设现在 index.html 页面引用了两个文件,并且这两个文件名都是根据它们自己的内容生成的。
  3. 服务器将 index.html 文件设为 Cache-control: no-cache,而将其他的资源文件都设为长期缓存(例如一年)。这样每次页面请求的时候都会比对一下 index.html 文件有没变化,如果没变化就使用缓存,有变化就使用新的 index.html 文件。
  4. 当你的资源文件内容发生变化时,生成的文件名称也会发生变化。进而引发 index.html 内容变化(引用的资源名称变了)。
  5. 这时页面再请求,就会重新拉取已经发生变化的资源文件。

# 8. 压缩文件

压缩文件可以减少文件下载时间,让用户体验性更好。

得益于 webpack 和 node 的发展,现在压缩文件已经非常方便了。

在 webpack 可以使用如下插件进行压缩:

  • JavaScript:UglifyPlugin
  • CSS :CssMinimizerWebpackPlugin
  • HTML:HtmlWebpackPlugin

其实,我们还可以做得更好,那就是使用 gzip 压缩。可以通过向 HTTP 请求头中的 Accept-Encoding 添加 gzip 标识来开启这一功能。当然,服务器也得支持这一功能。

gzip 是目前最流行和最有效的压缩方法。举个例子,我用 Vue 开发的项目构建后生成的 app.js 文件大小为 1.4MB,使用 gzip 压缩后只有 573KB,体积减少了将近 60%。

附上 webpack 和 node 配置 gzip 的使用方法。

下载插件

npm install compression-webpack-plugin --save-dev
npm install compression

webpack 配置

const CompressionPlugin = require('compression-webpack-plugin');

module.exports = {
  plugins: [new CompressionPlugin()],
}

node 配置

const compression = require('compression')
// 在其他中间件前使用
app.use(compression())

# 9. 图片优化

# (1). 图片延迟加载

在页面中,先不给图片设置路径,只有当图片出现在浏览器的可视区域时,才去加载真正的图片,这就是延迟加载。对于图片很多的网站来说,一次性加载全部图片,会对用户体验造成很大的影响,所以需要使用图片延迟加载。

首先可以将图片这样设置,在页面不可见时图片不会加载:

<img data-src="https://avatars0.githubusercontent.com/u/22117876?s=460&u=7bd8f32788df6988833da6bd155c3cfbebc68006&v=4">

等页面可见时,使用 JS 加载图片:

const img = document.querySelector('img')
img.src = img.dataset.src

这样图片就加载出来了,具体的代码实现请参考web 前端图片懒加载实现原理 (opens new window)

# (2). 响应式图片

响应式图片的优点是浏览器能够根据屏幕大小自动加载合适的图片。

通过 picture 实现

<picture>
	<source srcset="banner_w1000.jpg" media="(min-width: 801px)">
	<source srcset="banner_w800.jpg" media="(max-width: 800px)">
	<img src="banner_w800.jpg" alt="">
</picture>

通过 @media 实现

@media (min-width: 769px) {
	.bg {
		background-image: url(bg1080.jpg);
	}
}
@media (max-width: 768px) {
	.bg {
		background-image: url(bg768.jpg);
	}
}

# (3). 调整图片大小

例如,你有一个 1920 * 1080 大小的图片,用缩略图的方式展示给用户,并且当用户鼠标悬停在上面时才展示全图。如果用户从未真正将鼠标悬停在缩略图上,则浪费了下载图片的时间。

所以,我们可以用两张图片来实行优化。一开始,只加载缩略图,当用户悬停在图片上时,才加载大图。还有一种办法,即对大图进行延迟加载,在所有元素都加载完成后手动更改大图的 src 进行下载。

# (4). 降低图片质量

例如 JPG 格式的图片,100% 的质量和 90% 质量的通常看不出来区别,尤其是用来当背景图的时候。我经常用 PS 切背景图时, 将图片切成 JPG 格式,并且将它压缩到 60% 的质量,基本上看不出来区别。

压缩方法有两种,一是通过 webpack 插件 image-webpack-loader,二是通过在线网站进行压缩。

以下附上 webpack 插件 image-webpack-loader 的用法。

npm i -D image-webpack-loader

webpack 配置

{
  test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
  use:[
    {
    loader: 'url-loader',
    options: {
      limit: 10000, /* 图片大小小于1000字节限制时会自动转成 base64 码引用*/
      name: utils.assetsPath('img/[name].[hash:7].[ext]')
      }
    },
    /*对图片进行压缩*/
    {
      loader: 'image-webpack-loader',
      options: {
        bypassOnDebug: true,
      }
    }
  ]
}

# (5). 尽可能利用 CSS3 效果代替图片

有很多图片使用 CSS 效果(渐变、阴影等)就能画出来,这种情况选择 CSS3 效果更好。因为代码大小通常是图片大小的几分之一甚至几十分之一。

# (6). 使用 webp 格式的图片

webp 是一种新的图片文件格式,它提供了有损压缩和无损压缩两种方式。在相同图片质量下,webp 的体积比 png 和 jpg 更小。

# 10. 通过 webpack 按需加载代码,提取第三库代码,减少 ES6 转为 ES5 的冗余代码

懒加载或者按需加载,是一种很好的优化网页或应用的方式。这种方式实际上是先把你的代码在一些逻辑断点处分离开,然后在一些代码块中完成某些操作后,立即引用或即将引用另外一些新的代码块。这样加快了应用的初始加载速度,减轻了它的总体体积,因为某些代码块可能永远不会被加载。

# 根据文件内容生成文件名,结合 import 动态引入组件实现按需加载

通过配置 output 的 filename 属性可以实现这个需求。filename 属性的值选项中有一个 [contenthash],它将根据文件内容创建出唯一 hash。当文件内容发生变化时,[contenthash] 也会发生变化。

output: {
  filename: '[name].[contenthash].js',
  chunkFilename: '[name].[contenthash].js',
  path: path.resolve(__dirname, '../dist'),
},

# 提取第三方库

由于引入的第三方库一般都比较稳定,不会经常改变。所以将它们单独提取出来,作为长期缓存是一个更好的选择。 这里需要使用 webpack4 的 splitChunk 插件 cacheGroups 选项。

optimization: {
  	runtimeChunk: {
        name: 'manifest' // 将 webpack 的 runtime 代码拆分为一个单独的 chunk。
    },
    splitChunks: {
        cacheGroups: {
            vendor: {
                name: 'chunk-vendors',
                test: /[\\/]node_modules[\\/]/,
                priority: -10,
                chunks: 'initial'
            },
            common: {
                name: 'chunk-common',
                minChunks: 2,
                priority: -20,
                chunks: 'initial',
                reuseExistingChunk: true
            }
        },
    }
},
  • test: 用于控制哪些模块被这个缓存组匹配到。原封不动传递出去的话,它默认会选择所有的模块。可以传递的值类型:RegExp、String和Function;
  • priority:表示抽取权重,数字越大表示优先级越高。因为一个 module 可能会满足多个 cacheGroups 的条件,那么抽取到哪个就由权重最高的说了算;
  • reuseExistingChunk:表示是否使用已有的 chunk,如果为 true 则表示如果当前的 chunk 包含的模块已经被抽取出去了,那么将不会重新生成新的。
  • minChunks(默认是1):在分割之前,这个代码块最小应该被引用的次数(译注:保证代码块复用性,默认配置的策略是不需要多次引用也可以被分割)
  • chunks (默认是async) :initial、async和all
  • name(打包的chunks的名字):字符串或者函数(函数可以根据条件自定义名字)

# 减少 ES6 转为 ES5 的冗余代码

Babel 转化后的代码想要实现和原来代码一样的功能需要借助一些帮助函数,比如:

class Person {}

会被转换为:

"use strict";

function _classCallCheck(instance, Constructor) {
  if (!(instance instanceof Constructor)) {
    throw new TypeError("Cannot call a class as a function");
  }
}

var Person = function Person() {
  _classCallCheck(this, Person);
};

这里 _classCallCheck 就是一个 helper 函数,如果在很多文件里都声明了类,那么就会产生很多个这样的 helper 函数。

这里的 @babel/runtime 包就声明了所有需要用到的帮助函数,而 @babel/plugin-transform-runtime 的作用就是将所有需要 helper 函数的文件,从 @babel/runtime包 引进来:

"use strict";

var _classCallCheck2 = require("@babel/runtime/helpers/classCallCheck");

var _classCallCheck3 = _interopRequireDefault(_classCallCheck2);

function _interopRequireDefault(obj) {
  return obj && obj.__esModule ? obj : { default: obj };
}

var Person = function Person() {
  (0, _classCallCheck3.default)(this, Person);
};

这里就没有再编译出 helper 函数 classCallCheck 了,而是直接引用了 @babel/runtime 中的 helpers/classCallCheck

安装

npm i -D @babel/plugin-transform-runtime @babel/runtime

使用.babelrc 文件中

"plugins": [
        "@babel/plugin-transform-runtime"
]

# 参考资料