利用客户端能力提升 H5 首屏打开速度

一、前言

上篇文章《实现 H5 秒开的系统性思考》谈量如何根据分类思维来梳理性能优化方案,接下来将从客户端角度出发,聊聊如何借助端能力优化前端首屏加载速度。

本文主要是经验和原理之谈,不涉及客户端代码实现,比较适合想开拓视野的前端领域读者。

二、整体方案

页面加载链路如下

接下来利用分类思维,对该流程进行简化、前置、拆分,得到如下客户端优化方案:

三、容器启动

与浏览器不同,App 打开 H5 页面的第一步并不是建立页面请求连接,而是初始化 Webview。

初始化 Webview 包括创建 Webview 实例,对于 App 冷启后的首次 Webview 初始化,还需要初始化浏览器内核。

因此,对于冷启或者全新安装的 App ,首次初始化 Webview 耗时相对较长,大概在数百 ms ;而二次打开就较快了,大概在数十 ms。

首次打开 Webview 耗时二次打开 Webview 耗时
数百ms数十ms

容器启动优化的目标就是将初始化的这段时间省去,常用的解决方案是容器预建。

容器预建

提前创建 Webview 容器,当需要加载页面的时候就可以直接使用,省去容器初始化时间(数十ms~数百ms)。

提前创建 Webview 容器,需要注意创建时机和创建个数。

(1)创建时机:闲时创建。Webview 只能在主线程创建,但又不能阻碍主流程,因此需要在 IdleHandler 时机处理。与前端的 requestIdleCallback 、React Scheduler 概念相似。

(2)创建个数:一般仅创建一个,当预创建的 Webview 容器被使用后,再重新预创建(考虑内存状态)。

此外,结合线程池的概念,可以对容器进行复用,页面销毁并不回收 Webview 容器,而是继续常驻(考虑内存状态)。

四、资源加载

容器启动后,客户端将发起页面请求并加载资源。

根据页面的复杂度,这一阶段耗时大概在几十毫秒到数秒之间。

常用的优化手段包括:

(1)网络建连优化:优化网络连接,让解析更快、链路更短。

(2)资源离线化:使用本地资源,直接省去网络请求。

(3)资源分级下发:根据机型信息差异化分发离线包,减少包体积。

(4)资源预加载:在当前页面空闲状态加载下一页面资源。

1、网络建连优化

(1)利用 DNS 预解析以及 DNS 缓存,让请求解析更快。

(2)利用 CDN、域名智能调度等方案,自动选择链路较短的服务。

根据经验,这块的优化能够节省数十 ms 到数百 ms 不等。

另外,网络建连优化并不单针对 H5 处理,而是对整个客户端请求都有收益,但需要有相应的基建配合。

2、资源离线化

资源离线化,即将 H5 资源提前下载(或内置)到 App 中。这样加载资源时就可以通过 App 内部的请求拦截机制转发本地资源,避免网络请求。

优化后,可以显著降低 「资源加载」 这个环节的耗时,减少白屏时间,一般不超过 100ms(仅剩资源解析和本地 I/O 耗时),不再受弱网限制。

不过,要实施一整套完整的离线化方案,需要考虑的点较多:

(1)更新策略: 紧急更新、轮询更新、冷启更新。

(2)动态差分: bsdiff 算法,获取不同版本离线包的差异。

(3)签名校验: 校验资源是否被篡改。

(4)在线 CDN:离线资源未找到的情况下使用 CDN 资源兜底。

此外,还需注意 “HTML 是否应该放入离线包”的问题,是选择更好的性能还是更好的更新速度。

3、资源分级下发

分级下发指的是根据用户设备信息(机型、系统等)下发不同的资源包,尽可能减少资源请求大小。即可以作用于离线包,也可以作用于在线 CDN(边缘计算)。根据业务经验,每减小 100 KB 体积,约带来 50 ms 的收益。

要实现资源分级下发,往往还需要前端打包配合改造。

举个例子:

(1)静态资源分级:相对低端的机型,内存以及分辨率并不高,那么页面中的图片就没有必要使用三倍图了。在前端打包的时候,对图片进行不同等级的分级压缩(参考 sharp 工具),得到多种版本产物。

(2)业务代码分级:除了资源外,还有一种极致优化,如果业务针对操作系统做了较多的适配代码,那么可以在打包的时候,编写打包插件,抽分得到两份不含系统兼容逻辑的产物。

根据系统版本获取相应系统的资源,这个好理解。但是机型这个怎么评估中高低端?

其实也很简单,建立机型库,维护机型评分。不同业务可以根据评分、系统版本等设定自己的中高低端机型范围。

针对某些机型的特殊情况,还可以建立一套白名单和黑名单机制。

这样,在客户端发起离线包资源请求的时候,带上机型信息,离线包平台/ CDN 服务就可以计算得到合适的资源包了。

需要注意的是,随着网络速度逐渐变快,该方案的性能收益越来越小。

但除了性能收益外,分级下发还有两个优势:

(1)节省带宽:离线包下发用到的带宽也是要钱的。

(2)优化 内存:中低端机使用较小的图片,可以避免内存溢出。

4、资源预加载

资源预加载指的是,在当前页面提前缓存下一个页面的资源。

在浏览器上,我们可以使用 Prefetch 实现这个效果,浏览器会在空闲时间下载指定的资源。例如:

<link rel="prefetch" href="/upload/luo_sha_hai_shi.jpg" />

该方案针对多页面有效。但在移动端上,我们通常不会选择 MPA (多页面),而是打开多个 Webview ,或者原生和 Webview 互相跳转。

因此,要想实现资源预加载,就得借助客户端能力: 在上一个页面(原生或者 Webview)调用 JSB ,传递需要预加载的资源地址,由客户端内部进行请求和缓存。

在实现细节上,还要做到请求复用。即在资源请求过程中,若发生页面跳转,则继续未完成的目标请求而不是重新创建。

此外,要实施资源 预加载还需要关注 3 个事:

(1)预加载时机:需要在空闲状态进行,避免和主逻辑竞争资源。

(2)预加载内容:一般由服务端下发,涉及三端配合(移动端、前端、服务端)。

(3)转换率收益:预加载会带来更高的资源带宽成本。如果前置页面到目标页面转化率只有 10%,那么请求数量会放大 10 倍,造成带宽成本浪费。

基于以上原因,资源预加载的使用场景往往有限,不是所有页面都适用。

五、代码执行

相比原生页面的提前编译/静态编译(AOT),JS 的动态编译(JIT)性能相对较差。在低端机上,这个差异更为明显。

因此,若要优化代码执行效率,有一个解决方案是 JS 代码 AOT 化。个人水平有限,不敢多说,提供两篇文章供拓展学习:

1.TypeScript/JavaScript低成本静态编译AOT的探索

2.V8 JS AOT化的探索与实践

此外,由于业务的动态性,不可能所有 JS 代码都走静态编译。

一个思路是,采用原生渲染或自绘制,首屏代码走 AOT,基于原生能力执行;后续业务逻辑代码走 JIT ,基于 JS Runtime 执行;两者通过 JSBridge 通信。

要完成这套工作,需要开发构建工具、渲染引擎等一系列套件,事实上已经脱离纯前端生态,因此,我并不打算将其列为前端首屏优化手段。

各大厂应该也有类似的框架,比如字节的 Lynx ,这里有一篇介绍文章:Lynx:来自字节跳动的高性能跨端框架。从个人使用经验上看,对首屏性能提升确实蛮大的,结合其他优化方案基本能做到首屏直出,与原生无异。

六、数据获取

前面环节执行完毕,此时已经得到一个骨架页面,待数据填充。

接下来就是数据获取部分,页面数据通过主接口获取,耗时在数百毫秒到数秒不等,和数据量、网络、服务链路有关。

要对这个环节进行优化,通常有两种手段:

(1)数据预取:提前获取。

(2)数据缓存:优先使用旧数据。

1、数据预取

数据预取指的是将数据获取时机前置,通常是与 Webview 初始化并行,并由客户端发起数据请求。

优化后,原来的数据获取阶段不再额外发起请求,而是复用客户端请求结果。如果请求还未拿到结果,则继续等待。

实际上前端无需额外处理,正常前端发起的请求也是走的 JSBridge ,统一由客户端在内部处理即可。

那客户端如何知晓请求参数?业界常用方案有以下三种:

(1)scheme 参数配置:将数据请求信息编码后放到 webview scheme 指定参数中

(2)json 文件配置:数据请求信息采用 JSON 维护,文件地址可以基于 scheme 参数配置,也可以约定固定地址,比如 https://页面路径/prefetch.json

(3)worker 运行时方案:前端编写 JS 函数并单独打包文件,客户端额外启动轻量级 JS 引擎(比如 tabris(j2v8)、quickjs)运行该文件。文件地址可以基于配置也可以基于约定。

方案对比如下:

方案灵活性实现成本限制
scheme 参数配置scheme过长,不易维护
功能较弱
json 文件配置较低有额外一次请求(通过离线化解决)
功能较弱
worker 运行时方案
可以自由控制请求和响应逻辑。
不只是请求,JSBridge异步获取的数据也可以返回
有额外一次请求(通过离线化解决)
对内存有一定影响,但是相比Webview内存占用小得多
无法使用window/document等JS Runtime提供的变量
客户端需要额外提供JSBridge,让前端获取worker请求结果

备注:大多数情况下,使用 json 方案即可,成本低,收益高。

2、数据缓存

将页面数据存入缓存;下次进入页面,优先使用缓存数据,同时发起请求以待后续页面更新。

需要注意的是,不是所有数据都适合缓存。

对于敏感数据、可能对用户造成较大误解的,不建议缓存,比如积分、金币、红包、金额等与钱有关的数据。

其他相对次要的数据,可以使用缓存,比如收藏记录、粉丝数数据等。

是否缓存依业务而定,没有严格的划分标准。另外,数据缓存还应该设定缓存时效,避免数据差异过大。建议缓存一小时,具体可以根据业务决定。

七、绘制渲染

获取到数据后,最后一步就是绘制渲染了,对应的是 LCP 这个数据指标。

严格来说,前面的资源加载和代码执行阶段也有页面渲染行为,但更多以页面骨架展现为主,对应 FP、FCP、FMP 等数据指标。

要对这个阶段进行优化,业界常见的方案是预渲染,即在上一个页面的空闲状态,提前渲染页面。

关于预渲染的实现,主要分为两种:

(1)Webview 完全预渲染:额外启动 Webview 容器并完整地加载页面。对性能影响较大,较少使用。

(2)NSR(Native Side Render):利用客户端原生做 SSR。核心思路是利用客户端启动一个 JS 引擎,执行数据请求 + HTML 文档输出,并将结果缓存。在后续的页面加载过程中,直接渲染 HTML 文档,并做 hydrate (水合)处理。

NSR 方案相比 Webview 预加载方案更轻量,对系统内存影响较小。本节着重讲第二种 -- NSR。

1、预渲染 NSR

在上一个页面的空闲状态,客户端进行 NSR 处理,提前请求数据、输出 HTML 文档并缓存。

后续加载页面,直接渲染内存中的 HTML 文档,并做 hydrate (水合)处理。

使用该方案后,平均能够提升数百ms,对 FCP 数据指标的收益较大。同时相比 SSR 方案,对服务端的压力较小。

然而,要实施 NSR 还需关注这 5 个问题:

(1)能力限制:由于跑在 JS 引擎上,会缺失 window/document 等运行时变量,因此需要做 mock 处理,同 SSR。此外,还需关注外部 npm 包的表现,避免直出报错导致白屏。

(2)转化率问题:预渲染需要在前置页面执行,会带来更高的服务端接口负载。如果前置页面到目标页面转化率只有10%,那么请求数量会放大10倍(比如原先只有 10 次用户请求,结果请求了100 次),造成带宽成本浪费和服务端压力。

(3)强依赖客户端和前置页面:需要改造前置页面,通过 jsb 去告知客户端做预渲染。

(4)数据时效性问题:由于缓存机制,首屏页面不是最新数据。若页面对时效性要求较高,则不适合使用。

(5)访问间隔问题:若前置页面很快就进入目标页面,则不推荐使用,会导致 NSR 命中率过低。

八、方案总结

阶段方案数据收益注意事项开发成本建议优先级
容器启动容器预建数十毫秒~数百毫秒创建时机
创建个数
P0
资源加载网络建连优化数十毫秒~数百毫秒 P1
资源离线化数百毫秒~数秒 P0
资源分级下发0~数百毫秒 P1
资源预加载数百毫秒~数秒预加载时机
预加载内容
转换率收益
P2
代码执行
数据获取数据预取数百毫秒~数秒 低~高(视具体方案而定)P0
数据缓存数百毫秒~数秒敏感数据不适合缓存P1
绘制渲染预渲染NSR数百毫秒能力限制
转化率问题
强依赖客户端和前置页面
数据时效性问题
访问间隔问题
P2

如果还未建设任何客户端优化方案,建议优先考虑容器预建、资源离线化、数据预取,能够取得不错的效果。

版权声明:本文为老张的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。

原文链接:https://www.webppp.com/view/h5_fast_open.html