乐趣区

前端网页加载渲染链路优化

优化实战
本文属于思否课堂 VirtualDOM 到 AST 玩转前端性能原理解析与代码实战课程
我们已经全面分析总结了评估页面性能和用户体验的各个指标参数。那么怎么来优化呢?open signal 官方提供了 2018 年 2 月份统计的全世界 4G 网络覆盖率和通信速率的统计分布图如下,在目前移动互联网的浪潮下,我们要利用好用户终端设备的每个字节的流量。当然页面性能和体验优化并不是一蹴而就的,需要不断的研究、跟踪,发现问题,解决问题。但是我们可以在一开始编写业务代码的时候就做的更好,做到极致。所以,关于优化实战我们主要分为两部分:加载渲染链路优化 和 编程代码优化。
加载渲染链路优化
从访问 url 到页面呈现,整个链路可以做优化的思路。
幸运的是,W3C 推荐的 Navigation Timing 标准中所定义的核心的页面性能数据,它包含了从上个页面销毁到跳转到当前页面加载完成每个阶段所消耗的时间。在 canIuse 上查到的兼容性也很好:利用这个接口可以很方便的帮助我们排查链路问题。在 Navigation Timing 标准中介绍到这个 API 主要包含两个接口:PerformanceTiming 和 PerformanceNavigation,这两个接口由浏览器进行实现和维护,当浏览器创建页面的时候就会把接口定义的相关数据挂载到 window.performance.timing 和 window.performance.navigation 这两个属性上。我们可以打开一个网页看一下:我们把这两个图对比一下,就可以很容易的排查出页面的加载链路问题。
静态资源链路
打开页面的第一步是请求页面的 html,这里面涉及 TTFB 这个综合指标。同时如果有必要我们也可以统计 DNS 时间和 TCP 时间。
DNS 时间:主要是根据请求域名查询到对应主机 IP 的时间。这个和 DNS 服务器有关系,也可能和本地缓存有关,如果这个很慢,可以找服务商排查下问题。
TCP 时间:tcp 是承接 http 协议的下层协议。主要是路由到主机 ip,并建立 tcp 链接的时间。这个时间反应了服务器到用户客户端之间链路是否通畅,网络是否通畅。
请求完 HTML 之后,就开始解析 html 代码,按照从上至下、自然顺序解析,解析内联 CSS 代码或者加载外链 CSS 脚本,解析内联 Javascript 脚本,或者加载外链 Javascript 脚本。由于浏览器是单线程的,这些 CSS 和 Javascript 脚本很可能就会造成页面卡顿。参考 浏览器线程理解与 microtask 与 macrotask。
加载
CDN 是内容分发网络,主要用于缓存静态资源。CDN 服务商一般会在全国各地部署服务,而且带宽很大,这样访问 CDN 的资源时就可以有较短的路由路径,而且带宽也比较大,访问比较快。

建议最好把 html, CSS、JS、font、img 这些资源放在 CDN 上,没有 CDN 也可以放在 OSS 存储服务上,总之比自己的服务器硬盘快多了,至少服务商会在不同区域做分布式部署
如果没有钱买 CDN 服务,那么就尽可能少的加载外联 CSS 和 JS 代码,注意 html 头部可以增加 dns-prefetch,减少 DNS 解析时间
不是在首屏展示的资源,不要立即加载,可以在页面 onload 之后加载,或者首屏渲染完成再加载
压缩 CSS、JS、font、img,尽量减少体积,服务端开启 gzip

考虑资源 combo 请求,减少 http 请求量,浏览器一般都有并发限制, 比如 chrome 一次 6 个并发 http 请求,不同浏览器内核可能不一样。
<script> 加载脚本会阻塞浏览器主线程,考虑异步化,参考 script 标签的 defer 与 async

利用好缓存,利用好 http 响应头缓存字段,开启静态资源缓存,减少资源下载,建议开启 service worker 缓存,这个是作为 APP Cache 的替代方案,参考 MDN;
对于单纯的获取数据做展示,尽量采用 JSONP 请求数据,而不是 AJAX,提升数据请求性能。参考 jsonp 而不是 AJAX?
开启 HTTP/2,HTTP2 支持链接复用,可以很高效下载多个小文件。HTTP/2 的目的是通过支持完整的请求与响应复用来减少延迟,通过有效压缩 HTTP 标头字段将协议开销降至最低,同时增加对请求优先级和服务器推送的支持。

解析渲染
加载完 JS 和 CSS 之后,浏览器开始解析执行。Chrome 的渲染流程是这样的:(可以参考 高性能 CSS 动画)为了让浏览器更快的解析渲染,我们需要考虑这几点:

CSS 嵌套层级不要太深,不超过 3 级,避免在最内层使用通配选择器。参考关于 CSS 选择器性能

JS 脚本不要太复杂,考虑轻量化架构,降低 JS 复杂性,减少解析时间,尽量不要引用复杂的第三方脚本。
按需加载模块,按需打包,首页仅仅加载和执行和首屏相关的脚本。其他脚本延迟加载执行。
考虑依赖的第三方模块是不是必须,需不需要精简。
打包优化,code split 和 tree shaken。常用 webpack 和 rollup 的优化。
用户交互相关事件绑定(比如页面 scroll,用户左右滑动等),添加参数 {passive:true},减少浏览器事件等待。因为这些事件属于可阻止事件,浏览器不知道用户会不会阻止,所以需要等待 js 执行,然后再做响应。添加 passive 参数,就告诉浏览器不用等待了。
IOS8 以后的 ios 支持 wkwebview,但是很多 app 之前用的还是 uiwebview,建议转换成 wkwebview,获得性能的提升(UIwebview 在执行 JS 时会阻塞 UI 渲染进程,WKwebview 不会)。

介绍一下 code split 的方案:react-loadable
// 未处理
import OtherComponent from ‘./OtherComponent’;
const MyComponent = () => (
<OtherComponent/>
);
// 使用 react-loadable 按需加载
import Loadable from ‘react-loadable’;
const LoadableOtherComponent = Loadable({
loader: () => import(‘./OtherComponent’),
loading: () => <div>Loading…</div>,
});
const MyComponent = () => (
<LoadableOtherComponent/>
);
这个也可以在打包工具统一配置,不用每个模块都自己写。
只有浏览器尽快渲染出来,用户才能尽快的可以交互。
数据埋点
上面我们梳理了加载到解析渲染过程应该做的事情,那么如果你这些都做好了,发现网页表现依然不尽人意,那么你就要考虑做一下数据埋点。其实数据埋点在企业项目中也是必不可少的,和性能体验优化构成闭环。通过数据来发现页面性能和体验的问题,更有针对的进行解决。
事实上数据埋点分为三类:

业务埋点,统计诸如 pv、uv、点击率、流失率、转化率等
大数据埋点,统计与用户行为相关信息,比如那个用户点击了那个商品,上报用户 id 和商品 id,方便后台分析用户和商品的关系,可以用做大数据分析,推荐算法来为用户推荐商品。

工程埋点,统计工程上的数据信息,比如页面秒开率,dns 时间等,也就是我们上节课总结的性能和体验数据指标。
资源缓存
这一节我们单独介绍缓存,是的,利用好缓存可以解决很多问题,包括页面加载和渲染的问题都能得到很好的优化。
常见的 h5 缓存方案有很多种,
通常,与页面加载性能相关的,有下面几种缓存,
(1)MemoryCache
MemoryCache,资源存放在内存中,一般资源响应回来就会放进去,页面关闭就会释放。内存存取性能可达磁盘缓存性能的 100 倍,但这还不是 MemoryCache 的最大优势,MemoryCache 最大的优势是离排版渲染引擎非常近,可以直接被读取,甚至无需经过线程转换。在真实的页面访问过程中,获取资源的时间,磁盘 IO 仅仅是其中的一部分,更多的时间往往消耗在各种线程抛转。
(2)ClientCache
ClientCache,客户端缓存,比如,手淘里的 ZCache(离线压缩包缓存),本质上属于磁盘缓存。这类 Cache 的优点是能以相对可控的方式让资源提前缓存在磁盘,但它也有一系列的成本。比如,它需要一套服务器与客户端协同的下发更新逻辑,服务器端需要管理下发,客户端需要提前解压缩。我们可能觉得提前解压并不是什么弱点,但如果有一千个离线包,这个问题就比较严重了,如果不提前解压,就无法保证首次访问性能,如果提前解压会让 IO 非常繁忙,可能会造成客户端打开时严重卡顿。
(3)HttpCacheHttpCache,是历史比较悠久的缓存,它利用标准的 Cache-Control 与服务器端进行协商,根据标准的规则去缓存或更新资源。它应用非常广泛,是非常有效果的一种磁盘缓存。它的缺点是完全由浏览器按标准规则控制,其它端的控制力度非常弱。比如,某些被 HttpCache 缓存的静态资源出问题了,通常只能是改页面,不再使用出问题的资源,而无法主动清除出问题的资源。参考 http 请求缓存头,HTTP 协商缓存 VS 强缓存原理
(4)NetCache
网络相关的 Cache,一般是指 DNS 解析结果的缓存,或预连接的缓存。DNS 预解析和预连接是非常重要的,创建一个 Https 连接的成本非常大,通常需要 600ms 以上,也就是说,页面如果有关键资源需要全新建连接,秒开基本是不可能了。
(5)CDN
CDN 一般是通过负载均衡设备根据用户 IP 地址,以及用户请求的 URL,选择一台离用户比较近,缓存了用户所需的资源,有较好的服务能力的服务器,让用户从该服务器去请求内容。它能让各个用户的缓存共享,缩短用户获取资源的路径,来提升整体的性能。
当然,还有其它非常多类型的 Cache,比如,
JS 相关,V8 Bytecode Cache,字节码缓存,能极大的减少 JS 解析耗时,甚至可以提升 3 - 6 倍的性能。参考:前端优化系列 – JS 解析性能分析渲染相关,图片解码数据缓存,是一块非常大的内存缓存,约 100M,能保证页面滚动过程可以实时获取到图片解码数据,让滚动非常流畅。页面相关,页面缓存,Safari 的 PageCache,Firefox 的 Back-Forward Cache,UC 浏览器的 WebViewCache,都是一样性质的缓存,将整个执行过的页面保存在内存。标准的页面缓存,进入的条件非常苛刻,大部分情况都无法进入,而且在前进后退的场景才允许使用。
缓存优化实例
前面介绍了很多理论层面的内容,我们接下来介绍一些实践优化案例。
(1)预置资源进 MemoryCache
在页面的 onPageFinished 的回调里面去检查是否有资源可以预置,如果有,就通过相关接口把资源设置进内核的 MemoryCache。我们并不知道用户即将会访问什么页面,如果把大量的资源都预置进内存,而用户却没有使用,那就会造成浪费。另外,资源在内核内存,仅仅是加快了资源的加载速度,页面的首屏包含非常多非常复杂的流程,某个流程的加速并不一定能带来整体性能的提升,比如,非关键的 JS 放在内存,可能就会先于一些关键 JS 被提前执行,反而让首屏更慢。所以,选择放那些资源进内存也是非常有讲究的,能预置的资源一般是 非常关键的更新频率较低的少量公共基础资源。
对于一般公司来说,没有能力自己定制 webview 渲染的内核,可以看下系统默认 webview 内核有没有这样的接口来实现操作 MemoryCache 预置数据的能力。
(2)预加载资源进 HttpCache
预置资源进内存,对加载性能的提升是最明显的,但成本也是最大的,会占用用户手机宝贵的内存资源。另外一种预置资源的思路是,提前通过内核去预加载一些资源,资源加载回来之后就直接保存在标准的 HttpCache。资源在 HttpCache 和在客户端缓存(比如,手淘 ZCache)的性能差别不大。但如果资源不能放进 ZCache,通过这种方式提前放到 HttpCache,也是一种优化思路。
(3)使用 WebViewCache 极速切换页面
H5 页面的加载流程是非常重的一套流程,即使同一个页面多次重复访问,也需要走比较完整的流程,耗时极长,这与用户的期望是不符的,通常用户期望访问过的页面就能快速展现出来。在一些特定的场景,H5 也是可以做到极速展现的,比如,前进后退。其它的场景,比如页内几个 TAB 切换,是否也可以用上这类缓存呢?也是可以的。原理上也是比较简单的,在页面首次访问时,会将排版渲染好的页面放进 WebViewCache 里,WebViewCache 是存储完整页面的一块内存。
用户再次访问该页面时,会将 WebViewCache 内存中的完整页面读取出来,直接绘制展现,而无需再进行加载解析排版渲染等流程,从而达到极速打开的效果。
除了内核提供 WebViewCache 基础技术之外,前端也需要与内核进行一定的交互,比如,通过 JSAPI 查询当前页面是否在 WebViewCache,如果在则返回它在 WebViewCache 列表的位置,然后前端就可以使用 JSAPI 去跳转到相应位置的页面,内核就把页面从内存读取和展现出来。使用此类技术,页面一般能在 500ms 左右完全展现出来,具有非常好的用户体验。
当然这个也是需要浏览器内核提供这种能力,如果公司有自己的内核开发团队,可以做到定制。
(4)前端使用 LocalStorage 缓存 HTML 文档
当前前端渲染非常流行,页面大部分的逻辑都会由前端 JS 去执行,JS 执行完才会生成完整的 HTML 文档,而 JS 执行的成本是非常大的,JS 执行时间可能占据首屏时间的 50%,有些甚至能达到 80%。那么,我们有没有可能将 JS 执行生成的完整 HTML 文档缓存起来呢,下次访问时直接使用已缓存的页面,而无需重复执行 JS?这也是可以的原理上也不复杂,首次访问页面时,JS 执行完之后会生成完整的 HTML 文档,我们将 HTML 文档缓存到 LocalStorage 里面。在后续的访问中,我们优先从 LocalStorage 里面读取 HTML 文档,解析排版渲染页面,而无需 JS 执行去生成页面,让页面展现速度得到极大的提升。这种方案的关键在于前端能够实现一套 DOM-Diff 更新的机制,在从 LocalStorage 读取 HTML 页面的同时,前端还会发起请求去更新 HTML 文档,在新的 HTML 文档回来之后,会和旧的文档进行 Diff,针对 Diff 来进行局部更新,这样能保证页面得到及时的更新。
(5) service worker 参考使用 Service Workers 提升体验,这里附带介绍下这个方案,目前 service worker 只有在 android 的 webview 中可用,ios 还不支持。我们通过先注册一个 serviceworker 服务,指定哪些资源和数据需要存储,然后下次请求页面会自动激活这个 service worker,页面请求时会先从 service worker 中返回缓存的数据。当然 service worker 中需要自己处理版本和维护数据更新。
常用工具
Code coverage,检测哪些代码执行到了,哪些没有。支持 Javascript 和 CSS。webpagetest:https://webpagetest.org/Chrome Lighthouse 插件:https://chrome.google.com/web…

退出移动版