Serenader

Learning by sharing

页面的加载性能优化方案

为什么要进行性能优化?

在回答这个问题之前,先问问你自己,你会等待一个页面加载超过10秒吗?或者你会使用一个点击某个按钮需要几秒之后才会有响应的页面吗?我想大部分人应该不会。很明显人的耐心是有限的,如果页面的加载时间,页面的响应时间超出了他的耐心,那么他很大可能会选择离开这个页面。
这就是为什么要进行性能优化的原因了。性能优化就是让用户尽可能快地打开页面,尽可能流畅地使用页面。Google Developers 上的一篇文章:性能为何至关重要详细讲述了为什么性能优化这么重要。
当然性能优化已经是老生常谈的了,如何能够优化页面的性能,让用户更快地浏览页面,更流畅地消费页面,是每一个 Web 工程师都需要掌握的一门知识。
同时性能优化是一个非常庞大的话题,这里我也不可能通过一篇文章就能讲完。在这篇文章里面我主要会讲我实际使用过的,或者了解过的性能优化的方案。

前提

虽然说性能优化是 Web 开发人员的一门必修课,但是这并不意味着对于每个项目来说都要在每个方面都做到极致。在开始做优化之前,你要先做到以下两点:

先了解你的业务以及你的用户

这一步非常重要,它会决定你的优化方向和思路。业务的访问量级,目标用户群体,以及目标用户群体的设备情况和网络情况都是需要了解的。
访问量级通常能够决定当前是否需要进行优化。比如,如果当前业务的日 PV 只有不到100,那么这时候就算你做了非常多的优化手段,从数据来看可能得不出太多结论,因为本身量级太小,数据波动会特别大,这时候你就分辨不出数据的波动究竟是正常的波动还是说是因为优化带来的提升。就我目前的经验来看,当页面的日 PV 达到一万以上时,这时候数据就稍微稳定一些了,可以做适当优化。而如果是十万 PV ,或者百万 PV ,数据会更加趋向稳定,这时候如果你做了优化,并且有效果的话那么性能数据的波动就能够比较直接地反应优化的效果。以我的经验来看,在访问量比较小的时候,往往访问量的增加所带来的性能数据提升会比你在量级小的时候一直做优化所带来的数据提升要好很多。比如我们有个页面,平时日 PV 只有一万多,那时候做性能优化数据死活上不去。而后来某天访问量涨了一倍,到两万多时,性能数据一下子就好起来了。这是因为基数越大,数据就能更加体现出平均性能。
业务的目标用户也是非常重要的。毕竟说到底我们的页面是为了服务用户。要让用户更快地打开我们的页面,我们就需要让我们的页面尽可能部署在我们目标用户所在区域附近。假如你的目标用户是在美国,但是你的服务器搭建在中国,那么这时候访问速度肯定大打折扣。
除了需要知道用户的区域之外,还要了解用户所使用的设备,以及所使用的浏览器。用户是用安卓手机访问页面,还是iPhone,还是说都是用电脑访问。知道用户所使用的平台以及浏览器和版本之后,我们就可以知道我们可以在这些平台、浏览器上使用什么特性。
了解项目情况,以及用户情况都是为了给性能优化寻找方向,只要你掌握了这两点之后,在做优化时就能比较清楚地知道,哪种方案对于数据的提升是有正向效果的,哪种方案是没必要做的,这样才能最大化地得到性能提升。

寻找瓶颈,针对瓶颈做优化

除了要了解项目和用户之外,我们还需要了解项目目前存在的瓶颈。页面加载慢,究竟是慢在哪里,是服务器响应慢,还是资源下载慢,还是渲染得慢。找出瓶颈之后我们就知道优化的优先级,就可以针对瓶颈做定向优化,来解决性能问题。

如何衡量

快和慢这两个词,本身是非常主观的。有的人觉得只有一秒之内加载完页面才算快,而有的人觉得五秒内能够加载完页面就不算慢。为了衡量一个页面加载究竟是快还是慢,往往我们会制定一些指标,根据指标的数据情况来判断是快还是慢。
对于页面的加载性能来说,衡量的指标目前较为常见的有 First Paint,First Contentful Paint,First Meaningful Paint,以及页面 onload 时间等。这几个指标除了 First Meaningful Paint 没办法通过代码统计到数据之外,其余几个都可以通过浏览器的 API 拿到这些数据。事实上浏览器提供了相关的 API 来让开发人员获取性能数据,比如 window.performance.timing 可以用来分析页面的整体加载情况,包括 DNS 查询时间,TCP 连接时间,页面下载时间,页面可交互时间等。你甚至可以通过 window.performance.getEntries() 来获取页面的所有性能数据,包括每一个资源的加载性能数据。
性能指标有了,数据获取方法也知道了,那么怎么衡量呢?有两种方式来衡量,一种是开发人员通过性能分析工具来衡量,另一种是通过收集真实用户的访问性能数据,最后通过计算分析得到最终性能数据。前者比较常见的是通过 Lighthousewebpagetest 这些工具来进行性能分析。而后者就是所谓的 RUM(Real User Monitoring),大公司通常都会自建 RUM 系统,当然市面上也有一些 RUM 服务,他们都是用来收集真实用户的数据情况。毕竟有时候开发人员自己测试的结果可能会跟真实用户的实际结果有较大的差距。大部分时候我们所说的性能数据就是指 RUM 的数据。
通过 RUM 系统就可以很方便地进行性能数据的观察和分析了,通过对比不同日期,不同版本(如果支持的话)的性能数据就可以知道性能的整体趋势是怎样的,所做的性能优化是否有效果等。

优化方法

前面写了那么多,都是为了这部分做铺垫。优化的方法其实有很多,我根据优化的方式归纳为以下几类:

减少传输的体积

体积的减少往往收益是最明显的。在网速恒定的情况下,体积一旦变小了,那么下载时间就变短了,页面就更快地加载出来了。体积的减少对于新老用户来说都是有好处的,因此这是最实用的优化方法。减少体积的方法有很多,以下是常见的一些手段。

代码以及静态资源进行压缩

都 2020 年了,估计 Web 开发者应该都知道代码压缩这个优化。如果你是使用打包工具,比如 Webpack,或者 Parcel ,那么往往你不需要担心这一块,因为这些工具都会帮你进行代码的打包和压缩。只是需要注意的是,在打包生产环境的代码时,最好加上 process.env.NODE_ENV=production 这么一个环境变量,让打包工具知道当前是要打一个生产包,它会自动地移除没用的代码,和进行 tree shaking,进一步减少体积。
除了代码可以压缩之外,静态资源也可以压缩。如果你使用的是 Webpack 打包工具的话,那么可以使用 image-webpack-loader 来配置压缩规则,它可以压缩 JPEG ,PNG,GIF 以及 SVG 资源,做到在不影响视觉效果的情况之下尽量减少加载的体积。

在 Web Server 上开启 Gzip 或者 Brotli 编码

JS、CSS 代码的压缩估计绝大部分人都知道,并且知道怎么实现。但是 Gzip 或者 Brotli 却不一定人人都知道。简单地讲,Web Server 可以通过 Gzip 或者 Brotli ,将资源进行重新编码,使得文件体积进一步减少,这样传输给浏览器的体积就减少了。
举个例子,react-dom@16.13.0 这个库,在代码压缩后体积是 114 KB,而使用 Gzip 进一步压缩后体积只有 35 KB。相当于原本 114 KB 大小的压缩后的代码在传输到浏览器的时候只需要传输 35 KB 就行了。
/image/46b28a16-346d-4f7c-b3e0-3d6a26ae462a/7918df23-f876-4401-afd1-bfd07c02e687_Untitled.png
如果你的用户设备大部分都支持 Brotli 编码的话,你甚至可以使用 Brotli 取代 Gzip 。根据统计, Brotli 对比 Gzip 在 JS 文件体积上能够减少 14% 左右,在 HTML 文件体积上能减少 21%,在 CSS 文件上能减少 17% 。
开启 Gzip 的方式非常简单,在 Nginx 上你只需要在配置文件上写入这么一行配置:
gzip on;
即可实现 Gzip 。而 Brotli 的开启就稍微麻烦一些,要在 Nginx 上使用 Brotli 的话则需要自行引入 Brotli 模块手动编译 Nginx 。具体方法可以参考 Brotli 的 Github 仓库
判断一个资源是用什么编码方式很简单,只需要看资源所返回的 Content-Encoding 是什么类型就行。下面的截图所使用的编码方式就是 Gzip。
/image/46b28a16-346d-4f7c-b3e0-3d6a26ae462a/ecf34d1e-df70-41df-a18b-2cc6ae81714d_Untitled.png
Gzip 或者 Brotli 固然是好,但是并不是所有资源都需要使用 Gzip 或 Brotli 压缩。比方说图片资源,就不需要再二次压缩。因为这两者比较适合压缩文本内容,对于这种二进制内容不是很适合,有时候可能还会使得体积变大。

针对不同的设备下发最优的资源格式/类型

图片
常见的图片格式有 JPEG,PNG,GIF,每种图片格式都扮演者不同的角色。JPEG 主要是用来展示静态图片,他是有损压缩的,因此文件体积相对来说会比较小,也是最常见的图片格式之一。PNG 的话也是用来展示静态图片,但是它比 JPEG 好的一点在于它支持透明度。同时它还支持无损图片。而 GIF 则是我们常见的动图了,Web 页面主要用它来展示一些小动画,或者一些有趣的表情包,或者电影片段等。图片在网页上扮演的角色非常重要,根据 HTTP Archive 的统计,平均一个页面的加载流量里面有60%的流量是图片。因此图片的优化至关重要。
事实上,在部分浏览器上支持某些比较新的图片格式,如 WebP ,它能够实现在同等质量下图片体积比其他格式要小。WebP 是谷歌推出的一个图片格式,它可以展示静态图片,也能够实现透明度,还能够展示动态图片。在静态图片领域可以说它是目前的佼佼者。相同质量下它比 PNG 格式的图片体积要小大概 26% 左右,并且仍然能做到透明度的支持。WebP 对比 JPEG 的话,大概也有 25%-35% 左右的优势。而在动态图片领域,WebP 就显得没那么厉害了。目前 WebP 在安卓浏览器以及 Chrome,Opera 上都支持,所以如果你的用户的设备都支持 WebP 的话,赶紧把 WebP 用起来吧。
/image/46b28a16-346d-4f7c-b3e0-3d6a26ae462a/76c6d47c-832b-4c43-88c9-150d71f8faf3_Untitled.png
/image/46b28a16-346d-4f7c-b3e0-3d6a26ae462a/d35c8802-1f1b-4da7-9bc4-458c7381d137_Untitled.png
那么在动态图片领域有哪个格式比 WebP 以及 GIF 更好呢?答案是 APNG 。APNG在相同质量下体积会比 GIF 小 15%-30% 左右,并且 APNG 支持更多的色彩,所以它显示出来的效果要比 GIF 还好。比 GIF 体积更小,效果更好,这不是最完美的动图格式吗?可惜的是并不是所有浏览器都支持 APNG ,目前只有 Chrome 59 以上,Safari 8 以上,Firefox 3 以上才支持 APNG。如果你的用户只有部分支持 APNG ,那么也可以只在支持 APNG 的浏览器上下发 APNG 格式的图片,在不支持的浏览器上继续使用 GIF 。
/image/46b28a16-346d-4f7c-b3e0-3d6a26ae462a/c45c0d55-f5c2-4f8b-b3fd-73c114624dad_Untitled.png
除了图片格式的合理使用之外,图片的大小也需要合理地使用。如果你的用户会使用移动设备以及桌面设备访问你的网站,那么你最好能够提供两种分辨率的图片来展示。在移动端设备,由于屏幕较小,展示尺寸较大的图片没有任何意义,只会拖慢页面的加载性能。而在桌面端设备,图片尺寸又不能太小,太小的话就会显得图片很模糊,影响用户体验。想要做到图片尺寸的智能切换可以使用 srcset 来实现:
<img srcset="elva-fairy-480w.jpg 480w,
             elva-fairy-800w.jpg 800w"
     sizes="(max-width: 600px) 480px,
            800px"
     src="elva-fairy-800w.jpg" alt="Elva dressed as a fairy">
图片格式的灵活使用,以及图片尺寸的灵活调整,都需要对原始图片进行转换。如果要自己来做这个事情的话那么会非常麻烦,每一张图片最后可能会输出非常多个版本。与其自己维护多个版本的图片,不如使用现有的服务来做这种事情。国外的 Cloudinary,国内的阿里云 OSS 都可以实现上述说到的所有功能,它能够通过在图片 URL 上加入不同的参数来实现返回不同的图片格式,以及图片尺寸等。还可以实现图片水印,图片滤镜等功能。从投入产出比来看,使用这些服务往往会比自己维护成本更低,收益更高。
字体
与图片格式类似,网页字体也有多种格式,并且不同格式的体积大小明显不同。目前较为常见的字体格式有 EOT,TTF,WOFF,WOFF2。其中 EOT 是没有压缩的,并且主要是用来兼容 IE8 以及以下的浏览器,如果你的用户不是使用 IE 那么你应该尽可能避免使用 EOT 。TTF 是目前兼容性最好的一种字体格式,通常你可以把它作为 fallback 来显示字体。WOFF 是一种较新的字体格式,它对字体进行了压缩,所以体积对比 TTF 有一定的优势,并且现代浏览器基本都支持 WOFF 字体。而 WOFF2 字体顾名思义,就是 WOFF 字体的升级版。它拥有更小的文件体积,并且更好的性能,可以说它是最优的字体格式。遗憾的是兼容性相对来说较差。
好在在 CSS 里面定义网页字体时可以声明多条 src 来实现不同字体格式的灵活选择,比如:
@font-face {
  font-family: FontNam
  src: url('path/filename.woff2') format('woff2'), 
    url('path/filename.woff') format('woff'),
    url('path/filename.ttf') format('truetype');
}
这样在支持 WOFF2 的浏览器就会优先使用 WOFF2 字体,不支持的话就会 fallback 到 WOFF 字体,如果还是不支持那么就会使用最后的 TTF 字体。
在我们自己的业务里面,实际使用下来 WOFF2 字体的文件体积要比 TTF 的小 70% 左右,WOFF 字体比 TTF 字体小 50% 左右,收益非常明显。
JS 代码
目前的网页有个趋势是越来越多的页面使用 Webpack 或者类似的打包工具来打包页面的 JS、CSS 资源,开发人员在编写代码的时候会使用 ES2015+ 语法来开发,最终会通过 Webpack 打包工具打包成 ES5 版本的代码,以此来兼容低版本的浏览器。
但是如果你的用户所使用的浏览器版本较高的话,能够原生支持 ES2015+ 的语法的话,那么把代码打包成 ES5 代码反而增加了不少体积,因为增加 Polyfill 都会增加代码体积。好在我们也可以跟字体一样根据浏览器的支持情况来实现不同环境加载不同版本的代码。
在 Chrome 61 以及以上的版本支持 <script type="module"> 功能,而在不支持这个特性的浏览器则会忽略这个标签。这样的话就可以使用 <script type="module"> 以及 <script nomodule> 来实现根据不同环境自动使用不同版本的代码,如:
<script src="/bundle.mjs" type="module"></script>
<script src="/bundle.es5.js" nomodule></script>
在我们的业务里面已经使用这种优化方式,ES2015+ 版本的代码比 ES5 版本的代码体积减少 20% 左右。更小的代码体积不仅是加快代码的下载,还会减少代码的解析。特别是在移动端设备,体积越小,解析时间的差距会更小。
使用 Preact 替换 React
如果你的项目使用了 React 来编写页面的话,那么可以考虑使用 Preact 来无痛替换 React 。Preact 对比 React 来说,它的体积小了 10倍以上。而且更棒的是,Preact 它号称100%支持 React 的 API,如果要从已有的项目里面切换到 Preact 的话,只需要在 Webpack 里面配置 alias 即可,无需改动业务代码。具体步骤可以参考 Preact 的官方文档
/image/46b28a16-346d-4f7c-b3e0-3d6a26ae462a/3579ee23-090f-441f-8686-9cba4fabb8b6_Untitled.png
以我们自己的业务来说,真正实现了无需改动任何业务代码,只需替换 React 为 Preact 就能实现所有业务功能,并且页面整体体积减少了 30% 左右。这是相当惊艳的结果,只需要很小的投入就能产出挺大的收益。因此这也是一个非常值得做的优化点。

复用 HTTP 连接

HTTP 连接的创建是一个不小的开销,少则几十毫秒,多则几百毫秒。如果不复用 HTTP 连接的话,那么每个请求都会单独创建 HTTP 连接,这势必会降低页面的加载速度。因此合理复用 HTTP 连接也是一个非常好的优化方案。

HTTP 1.1 的 keep-alive

在 HTTP1.1 上,默认的请求都是 keep-alive 的,也就是说当前的 HTTP 连接在传输完当前的资源后不会立即关闭,而会继续复用。这一定程度上改善了 HTTP1.0 时代的连接问题。现阶段的页面基本都是 HTTP1.1 的,所以大部分情况下你不需要关心这部分内容。
但是有个点需要注意的是,在 HTTP1.1 协议里面每条 HTTP 连接一次只能传输一个请求,所以如果在同个域名下你同时发出了两条请求,那么是会分别创建两个 HTTP 连接的,因此连接还是没办法复用。在这种场景下如果要复用 HTTP 连接那么就需要使用 HTTP2 协议。

HTTP2

HTTP2 是全双工、多路复用的协议,它从根本上解决了 HTTP 连接复用的问题。在同个域名下,无论你并发请求多少个资源,理论上只会创建一条 HTTP 连接,所有请求都在这条连接上完成。
在有大量的并发请求的场景下,使用 HTTP2 协议会比 HTTP1.1 协议要快不少,因为每条请求都能节省创建 HTTP 连接的时间,这样一来总的加载时间就降下来了。
但是 HTTP2 必须工作在 HTTPS 上,因此如果原来你的网站是使用 HTTP 协议的,那么你要使用 HTTP2 的话除了在服务器端部署 HTTP2 的支持之外,你还需要采购对应的 HTTPS 证书等。除此之外,在某些场景下 HTTP2 可能不比 HTTP 快。因为 HTTP2 连接在创建的过程中会多了一步 TLS 握手,在网络延迟比较高的环境下这可能会影响整体的加载速度,因此可能在这种场景下具体表现效果没有 HTTP 好。所以要先了解你的用户的网络情况再来决定应该使用什么协议。
目前大多数现代浏览器都支持 HTTP2,在 Chrome 41 以及以上,Firefox 36以及以上,Opera 28以及以上都支持 HTTP2 。

页面离线化

所谓离线化,即页面的所有资源都离线存储到浏览器上,当用户访问页面时不需要再发起任何网络请求就能渲染页面。
Service worker 可以实现这样的能力,它可以让页面在第一次访问的时候注册好 service worker,并且缓存页面所有的资源。当用户第二次访问页面时,页面内容完全从 service worker 的缓存里面读取。Service worker 是业界较为常见的一种优化方式,它的优点非常明显,只要缓存过数据,那么后续的页面访问都非常快。但是它有个缺点,就是页面内容更新比较复杂。如果页面内容有更新,那么用户必须在下一次页面访问的时候才能看到新内容。另外它还有个局限性就是它不能加速新用户的第一次访问。第一次访问仍然要下载页面的所有资源。
除了 Service worker 之外,目前还有一种容器化技术,能够实现页面离线化。它是依赖自有的客户端实现一套标准,当客户端启动的时候会在后台静默下载配置好的离线页面,当用户正式访问到这些页面时,就能直接从离线缓存中读取。由于这并不是一个开放的、标准的技术,各大公司的实现不一样。我所在的团队所实现的容器化技术能够做到页面静态资源的离线化,以及页面 API 接口的预请求。这样一来,只要用户使用我们自己的客户端打开我们的页面,那么用户所访问到的都是从本地离线内容读取的,网页加载速度会非常快。同时由于离线页面的加载是在客户端启动的时候就开始了,所以这种优化不仅仅适用于老用户,也适用于新用户。这种技术是革命性的,效果非常明显。当然这种技术只局限于在自家的客户端才能实现这样的能力。在第三方平台/浏览器上,要实现类似的能力只能使用 service worker。

适当懒加载资源

代码懒加载

熟悉 Webpack 的同学应该都知道,默认情况下 webpack 最后会打包生成一个 bundle 文件,这个文件包含了页面所有的代码。对于一个有很多页面的 SPA 应用来说,如果用户每次访问一个页面都要完整下载整个应用的所有代码,那么是相当浪费的。Webpack 提供了一种加载异步代码的方式,通过使用动态 import 就能实现代码的按需加载,只有需要执行到该动态模块的时候才会真正去下载。在 React 里面可以通过使用 React.lazy 实现懒加载的功能,这样就能实现模块按需加载,或者页面按需加载,对于单个页面的加载体积也会小很多。
在我们的项目里面,我们通过使用 React Router 以及 React.lazy 实现了按 URL 懒加载代码的功能,并且在我们的核心页面,为了保证页面的加载速度,我们对页面上的非核心模块进行了懒加载,这些懒加载的模块只有在页面渲染完成之后才会异步去下载,优先保障了主要内容的展示,以此来加快页面的加载速度。

图片懒加载

前面部分提到了图片在页面中占据了非常重要的地位,60% 的网页流量是图片。但是其实部分图片也可以通过懒加载的方式去加载。
一个非常典型的场景是,针对不在视窗范围内的图片进行懒加载,只有展示在视窗范围内的图片才立刻加载。这样做的好处是会让页面的 onload 时间缩短,假设有一个图片列表页,如果没有做懒加载的话,那么页面要等到列表里面的所有图片都下载完成才会触发 onload 时间。而如果给不在视窗范围内的图片做懒加载的话,那么页面只需要加载首屏内的几张图片,页面的 onload 事件会更早触发。除了 onload 时间的缩短之外,它还能加快首屏图片的加载。原本需要并发下载所有图片,图片的下载速度会发生竞争,每张图片能够分到的下载速度往往比较低。而如果只需要下载首屏的几张图片的话,那么平均下来每张图片所能分配到的下载速度就更高了,因此图片就能更早展示。
那么懒加载的图片具体是什么时候才会开始加载呢?在我们项目里面,我们设置了只有当这些懒加载的图片曝光在视窗范围内时才开始加载。我们使用了 Intersection Observer 来实现曝光的监听。在我们的核心页面里面,通过使用懒加载功能,页面的加载性能得到了非常大的提升。
但是懒加载图片有个小缺点就是,当这些图片开始出现在视窗范围内时,它需要一定的时间去加载,加载完成之后才能展示图片内容。而加载过程中你只能看到一个占位图。这对用户体验有些影响,当然这种问题也可以通过 Resource hint 来解决。

使用 Resource hint 提前加载页面资源

Resource hint 是指 preload,prefetch,preconnect。这些技术的作用都是用来提示浏览器,接下来可能会需要用到哪些资源,让浏览器提前去加载这些资源。这几个技术所要解决的问题不尽相同。事实上 prerender 也算是 resource hint ,但是因为使用场景实在是太少了,因此这里不做讨论。

Preload

Preload 是比较新的一个规范,它用来提示浏览器当前页面可能会用到某些资源,浏览器去下载这些资源的时候可以根据设置的 as 属性来设置不同的优先级。具体的使用方法可以参考 MDN 的文档。在实际业务中,preload 的使用场景有:
预加载异步资源
Preload 非常适合用来预加载异步资源,比如上面提到的代码懒加载,如果没有使用 preload 的话,那么这些懒加载的资源只有当代码执行到的时候才会开始去下载。这就会出现一个 loading 过程。如果使用 preload 来预加载这些异步资源的话,那么就相当于可以把这个 loading 过程省略掉。并且由于 preload 是不会影响页面的 onload 事件的,因此我们可以放心的预加载,而不用担心异步资源的预加载会影响页面的整体加载性能。
使用 preload 的方法很简单,只需要在 HTML 中插入一个 link 标签即可:
<link href="/async-script.js" as="script" rel="preload" />
实际上在我们的业务当中,也是使用 preload 来预加载核心页面中的异步模块,以此来加快异步模块的展示。
预加载网页字体
Preload 的另一个非常典型的使用场景是用来预加载网页字体。在没有使用 preload 预加载字体之前,字体的下载时机往往会比较慢,它需要等待 CSS 文件下载完,解析完,并且 HTML 中有使用该字体的内容出现时才会开始去下载。这就导致页面文字在字体没下载完成之前会显示空白。
而使用 preload 的话则可以直接提前字体文件的加载,让页面上的文字更快地展示出来。这一项优化也有应用到我们的业务当中,使用 preload 之后页面内容渲染几乎不会出现文字空白的情况。使用 preload 预加载字体也非常简单,只需设置 as 属性为 font ,并且设置 crossorigin 就行:
<link href="/myfont.woff2" as="font" rel="preload" crossorigin type="font/woff2" />
预请求 API 接口
Preload 除了可以预加载字体、异步资源之外,还可以预加载 API 接口,这也是很少人会用到的一个功能,但是如果灵活使用的话能够给 SPA 应用带来非常大的优化。
Preload API 接口本质上就是将页面的 API 接口的请求发出时机往前提。在一个 SPA 应用里面,通常都需要等待 JS 代码下载解析完才会触发接口请求,在接口请求回来之前页面往往是没有实际内容的。因此用户需要等待的时间会比较长。而如果使用 preload 来预加载 API 接口的话,那么当页面 JS 代码下载解析完就能立即获取到数据了(假设 JS 代码下载完成之前 preload 就已经结束了),这时候页面内容的渲染就能提前显示出来。
根据我们业务的统计,API 接口通常需要几百毫秒甚至一两秒的时间才能请求完成,因此 preload 可能可以为你的页面节省几百毫秒到一两秒的加载时间。
但是使用 preload 预加载 API 有一些局限性,它只能使用 Get 方法来请求接口,而且要想预加载的结果生效那么必须预加载与实际 JS 发出的接口请求在请求 URL,请求方法,请求头都完全一致才行,手动在 JS 发出的接口请求里面添加或者修改请求头都会导致预加载失效。
因此虽然 preload API 接口挺强大的,但是由于有不少限制,我们在实际业务中并没有使用到。如果你的业务满足这些要求的话,那么可以尝试使用这种方式来优化接口请求。
一个简单的 API 接口 preload 代码如下:
<link rel="preload" href="https://api.github.com/users/octocat" as="fetch" type="application/json" crossorigin="" />

Prefetch

Prefetch 与 preload 非常类似,只是它要比 preload “年长”很多。它很早就被提出来了,一开始人们是使用它来预加载下一个页面访问的资源,比如在分页列表里面预加载下一页的文档。MDN 的这篇文章能够帮你解答关于 prefetch 的一些常见问题。Prefetch 的典型使用场景有:
预加载下个页面的主文档
使用 prefetch 来预加载主文档的话,那么浏览器会去解析下载该主文档,并且存到 HTTP 缓存当中。即使该 URL 会经过 30X 跳转到新的 URL ,prefetch 也能跟踪下去。当下次用户访问这个页面时,就能直接从缓存里面读取主文档的内容了。但是浏览器只会帮你下载主文档的内容,也就是说它只会帮你下载 HTML 内容,并不会帮你下载 HTML 中所引用的资源等。
这种优化方式有一定的效果,在我们的业务当中,有一个页面会有跳转到第三方页面的场景,而且是大部分用户都会跳转到这个第三方页面,因此我们在这个页面上使用了 prefetch 来提前帮用户加载好第三方页面的主文档,这样用户在跳转到第三方页面的时候就能够更快地打开页面了,毕竟节省了域名解析、HTTP 连接创建、HTML 文档下载的过程。最简单的例子可以是这样的:
<link href="https://developer.mozilla.org/en-US/" rel="prefetch" />
预加载懒加载图片
“预加载懒加载图片”可能听起来很拗口,其实就是为了解决上面“图片懒加载”部分提到的用户体验的问题。
当图片使用懒加载了,那么它的下载时机就存在不确定性,因此用户看到这张图片的时候可能它才刚开始下载。如果我们给这些懒加载图片提前使用 prefetch 加载呢?那么这些懒加载的图片就会提前下载好,当这些图片暴露到视窗范围内时会触发图片的加载,从而直接从 prefetch 的缓存里面读取,因此图片的展示会非常快,用户几乎感受不到图片的加载过程。
之所以可以使用 prefetch 来预加载这些懒加载的图片,是因为 prefetch 加载资源会以非常低的优先级去加载,并且会尽可能在浏览器空闲的时候去加载,这样的话就能做到不影响页面其他资源的加载的情况下,尽可能地提前去加载我们这些懒加载的图片了。
这项技术也有实际应用到我们的业务当中,在我们的图片列表页我们既使用了图片懒加载技术,同时又使用了 prefetch 来预加载这些懒加载图片,最终实现的用户体验良好,并且页面的整体性能数据并没有受到影响。
有一点需要注意的是,无论使用 prefetch 来预加载什么类型的资源,如果该资源所返回的响应头里面声明了 cache-control: no-store 的话,那么就会导致 prefetch 的缓存不会生效。除此之外的其他场景,如 cache-control: no-cache 或者 max-age=0 prefetch 的缓存仍然会生效。因此如果资源的响应头包含了 cache-control: no-store 的话那么不应该使用 prefetch 来预加载。

Preconnect

Preconnect 可以用来提前创建某个域名下的 HTTP 连接,这个过程包括 DNS 解析,TCP 连接的创建,TLS 连接的创建。当浏览器请求到这个域名下的资源时,浏览器就能够直接复用 preconnect 好的 HTTP 连接了。
Preconnect 非常适合用来为一些动态 URL 请求提前创建 HTTP 连接。比如提前创建好 API 接口的连接等。使用 preconnect 的效果如下图:
/image/46b28a16-346d-4f7c-b3e0-3d6a26ae462a/121243b1-baa4-4be0-b5ad-44dc5b72a5f8_Untitled.png
我们使用 preconnect 来给我们的动态数据,比如 API 接口,以及图片资源提前创建 HTTP 连接。Preconnect 的收益效果可能没有那么明显,但是它确实能够提升部分性能。并且使用方法非常简单,只需要一个简单的 link 标签就行:
<link href="http://baidu.com" rel="preconnect" />
<!-- 如果是 preconnect 跨域接口的话需要加上 crossorigin -->
<link href="//api.host.com" rel="preconnect" crossorigin="anonymous" /
另外需要注意的是,如果你通过 preconnect 提前创建的 HTTP 连接是 HTTP1.1 协议的话,那么这个连接同个时刻只能传输一个请求。具体原因可以直接参考上面“复用 HTTP 连接”部分。对于有并发的请求,最优的优化手段是使用 preconnect + HTTP2 ,来优化 HTTP 连接的创建。

使用骨架屏优化用户体验

所谓的骨架屏就是指在页面还未渲染出完整内容时,先在页面绘制大致的骨架,让页面在加载的过程中不那么枯燥无味,也让用户提前认识到页面接下来的整体布局骨架。比如 Slack 的加载过程的截图:
/image/46b28a16-346d-4f7c-b3e0-3d6a26ae462a/5a393ecc-0519-41cc-b6f6-41524026154b_Untitled.png
在没有使用骨架屏优化之前,我们页面的加载都是一片空白。使用了骨架屏之后,页面的交互体验改善了很多,并且页面的 First Paint 跟 First Contentful Paint 都有一定的提升。虽然骨架屏内容并不算真正的内容,First Paint 的性能数据提升也不是我们做这个优化的主要目标,我们做这个优化的主要原因更多的是为了优化用户体验。关于这部分内容可以参考这篇很棒的文章:Content Placeholders: A Way to Style Waiting Time

总结

性能优化是一个永恒的话题,无论何时何刻性能都不应该被忽略。我们也应该有针对性地去做性能优化。在经过一系列的性能优化之后,我所负责的业务在性能数据上有阶段性的提升。其中使用到的方法大部分都是上面所提到的。当然做了这些优化之后并不意味着事情就这样结束了。性能优化是一个持久战,应该持续关注,持续实践,持续研究。