这篇文章试图整理所有可能的移动端Web前端性能优化手段。这里指的性能,特指打开速度,其关键指标为首次可交互时间、首屏无图时间等。
说在前面
中国的移动互联网,是与别处不同的。首先,流量集中在头部公司的现象明显,而大厂总希望自己的应用成为互联网入口,在一个 App 内塞入各种功能。其次,功能迭代速度快,纯原生应用无法满足需求。综合各种因素,当前各家大厂都在 App 内广泛采用 Webview 承载功能。固然,Web 页面性能无法与原生页面媲美,但其强大的表现力、灵活的发布节奏使其别具优势。本文探讨的是这种使用情景下,Web 页面如何优化打开速度,提升用户体验。也就是说,以下有一些方法是需要客户端配合才能做到的。
这篇文章不会手把手教你具体怎么用什么工具去做,写什么代码优化,只会大概梳理原理,并分析优化手段的收益与成本,具体选择什么方案如何实现,需要结合实际项目来看。
另外,我水平有限,以下内容可能会有一些错误,欢迎指出!
减小体积
网速,是制约加载速度的一大因素。只要你要加载的东西越小,加载速度就越快,这是互联网的真理。即使用户有1000Mbps的光纤接入,用户浏览页面时也用不到那么大的带宽(由于TCP拥塞算法特性,加载一个页面可能还在慢启动阶段就结束了)。更何况在移动端,大量用户使用4G网络接入,带宽更为有限。那么优化体积带来的收益就是非常立竿见影的。假设资源减少了100KB,很可能带来数十毫秒的优化。
JavaScript 压缩
JavaScript 是一门语法灵活的语言,通过一些神奇的操作可以以牺牲代码可读性为交换条件大幅压缩代码,在生产环境我们显然不需要代码可读性。Webpack v5 开箱自带 TerserWebpackPlugin,在 production 环境下默认启用。该插件可以通过变量名替换、删除空行等手段将 JavaScript 代码进行压缩。如果你希望调试压缩后代码/对应压缩前后代码,可以通过生成 Source Map 来辅助。
CSS 压缩
CSS 的可压缩空间比 JavaScript 要小一些,但能压一点是一点。通过删除空行,优化写法等手段可以减小 CSS 的体积。例如将margin-top: 2px; margin-left: 2px; margin-right: 2px; margin-bottom: 2px;
压缩为margin: 2px
。可以使用 CSS 后处理器如 PostCSS 加载 CSSNano 插件对 CSS 代码进行压缩。Webpack 可以通过 postcss-loader 使用 PostCSS。
Tree Shaking
Tree Shaking 指的是在打包过程中自动删除未使用模块的技术。这一做法由 rollup 首创,后续 Webpack 也加入了该功能。在 Webpack v5 中,默认开启了使用 terser 来分析未使用的代码并删去的功能。虽然这一模式的分析不能达到完美,但基于副作用的分析模式在真实开发下很难实现,需要依赖包在package.json
中标记副作用情况,过于理想。Webpack v5 也默认启用了基于副作用标记的 Tree Shaking。
Tree Shaking 技术可以大幅减少引入的依赖,优化代码体积。
按需引入 Polyfill
现代前端页面通常使用 Babel 对代码进行转译,以保证在低版本浏览器上的兼容性。对于一些低版本不支持的特性,通常使用 core-js 引入 Polyfill。Polyfill 指的是,通过引入一段代码使低版本浏览器得以支持新特性。但我们一般不需要引入全部的Polyfill,而是只引入需要的。babel-preset-env 提供了选项来支持按需引入 Polyfill。
gzip 压缩
最简单而粗暴的优化手段之一。为服务器启用 gzip 压缩,让服务器发送给客户端的内容经过压缩算法处理后再发送,客户端下载内容后进行解压缩。由于 gzip 算法的压缩/解压非常快,压缩开销可以忽略不计,所以启用 gzip 可以说是百利而无一害的一种优化手段。
使用 Nginx 时,启用自带的 HttpGzip 模块即可。
字体裁剪
假如页面为静态页面,展示内容固定,这种页面一般对设计有较高要求,也可能会指定使用特定字体。这种情况下需要通过@font-face
额外加载字体。中文字体通常体积较大,我们可以通过字体裁剪的方法减小字体的体积。
所谓字体裁剪就是去除字体中未使用的字符,减小字体的体积。目前比较流行的方案有 fontmin、fontkit 等(我没用过,刚搜的)。
WebP 图片
WebP 格式的图片比起常用的 JPEG/PNG 格式图片有更好的压缩率,如果有能力在 CDN 上部署多个版本的图片,可以考虑通过 JavaScript 判断客户端是否支持 WebP 图片然后选择 WebP 版本显示或 Fallback 到 JPEG/PNG。
iOS 14 及以上版本已经支持了 WebP 格式图片。相信支持率会越来越高。今后可能会有大厂开始考虑用压缩率更优秀的 AVIF/JPEG XL 了。
顺便一提(不单独开个小节了),使用 CSS 的 background-image 比直接使用 <img> 标签要慢,所以对图片加载速度要求高的时候尽量不要用 CSS 背景图。因为使用 CSS background-image 的话,图片加载时机需要在 CSS 加载完毕之后。
懒加载
非首屏需要的资源可以在页面渲染之后再下载,或者在用到的时候再下载。比较常见的应用场景是页面内弹窗的组件可以延后加载。通过配置打包工具,将这一部分代码分割,在使用时再加载可以减少首屏渲染需要的资源大小,从而加快首屏展示。
同时,需要懒加载的内容也可以再进行预请求,这样使用到的时候不用再请求资源文件,用户体验更加。HTML 的 <link> 标签支持使用 preload 属性表示需要预加载的内容。
其中,图片懒加载较为特殊。用于浏览器会同时开始请求所有页面中的图片,无论其是否在首屏使用。由于带宽有限,同时加载更多图片则会降低图片的平均下载速度。因此,如长列表等场景,非首屏图片可以延后加载。不考虑兼容性的情况下可以使用 <img> 标签的 loading=”lazy” 属性,由浏览器原生支持,效果最佳。否则可以使用 IntersectionObserver 观察元素与 viewport 的位置关系决定是否加载图片,这会比原生支持稍慢,因为需要先执行 JavaScript。
动态 Polyfill
近年来许多 CDN 厂商也开始给 CDN 边缘节点引入计算能力,让动态 Polyfill 成为可行的方案。所谓动态 Polyfill 即根据客户端的 User-Agent 返回不同的 Polyfill 文件。当前现代浏览器比例不断提高,有大部分用户其实是不需要任何 Polyfill 的,如果给这些用户也引入了 Polyfill 显然是浪费了文件大小。利用 CDN 边缘节点的计算能力,判断出这些用户并给他们返回空,可以节约大量传输体积,从而加快页面下载。
加快渲染
如何让页面更快地呈现给用户,也是一大历史难题。
资源加载顺序
CSS/JavaScript 都会阻塞页面渲染。具体来说:style 标签会阻塞其之后的 DOM 显示,但不阻塞 DOM 的解析、DOM 树的构建,只是会等样式文件下载完之后再显示后面的内容(内联样式则无需下载)。script 标签则会阻塞其之后的 DOM 的解析与渲染。这篇文章总结得浅显易懂。
这就是为什么大家一般选择将 style 标签放在 HTML 头部,因为如果先显示 HTML 布局再加载样式,会有一个从无样式到有样式的重渲染的闪动,这是一个体验很差的过程。而 JavaScript 则一般放在 HTML 末,在首次渲染后再做事件绑定等操作让页面可交互。
关键 CSS 内联
根据前一小节所说,外联 CSS 需要放在 HTML 的头部,同时页面需要等待外联的 CSS 文件下载并解析完后再显示。如果使用外联 CSS ,那么需要先下载 HTML,然后解析 HTML,浏览器再发起请求下载 CSS 文件,然后再显示内容。
既然如此,不如将首屏需要用到的 CSS 直接内联进 HTML 中,这样可以减少一次请求,降低页面首屏时间。但要注意的是,内联进 HTML 的 CSS 要尽量少,不然可能影响页面加载速度。这些关键的 CSS 被称为 Critical CSS,那么如何将这些 CSS 从所有的 CSS 中分出来呢。
一般有两个方向,一种是静态分析,通过分析 CSS 和 HTML 文件,将必要的 CSS 抽出来,比如说 used-styles 就是这么做的。
还有一种更准确的做法就是使用类似 isomorphic-style-loader 的方案,直接在服务端渲染的时候分析出哪些 CSS 需要在首屏渲染时用到,非常巧妙而准确,建议了解一下。
服务端渲染
现代前端应用越来越复杂,通常都需要依赖接口数据做展示。在传统的客户端渲染页面中,页面最终呈现之前需要经过:下载页面->下载 JavaScript/CSS 等资源 -> 请求接口 -> 根据接口数据渲染页面。无论是用 jQuery 还是 React/Vue ,获得接口数据之后都需要再解析数据根据渲染出 DOM 结构。
服务端渲染则是将从接口获取数据和首次渲染前置到服务端,通常由 Node 从后端请求数据并渲染后发回客户端。当前前端领域所采用的的服务端渲染多为同构渲染方案,即 Node 端和客户端运行同一份代码,以 React 为例,在 Node 端进行接口的请求后即可从数据渲染出 DOM 字符串。同时,Node 端将状态序列化后发回客户端,客户端进行反序列化恢复状态,进行”Hydrate”再次渲染(渲染后与 Node 端渲染出的 DOM 进行对比,利用已有 DOM 少更新或不更新界面),之后页面进入可交互状态。
采用服务端渲染,可以提升首屏展示的速度。因为客户端首次渲染即是带有数据的页面,而不是等待 JavaScript 下载解析完毕后执行再去请求接口。请求接口本身的时间也有所减少,因为在Node 端请求接口通常走内网或专线,比从用户端发起请求要快得多。
流式渲染与分块渲染
流式渲染和分块渲染也是性能优化的一大利器。为什么放到一起讲呢,主要现在的主流实现的分块渲染都是以流式渲染为基础的。有时候我们也无法简单地将这两者分开讨论。
流式渲染顾名思义就是以流的方式渲染页面。以 React 为例,调用 ReactDOMServer 的 renderToNodeStream 方法就可以将渲染结果以 Stream 的方式返回。这可以一定程度降低页面的首字节时间(TTFB),同时提前外部资源的加载,从而加快页面展示。需要注意,使用流式输出需要在各转发层都禁用 Buffer,例如如果 Node 层前有 Nginx 层,则需要配置 X-Accel-Buffering 头禁用掉 Nginx 的 Buffer。
仅仅是将输出从字符串改为流,就已经能在性能上有所收益。在此基础上再引入分块渲染技术,可以再大幅提高页面性能。当前我们通常的分块渲染做法是这样的:
- Node 端首先同步输出页面框架,需要请求接口的部分使用骨架屏先占位。
- 客户端开始下载 JavaScript 文件的同时,Node 端开始请求接口。
- Node 端请求到接口输出后继续向流输出脚本,将骨架屏区域填充为有数据的内容。
- Node 端关闭流,结束输出,客户端开始 Hydrate。
这样做法的最大有点在于,将 SSR 中串行的请求 JavaScript 和请求接口输出并行化,大幅降低了页面渲染需要的时间。
接口预请求
接口预请求顾名思义就是预先请求下一个打开的页面所需要的数据,这样,当用户访问下一页面时无需再向接口请求数据,直接利用已经请求好的数据渲染页面,这当然是极好的。这是一种稍微有点作弊的优化手段,原理简单效果优异,只是有一些地方需要注意。
一是需要注意接口数据的时效,假如说预先请求接口之后用户过了很久才真正打开需要接口数据的页面,这个时候接口返回的数据可能已经发生了变化,之前请求的数据不再是最新数据,有时候可能就会给用户带来困扰。所以需要数据高实时性的页面不适合用这种优化方法,例如说订单列表等。
二是预请求了接口数据之后用户可能根本不会再去访问下级页面,这样请求的数据就浪费了,给服务端增加了压力,需要权衡好这个成本。通常上级页面到下级页面存在转化率,这个可以通过打点等方法统计,转化率比较高的页面可以考虑上这个优化手段。
在具体实现上,如果有客户端配合那直接由客户端准备数据,劫持网络请求方法,可以让页面做到无感接入,无需代码改动即可享受接口预请求带来的收益。如果是端外,可以上级页面请求后存在 localStorage 等地方,下级页面请求之前先检查是否有请求好的数据。
不难看出,如果预请求首屏数据,那这一方法和 SSR 是冲突的。需要权衡收益与成本后决定具体使用那种技术方案。
边缘节点渲染
边缘节点渲染,指的是利用 CDN 的计算能力,将 BFF 层前移,进一步优化 TTFB 等性能指标。这篇文章介绍了一种常见的边缘节点渲染的方法,可以参考。
也就是在流式+分块渲染的基础上,将服务端渲染前移到了 CDN 节点。客户端发起请求时,CDN 负责渲染服务端页面先返回,然后 CDN 开始请求后端服务获得数据,完成后继续以流式渲染的方式返回给客户端。这样做的好处是能够降低 TTFB,同时客户端早一步获得 HTML 内容就可以早一步开始下载 JavaScript 等资源文件,降低页面首次可交互时间等性能指标。
iOS 特供优化
单独开一个小节讲一个 iOS 特供的优化,这是一位知乎用户发现的,令人十分无语的 Safari Bug,但是 iOS 用户又那么多,没办法,优化下吧。实在不知道这种东西应该归入哪个小节。
看这篇文章就好啦!-> https://zhuanlan.zhihu.com/p/68290048
网络与缓存
CDN
内容分发网络 CDN 在现代网站中广泛使用。静态资源不依赖服务端计算,为了让客户端更快下载,可以将静态资源分发到 CDN 服务器上。CDN 服务器通常离用户更近,连接性更好,如此客户端就能以更快的速度加载静态资源了。
HTTP/2
HTTP/2 相比 HTTP/1.1 而言有大幅的性能提升,主要是由于:
- 头部压缩(HPACK)
- 二进制协议,信息密度更高
- 改进的 Pipelining 和多路复用,支持连接复用,乱序返回
落到实际上的效果就是可以明显提升加载速度,而且基本上在 HTTP/2 下不需要再考虑模块分割需要多加载文件导致性能下降的问题。在 HTTP/1.1 时代,开发者们将所有 JavaScript 打包成一个文件或者尽量少的文件的一大原因是浏览器限制同一 Host 的并发连接数(在 Chrome 下,一个 Host 的并发连接上限是 6),加载 10 个外部 JavaScript 文件可能就要创建 10 个连接,造成阻塞。如果还有其他图片文件之类的就更挤占配额了。所以人们发明了打包、雪碧图、CDN 分域名等方法来绕开这一限制。在 HTTP/2 下,由于一个 Host 只会创建一个连接,这些都是无需考虑的问题了。
启用 HTTP/2 除了兼容性问题外应该没有什么弊端,无脑开启吧。
缓存
本小节所称缓存特指 HTTP 缓存,这是面试中常见的考察题目之一。
HTTP 缓存指的是,当第二次使用某一资源时,不再向服务器请求文件,而是直接使用缓存在本地的之前版本(也可能是和服务器比对 ETag,一种代表资源版本的短字符串,来决定是否使用缓存版本)。在服务器返回的 HTTP Header 中设置 Cache-Control 头,可以控制资源的缓存策略。
在实践中,我们一般将 HTML 的缓存策略设置为不缓存,外链的 CSS、JavaScript 文件的缓存策略则设置为尽量缓存。在打包时,外链资源文件的文件名根据其文件 Hash 生成,这样在充分利用缓存的同时规避客户端由于缓存旧资源导致更新延迟问题。当资源文件发生更新时,由于文件名改变,由于 HTML 文件不缓存,客户端会获得到最新的文件名,由于和缓存的资源文件名不同,原先的缓存失效,客户端请求到最新文件并建立新缓存。
PWA
PWA 即 Progressive Web Application,渐进式 Web 应用。PWA 包含一系列对 Web 体验的增强,而在性能方面主要发挥作用的是 Service Worker 可以细粒度管理文件缓存,可以直接拦截请求并使用本地文件。PWA 的优势在于在 Service Worker 的控制下可以对 HTML 主文档进行缓存,达到秒开的效果,同时 Service Worker 可以在后台进行更新页面版本,下一次访问就能获得新版本的页面。
PWA 缓存主文档效果显著,但其特性也注定了缓存失效时间会变长,可能在推送新版本之后还需要一段时间才能在客户端达到全量,可能导致 Hotfix 版本全量时间延长,损失扩大。
注意,如果缓存主 HTML 文档,可能会与服务端渲染冲突:服务端渲染将数据也在 HTML 中直出,而数据内容被缓存可能不是预期的效果,为了解决这个问题通常需要引入专门处理数据过期的逻辑,并且可能不适合需要数据高实时性的页面。
资源包下发
这是一种需要客户端配合的更激进的缓存手段。即将页面中引用的外部资源(JavaScript、CSS 等)提前下发到客户端,在请求页面时直接使用资源包内的版本。使用资源包,需要在每次代码有更改之后构建新的资源包并下发到客户端,这一过程可以通过 CI 等手段自动完成。资源包的优化在首次加载页面时较为显著,而后续由于缓存建立,效果不甚明显。
长连接
这一优化也需要客户端配合。我们都知道 HTTP 是基于 TCP 的应用层协议,自然,一个 HTTP 请求也需要经过三次握手等流程,导致如果打开一个页面,客户端发起 HTTP 请求下载资源时需要重新建立连接。
如果在客户端内,可以由客户端负责和服务端保持长连接,并接管 Webview 发起的 HTTP 请求,通过已经建立的长连接获得返回,节省可观的建立连接的时间。
这一优化的效果显著,但需要客户端长时间与服务端保持长连接,需要消耗一定的服务器资源。
DNS 优化
一般我们做 Web 开发的时候不大会注意 DNS 消耗的时间,但实际上 DNS 有时候也是挺耗时的。我们都知道 DNS 的递归查询机制,当 TTL 到期之后,用户向 DNS 服务器发起解析请求时,服务器需要代替用户去发起递归查询,而递归查询其实是非常耗时的。在某些情况下这也会影响页面打开的速度。
如何优化 DNS,首先需要抛弃传统的 DNS ,改由客户端接管。下发配置也好,自定义 TTL 也好,HTTP DNS 也好,通过长连接 HTTP DNS 也好,说不定都能起到一定效果。当然,对 DNS 的改造成本比较高,收益也相对较小,可以在大后期考虑。
更新日志
- 2021年11月1日 初版,写了好几个月..我真能拖
- 2022年2月20日 修改了关于懒加载的一些描述
“移动端 Web 前端性能优化”的一个回复