GithubHelp home page GithubHelp logo

blog's People

Contributors

jasonfang93 avatar

Watchers

 avatar  avatar

blog's Issues

基于H5的白屏检测方案探索

现状

随着海外稳定性指标的持续建设,页面接口成功率、资源加载成功率、页面报错等相关的监控能够及时帮助业务问题,但对于页面白屏监控这块还处于空白阶段,虽然 Yoda 也有尝试白屏检测方案,但由于性能等方面的原因,线上开关始终是关闭的。目前白屏问题反馈仍主要依赖于用户主动反馈。因此通过技术手段来监控白屏指标、收集白屏相关原因是不可或缺的。

在方案开发实现之前,调研了业内的相关前端的白屏检测的方案,大体分为三类:
1、检测 DOM 节点是否渲染
2、通过截屏方式检测页面是否有内容
3、依赖浏览器支持的 FCP 特性来界定

这些方案优缺点比较也比较明显:
1、原理简单,但泛用性较差、缺乏成熟的检测算法;
2、检测准确,有成熟算法支持,但无论采用 Native 或 JS 截屏,均存在一定的性能影响;
3、浏览器支持的特性,使用简便、规范,但存在兼容性问题。
综合考虑以上方案的优缺点,结合自身实际业务场景和技术选型,最终基于方案一来设计落地方案。

落地方案

选定了大致方向后,便来着重看下方案一,方案原理很简单,当下主流的 SPA 框架,页面主体内容的 DOM 一般挂载在一个根节点下,例如 <div id="app"></div>,当页面发生白屏时,对应的现象一般表现为根节点下的 DOM 被卸载或一开始就未渲染挂载过。所以,当检测到根节点下没有挂载 DOM 时可以认为当前是白屏状态,在这个前提下,虽然可能就存在一些场景不满足这个条件,但对于当前海外使用 Vue 技术栈的 C 端业务而言,是满足的。
前面提到这个方案的核心在于检测根节点,那么对于检测时机又该如何来设计呢?

检测时机

最简单粗暴的方式便是通过轮询来检测,且不说对于页面性能的影响,仅是埋点上报数据的处理就有很大的成本,一方面数据量级不太可控,另一方面在这庞大的数据中,清洗出有效数据也得依赖数据团队支持,这在方案验证阶段几乎不太现实的;如果通过指定一些特定时机来检测,的确能避免轮询方案带来这些问题,但时机选择不合适对于方案的准确性也会带来影响,例如:
(1)时机过早,可能会将页面加载缓慢的 case 误判为白屏;
(2)时机过晚,可能还未检测用户已将页面关闭,从而出现漏判情况;
(3)页面加载完成后,可能某些用户操作背后的业务逻辑导致页面白屏情况,会被遗漏等。
因此,考虑到以上问题,最终引入了“白屏修正机制”,页面状态默认为白屏,然后通过一些时机来进行状态纠偏。具体如下:
检测时机.png

进入页面后立即初始化页面为白屏,设置值为 result=0,监听 DOMContentLoaded、beforeunload 事件,并通过 MutationObserver 监听 DOM 节点的变化,注意这两者间是并行处理的,两者间的交集只有页面的状态。只要 MutationObserver 检测到根节点有 DOM 变化,就会进行白屏感知检测,如果检测结果出现状态的切换,即白屏与非白屏的切换,就会更新状态值 result;而 DOMContentLoaded 和 beforeunload 事件主要被用于埋点上报,更详细的上报方案参加「埋点上报」。

最开始也有将 FCP 特性作为纠偏的一个重要指标,但是在回验阶段发现部分手机在白屏下仍会触发 FCP,所以只好暂时将该逻辑下线。
FCP异常.png

白屏感知

在「检测时机」中提到白屏感知依赖于 MutationObserver 监听 DOM 节点的变化,这里可能又会有疑问——为何不直接用 onerror 事件来做白屏感知的时机,在进行白屏检测的同时收集报错信息,并进行两者的关联,但这个前提是一切的白屏都是报错导致的,并均能被 onerror 事件捕获;即使可行,但这会将白屏检测和错误收集的逻辑耦合在一起。所以考虑到这些因素,最终采用了 MutationObserver 的方式来处理,关于与错误信息关联的部分下文「埋点上报」将会谈到。
使用 MutationObserver 监听目标节点,当有 DOM 变化时进行白屏感知——检测 DOM 节点是否渲染。

最初的想法是检测根节点(即<div id="app"></div>)下的元素长度,如果超过一定的阈值便认为是非白屏,明显这种检测过于粗糙;但如果对根节点下所有节点进行解析,逻辑较为复杂,多次触发检测对性能可能会有影响。

一般我们认为白屏表现为页面上没有任何内容,所以从这个角度出发,是不是只要检测到页面上有展示文本或图片即可认为当前是非白屏状态。因此检测感知做了一定优化:将根节点作为入口,进行深度优先遍历,在遍历过程中如果检测到有可见的文本或图片,即可认为当前为非白屏状态并可以立即结束遍历过程,如果遍历完所有节点仍未找到,即当前为白屏。

考虑到性能影响,在遍历过程中,如果某个父节点是不可见状态,便可退出当前树的遍历,跳到下一棵树,因为当前树的子节点以及后续节点均不可见;另外如果页面过于复杂,整个树的层级较深,也可以结合业务情况定制遍历的数的层级数。
当然为了保证检测结果更准确,也可以对树的节点分配权重,在遍历后计算权重总和,当超过一定阈值才界定为非白屏,这样可能需要在准确性和性能方面做一定取舍了。

埋点上报

主要埋点字段信息以下所示(非核心字段已省略):

name src message result count session_id
white_screen_detect 页面链接 上报时机(枚举值) 白屏状态,1为非白屏,0为白屏 纠偏次数,0即以上 每次页面访问时生成的唯一ID

埋点上报.png

再来看下这张增加了埋点逻辑的页面生命周期的图,前文有提到 MutationObserver 和 DOM 事件并行,埋点上报的时机主要有以下四种:
(1)MutationObserver 检测到目标节点变化:
(a)状态从白屏状态切换为非白屏状态,仅更新状态数据,写入 localstorage,不进行上报,纠偏次数自增;
(b)状态从非白屏状态切换为白屏状态,立即进行上报并清除 localstorage 数据,纠偏次数自增;

(2)DOMContentLoaded 事件触发:白屏状态且纠偏次数为 0 和非白屏状态两种情况,立即进行上报并清除 localstorage 数据,其他情况不上报;

(3)beforeunload 事件触发:非白屏状态切纠偏次数与 DOMContentLoaded 事件上报的纠偏次数不一致时,立即进行上报并清除 localstorage 数据,其他情况不上报;

(4)下次进入页面:以防上次页面异常关闭未能及时上报数据,在下次进入页面时立即检查 localstorage 中是否存在白屏检测相关数据,存在则立即进行上报并清除 localstorage 数据。

通过 MutationObserver 检测机制保证白屏情况下数据立即上报的同时,也避免了重复数据的多次上报,而后三个时机对「检测时机」部分提到的一些边界情况,例如时机过早误判,过晚漏判等情况,进行了较好的补充。

「白屏感知」部分提到的白屏信息与错误信息关联问题的处理,需要依赖错误收集和 CDN 加载重试上报的埋点,该插件能够比较详细地收集包括 js error、未捕获的 promise reject、console.error 和资源加载 error 等报错信息,白屏信息则可以通过埋点中的 radar_session_id 将两者进行关联。

看板建设

通过前面的各种方案设计和实现,收集到了一定的数据,将之对应埋点转化为 grafana 指标看板。
通过「埋点上报」部分的说明,可以了解到,虽然做了埋点日志上报频率和条数的优化,但每个用户每次访问会上报1 到多条日志,日志条数与状态切换的次数呈正相关;而当次访问的结果是确定的;所以必须从1到多条数据中取出最终结果,即基于唯一 radar_session_id 和最大纠偏次数 count 筛选出最终结果。

示例 sql 如下:

SELECT
  t_a.*
FROM
  (
    SELECT
      t1.*
    FROM
      event_table AS t1
    WHERE
      t1.p_date = '20211213'
      AND t1.name = 'white_screen_detect'
      AND t1.session_id IS NOT null
      AND t1.session_id != ''
  ) AS t_a,
  (
    SELECT
      t2.session_id,
      max(t2.count) AS `max_count`
    FROM
      event_table AS t2
    WHERE
      t2.p_date = '20211213'
      AND t2.name = 'white_screen_detect'
      AND t2.session_id IS NOT null
      AND t2.session_id != ''
    GROUP BY
      t2.session_id
  ) AS t_b
WHERE
  t_a.session_id = t_b.session_id
  AND t_a.count = t_b.max_count;

在进行数据分析置信时发现,无论页面是否白屏,用户都可能主动取消,通过上述的埋点策略,用户误操作后立即主动退出 case 的数据也可能上报,对页面白屏率指标的分析存在一定影响,因此结合埋点的上报时机(即埋点的 message),约定通过 localstorage 上报且纠偏次数 count=0 的白屏数据置为脏数据,在一定程度上能提高白屏率指标的准确性。

效果

通过白屏检测不仅能帮助业务很好地提前发现问题并预警,而且借助于错误信息的收集,能够进行白屏异常的错误关联,在一定程度上降低排查问题的难度,提升问题解决效率,从而进一步提升服务稳定性。以一个具体线上 case 为例来看下效果:
image
image
某次上线发布离线包后,task-center 页面白屏数暴涨,对应的 JS 错误量同步上涨,实际验证打开页面稳定复现白屏,快速回滚后,白屏数和 JS 错误量较快回落并逐渐恢复到原来水平,经验证是离线包异常,文件被截断导致的。

总结

简单来说,上述白屏检测方案主要基于 MutationObserver 检测 DOM 节点的策略,针对页面挂载于特定根节点的业务场景下设计落地的,在海外激励方向进行了线上的验证和数据收集,在此过程中,先后几次出现问题时,白屏率指标均有同步反馈,且上线后对于页面性能指标影响可控。
通过上文对白屏检测的探索的介绍,希望对大家能有一定的启发和帮助,如果有相关方面的心得,欢迎交流。

Hybrid容器之离线包的原理与应用

背景

公司春节活动第一次全面采用 H5 的方式承接,这对于 Hybrid 跨端容器来说是一场极其重要的考验, Hybrid 跨端容器作为活动在端内的承接方,一直被业界诟病的 Hybrid 方案性能问题是不得不直面的挑战之一,因为性能指标的好坏将直接影响活动的效果。
离线包是业内优化 Hybrid 页面性能方案之一,简单的说,就是将 H5 页面需要的静态资源打包上传到 CDN 服务,用户提前将之下载到本地,后续打开页面直接从本地读取资源,不仅能缩短获取资源的时间,还能够减少对线上资源的请求,缓解部分 CDN 压力。
在春节活动期间,离线包承载的任务包括页面性能提升和分流部分 CDN 静态资源负载压力。离线包的包体积、下载覆盖率与最终离线包的性能表现密切相关,这些指标都依赖于服务接口和 CDN 资源,与此同时春节活动本身带来的服务端接口负载压力大,机器、CDN 资源紧张等问题难以避免,所以在有限资源支持下,如何完成既定的目标,是 Hybrid 跨端方案团队不得不攻克的一道难关。

优化与实践方案

离线包的使用流程主要分为打包发布、下载更新和拦截使用三个部分,在「Hybrid性能优化之离线包加载」文章中有介绍离线包的打包发布逻辑,那么笔者将结合春节活动实践,继续来介绍打包发布、下载更新和拦截使用相关的功能。

打包构建

在上一篇文章中有详细介绍离线包打包构建逻辑,本质上是对文件进行静态解析,获取文件依赖关系,并且未对 JS 文件进行解析,使得抓取资源并不是完全的,所以最终支持了直接对项目构建产物直接进行打包生成离线包,manifest.json 中的 header 信息默认生成,当然如果有特定 header,打包工具支持自定义,并且兼容原有打包逻辑,可以和在线抓取的逻辑配合使用。
前端页面多采用单页应用的方案,不同的入口或 URL 地址实际对应的仍是同一份资源文件,按原有逻辑都会将这些重复资源打入离线包中;上文有提及春节活动期间,CDN 资源本身紧张,所以在打包构建时,会根据文件内容 MD5 去重,同样内容的文件整个离线包仅保留一份,只在 manifest.json 的关系映射中保留这些重复资源请求信息,并通过 alias 字段将离线包唯一保留的资源和已被去重的资源信息做关联;这样既保障了离线包 size 更小,也不影响页面静态资源命中数量。

发布更新

离线包打包构建完成后,便需要将之发布到离线包服务供用户下载更新。为保证离线包发布的覆盖率,节省 CDN 资源,Yoda 离线包增量更新方案采用了业内成熟的 bsdiff 算法做差分,服务端基于当前离线包前几个版本生成对应差分包,客户端根据本地实际的离线包请求对应版本差分包进行 patch 合并更新,实际测试未变更的离线包差分包仅几百字节;离线包改动 100KB,差分包也是 KB 级别,当然实际的差分包也和本身改动的文件类型、改动大小等因素有关。

配置获取

当离线包发布成功后,用户及时获取配置信息也是离线包覆盖的关键之一,目前 Hybrid 跨端方案支持多种方式获取对应离线包的配置 —— 冷启动、APP 切前后台比较好理解,轮询则是借助于 APP 端上的 clock 接口实现了 2min 一次获取配置,当然也支持业务在代码层面通过 JSBridge 主动获取离线包信息,即使是当前正打开的页面,也可借助 Bridge 事件回调,由业务来控制是否需要更新,使用新的离线包加载页面。

获取配置.png

当正确更新到离线包信息,Native 层也有在 Cache 中做缓存优化,使得离线包信息的读取更加快捷。

下载

即使用户获取到离线包配置,不一定会立即进行资源下载。下载时机是由业务决定的。目前 Yoda 支持预加载、WIFI 预加载、点击加载和预置四种方式。预加载、WIFI 预加载的方式是当获取到离线包配置便会立即向下载器提交下载任务,唯一的区别只是 WIFI 预加载需要检查当前的网络环境是否是 WIFI。点击加载则是只有当用户真正打开页面后才会触发下载任务,虽然更新可能不一定那么及时,但是在春节期间,多数业务更新频率不高的场景下,通过这种方式在一定程度上可以避免无用资源下载,减少了 CDN 资源、带宽压力等。
另外,春节期间,端内离线包总数达到 80+,为保证春节活动等离线包覆盖率,对离线包进行了下载等级划分,像春节这种 S 级活动或离线包使用 Top10 的项目,支持高优下载配置。
当然从上文简要介绍离线包的信息中,应该有注意到用户在使用前,需要提前将离线包资源下载到本地,那么对于有拉新目标的项目,等待离线包下载前,访问页面直接从线上获取资源,对性能体验影响很大,所以 Hybrid 跨端方案已经支持了离线包预置,相当于把下载时间提前到了 APP 下载阶段。即使是新下载 APP 的用户,打开预置了离线包的页面,也能立即享受离线包加速,提升了用户体验。

更新配置.png

更新

目标是美好的,借助于离线包能力,在打开页面时,将其依赖的资源都从本地读取,减少了建立连接获取线上资源的时间,的确是可以提高页面打开速度,缩短可交互时间;但是带来的问题便是离线包更新,无论采用何种下载方式,都存在使用旧版本离线包的概率。如果本身新版本离线包是向前兼容旧版本,问题可能没那么大;但如果是 break change,使用旧版本离线包可能会导致线上故障等。像春节活动有多次改版调整,内测、公测阶段反馈旧版本的 bug 等皆是因为这个原因。最终 Yoda 支持了更新配置设置 —— 新版本离线包下载成功前使用旧版本离线包、获取新版本离线包配置后立即删除旧版本离线包,业务可以根据自身需求场景,自行选择合适的更新方式。

限流

APP 使用离线包的项目较多,封网前离线包发布较为频繁,且离线包 size 大小不一,为了保证离线包下发的覆盖率的同时,避免导致 CDN 带宽和 https QPS 报警,不得不设计相应的限流策略。
日常迭代项目离线包以 JS 文件为主,整体 size 偏小,平均不到 1M,总数较多;而春节活动项目包含动效、头图、音视频等多媒体素材较多,生成离线包时可压缩空间有限,整体 size 偏大,平均 10M 左右,但总数不超过 10 个。大小离线包混合下发,如果下载请求并发较高,特别容易出现 CDN 带宽和请求 QPS 尖峰。
为了解决这个问题,结合离线包实际情况和 CDN 下发特点,将离线包以 5M size 的阈值分为大小包,每天 18 点至 24 点定为忙时模式,其他时段为闲时模式。闲时模式下,5M 包以上限速为 5W QPS, 5M 包以下限速 20W;忙时模式下,5M 包以上限速为 2.5W QPS, 5M 包以下限速 10W QPS。通过限流策略,虽然 CDN 的问题解决了,但是这也导致忙时阶段小包被限流严重,大包下发速率远超小包。
限流策略影响了离线包的覆盖率,不得不对其进行优化调整:仍然保留原策略的按包大小和时段下发,但是在并行下发大小离线包时,对两者权重进行调整 —— 在 1s 内当出现大离线包被限流,小离线包立刻停止下发,当大包不再限流后,逐步恢复小包下发。在此基础上,可以对原有限流阈值进行向上微调,并通过人为控制下发大包频率和时段,便可确保 CDN 服务稳定性的同时减少小包限流时间,加快离线包覆盖。

资源命中

离线包资源下发到本地,客户端是如何完成资源加载,加快页面打开速度的呢?
在端内使用 WebView 加载 H5 页面,Native 可以拦截到页面发出的所有请求,通过 URL 上是否携带 Hybrid 跨端方案定义的 hyId(离线包ID)可以控制是否使用离线包,并通过 hyId 作为唯一标示进行资源查找,我们知道离线包构建时会保存资源完整的请求路径,Native 会结合 hyId 和资源路径作为命中判断依据,如果本地有对应资源,会进行读取,加上构建时保存的 header 信息生成资源响应头返回给 H5,当然 Native 在读取资源和配置时也有做 Cache 优化,加快文件的读取和请求响应。即使本地没有对应资源,Native 会将请求拦截解除,请求正常到线上服务获取资源,对于 H5 而言,无论是从 Native 读取构造的资源响应还是线上 CDN 服务返回的,两者是基本没有差别的,获取到资源后便会开始正常的页面渲染和展示。

思考总结

通过这些优化, Hybrid 跨端方案双端稳定支持预置离线包,多离线包,差分离线包,在春节主会场和预热活动,留存活动都已经验证。主会场活动双端离线包覆盖率在活动期间(非CDN故障时间)在 97% 以上,一个小时左右完成 80%+ 覆盖。通过离线包稳定加速,保障了活动的体验,并为 CDN 扛住请求高峰贡献了一份力量。
当然离线包服务还有进一步优化和提升的空间,例如结合 Service Worker,支持预渲染功能,优化页面加载速度;与现有的 CI 流程配合,使用自动化方案代替手动操作,降级接入成本;完善 Debug 排障工具,提高业务开发排障效率等等。在后续迭代优化阶段,这些都将是 Hybrid 跨端方案团队会重点优化探索的方向。限于篇幅,文中有些方案细节未展开详述,行文如有错漏,欢迎斧正。

CDN静态资源加载重试探索

背景

浏览器缓存在页面再次加载时能够提升加载速度,但是对于首次加载速度的提升,通常是借助于 CDN 加速;并且对于访问量大、并发高的应用来说,CDN 的负载均衡能力也是不可或缺的。所以在部署上线阶段,除 HTML 文件外其他静态资源一般都会上到 CDN 服务。虽然 CDN 运营商可能宣称高可用性,但也难免出现网络环境差,甚至域名过期、被封禁等黑天鹅事件发生;不同 CDN 服务厂商在不同地区的情况也可能存在稳定性差异。如果静态资源加载异常,可能会导致页面渲染异常或功能与预期不符,如果是核心逻辑的 js 等资源加载失败,更可能直接导致页面白屏。

为了能提高服务的稳定性指标、给予用户较好的使用体验,页面静态资源加载异常时能够进行 CDN 资源地址自动切换并尝试重新加载,通过相关监控及时发现静态资源加载异常情况,是不可或缺的。那么对于静态资源的加载重试方案的重点就拆解为如何判断资源加载异常和如何正确重新加载两个部分。

业界方案

通过调研了解到,目前对于资源加载失败的监控方案主要分为以下三类:
(1)在构建阶段,额外插入检测代码,例如:在 CSS 文件中插入与主体功能无关的样式,再通过 JS 插入对应节点并获取样式进行验证;JS 文件中注入检测变量,再检测变量是否存在等,当不符合预期则进行重新加载。
(2)通过 performance.getEntries 方法获取成功加载的资源,与总资源进行比较,判断是否存在资源加载失败。
(3)添加全局的异常捕获,通过事件类型,目标元素等区分出资源加载异常的报错,或借助于 script、link 和 img 等标签的 onerror 属性。

加载重试针对不同场景也有着对应的方案:
(1)标签直接加载:主要借助于 document.write 立即插入重试的 script 等标签,或者在加载失败的文件后立即执行 createElement 新建对应标签并插入实现重试;
(2)createElement 异步加载:随着代码拆包和 webpack 等支持动态导入功能支持后出现的场景,主要还是借助于 webpack 等的插件,hook 对应动态导入逻辑来支持失败重试,例如 webpack-retry-chunk-load-plugin 等。

落地方案

通过了解了上述这些方案,或多或少有一些限制或前提条件,那如何在自己负责的业务上落地呢。

监控

监控方案一需要插入检测代码,会对源码有一定侵入性;方案二对于检测时机有要求;方案三捕获的错误较多,需要将资源加载异常的错误筛选出来。
相比较之下选了方案三,在具体实施时根据场景做了区分,对于 createElement 异步加载资源的情况,主要借助于创建的标签的 onerror 属性,这在重试部分再来详细解释;而通过内联标签的方式加载的资源则通过全局监听 error 事件来处理。

document.addEventListener('error', (event) => {
	const target = event.target || event.srcElement || (Array.isArray(event.path) && event.path[0]);
  // 非 script、link 和 img 标签的错误直接不处理
  if (!(target instanceof HTMLScriptElement || target instanceof HTMLLinkElement || target instanceof HTMLImageElement)) {
  	return;
  }
  // 解析错误信息获取到资源链接,结合错误类型选择对应的方式进行重试
  ...
}, true);

注意,通过该方式需要在捕获阶段处理,onerror 事件不会冒泡;另外如果报错信息是 Script error,则需要额外处理同源策略限制。

重试

通过内联标签加载的资源当首次加载失败时,会触发全局监听的 error 事件,根据标签类型分别进行重试加载:
(1)img 标签:该情况较为简单,直接重新设置 img 标签的 src 属性即可重新加载图片;
(2)link 标签:通常 link 标签主要用于加载 CSS 资源,当加载失败时直接 createElement 创建 link 标签加载即可;
(3)script 标签:当未设置 defer 或 async 属性且当前页面 document.readyState 仍为 loading,即页面仍是加载中,则可以直接通过 document.write 写入新链接的 script 标签;其他情况则通过 createElement 创建 script 标签并插入 head。

document.addEventListener('error', (event) => {
	const target = event.target || event.srcElement || (Array.isArray(event.path) && event.path[0]);
  ...
  if (target instanceof HTMLScriptElement) {
  	if (document.readyState === 'loading' || target.defer || target.async) {
    	const scriptHtml = target.outerHTML;
      // 更新资源链接
      ...
      document.write(scriptHtml);
    } else {
    	const elem = document.createElement('script');
      elem.src = 'xxx.js';
      document.getElementsByTagName('head')[0].appendChild(elem);
    }
  }
  ...
}, true);

资源加载顺序对于图片和样式文件而言比较好处理,然后对于 JS 资源,资源加载顺序直接关联到逻辑能否正常执行,多个文件之间可能存在关联关系,尤其是 webpack 等构建工具支持拆包优化、异步加载等功能被广泛使用后,使得在资源加载失败后进行重试加载时必须保证文件的加载和执行顺序。
如果手动来维护各个 JS 间的依赖关系和执行顺序,有较大的维护成本,而像 webpack 等构建工具已经支持了这个功能,可以自行了解下 webpack 构建产物中定义的 checkDeferredModules 方法,在执行对应模块代码前,会检查它所依赖的其他模块是否全部加载完成,而对于其中的异步加载的模块,则是借助于 Promise 状态不可逆的特性来确保模块加载结果准确,通过 createElement 创建 script 标签加载资源,加载成功触发 resolve,否则触发 reject。当加载某个资源失败了,触发 reject 后,checkDeferredModules 最终检查无法通过,整个逻辑无法继续向下执行了。
异步资源加载重试的方案就基于 webpack 异步加载逻辑进行了设计,当资源加载失败后,在 reject 触发前主动接手这个错误,并在这期间完成资源的加载重试。从 webpack 构建代码可以知道整个过程是在 createElement 创建的 script 标签的 onerror 回调中处理的,所以重试方案变成了 hook createElement 方法,当给创建的元素设置 onerror 属性时,执行 hook 逻辑 —— 通过 Object.defineProperties 创建的 script 标签,在 setter 中,拿到 webpack 设置的 onerror callback 作为原始 callback,并设置自定义的 onerror,在 onerror 中重试加载资源,当多次重试均失败或触发了 webpack 构建时本身设置的超时,则调用开始保存的原始的 callback;如果重试成功则会按照异步加载成功的流程继续向下执行。

const hookedCreateElement = hooked(createElement);
const elem = hookedCreateElement('script');
Object.defineProperties(elem, {
	'onerror': {
    set: (newVal) => {
    	elem.onerror = () => {
        // count 重试次数
      	if (count > maxCount || timeout) {
        	newVal();
        } else {
        	// 重试加载
          ...
        }
      };
    },
  },
  ...
});

根据以上的几类场景采用对应的重试加载的策略,便是整个 CDN 静态资源加载重试的方案了,多数的问题都能较好的覆盖到,当然,对于在样式中通过 background-image 使用图片的场景目前还没有较好的解决方案,最初的想法是多添加几个 URL,但是部分场景下会出现问题,当有透明通道的图片会出现颜色加深的情况。对于这类场景可以考虑换用 img 标签加载代替背景图,或者考虑使用相对路径,结合 css 资源的域名进行重试。

埋点

为了便于对 CDN 静态资源加载情况的监控和观察,以及对于方案收益的衡量,在资源加载和重试时进行埋点上报 —— 上报资源链接、加载成功与否和重试次数即可。通过这几个指标可以分别统计出资源首次加载的成功率、重试成功率、重试次数分布等。

总结

结合收集的结果来看,CDN静态资源加载重试方案对于用户打开页面的成功率、体验的提升和服务稳定性指标的建设都有着正向的收益。

Hybrid性能优化之离线包加载

背景

随着移动互联网的迅猛发展,移动端开发技术随着时代的要求不断演进,出于对性能和迭代效率的权衡,无论是最初的客户端原生开发,到 phoneGap 演进的 Hybrid 方案,还是前两年大火的 RN、weex,到如今各大技术峰会津津乐道的 flutter 方案,在当下移动开发中都占有一席之地;而笔者将要讨论的仍是多数公司、团队优先考虑的跨端解决方案 Hybrid。

Hybrid 混合开发方案支持跨平台、开发成本相对较小、方便业务快速迭代等特性,但是只要从事过移动端 Hybrid 项目开发,应该能发现这种方案存在着性能瓶颈、用户体验与原生应用存在差异、拓展性较差等问题;所以在这种大背景下,提升 webview 性能从而提升整个应用用户体验是采用 Hybrid 方案时不得不直面的问题。随着技术的演进和沉淀,业内对于 Hybrid 性能提升的方案也愈加成熟;下文将要讨论的离线包方案便是其中之一。

正式讨论离线包方案之前,不妨先了解下 Hybrid 方案中 webview 加载移动页面正常的流程。

页面加载流程

从 webview 初始化到 H5 页面最终渲染完成,其中 DNS 解析及建立连接、下载 html 页面及依赖的静态资源占据了很大的比例。因此,在这两个阶段如果能进行优化,势必能够缩短首屏加载耗时,从而减少用户等待时长,达到提升性能的目标;其中一种优化的方案就是本文重点要讨论的离线包加载方案。

什么是离线包

离线包可以简单理解为是将 html、css、javascript 等代码文件,图片、字体和音视频等静态资源文件等按照约定的规则通过文件夹目录结构进行组织,最终打包到一个压缩包中;根据项目要求设置对应下载、加载规则后发布到离线包更新服务;APP 下载离线包到本地,当打开页面时直接从本地加载离线包资源。整个方案涉及到前端、服务端和客户端三端,需要相互配合完成,下文将结合 H5 混合开发中台的方案进行介绍。

离线包流程

离线包构建

前端的工作包括离线包构建和相关配置发布。离线包构建流程需要依次进行项目资源解析、资源下载、打包上传三个重要步骤。

项目资源解析可以抽象为图的深度优先遍历,解析前需要先指定解析的入口,虽然一般是项目的入口 html 文件,但也是支持指定css或图片等特定类型文件作为入口的,原理基本一致。

  • 将指定的入口文件作为根节点,将其标记为已访问,解析文件内容;
  • 取到第一个依赖的子文件,该子文件将作为新的入口文件,即新的根节点。重复此步骤,直到作为新的入口文件不存在未访问过的子文件为止,最后将该文件放入解析成功的列表中备用;
  • 返回上一个已访问且仍有未访问子文件的根节点,找到该节点下一个未访问的子文件并访问;
  • 重复2,3步骤,直到项目依赖的所有文件都被访问过。

资源解析示例.jpg

通过遍历解析获取当前项目依赖的所有静态资源文件列表,注意在解析过程中可能存在相对路径文件,需要对其进行绝对路径转化后再解析。

解析得到的列表文件并非都需要打包到离线包中,根据业务需要进行筛选,对选定需要打包的文件依次进行下载,以文件实际可访问的 CDN URL 地址作为目录层级结构,这样有利于 Native 在匹配静态资源时更便利,在后续内容中将会提及;同时需要生成一份静态资源映射关系表 manifest.json,包括离线包中所有静态资源对应的 URL 地址、Content-Type 和 header 等信息。

{
    "https://m.xx.com/xxx/xxx.html": {
        "Content-Type": "text/html",
        "headers": {
            "content-encoding": "gzip"
        }
    },
    "https://m.xx.com/xxx/xxx.js": {
        "Content-Type": "application/javascript",
        "headers": {
            "content-length": "13525",
            "access-control-allow-origin": "*",
            "access-control-allow-methods": "GET, POST, HEAD, PUT, DELETE",
            "access-control-max-age": "2592000",
            "cache-control": "max-age=63072000",
            ...
        }
    },
    ...
}

最后将 manifest.json 文件和已下载的静态资源文件打包到一个压缩包中,压缩包命名除了离线包项目名、版本信息外还有一个加密的 hash 序列,该序列用于客户端解析时对离线包安全性、完整性的校验。当生成的离线包上传到 CDN 服务时,标志着离线包构建流程完成。

配置发布

虽然离线包构建发布到了 CDN 服务,此时客户端还无法拉取到,还缺少相关配置信息的关联发布。一个离线包是否被启用,如何进行加载,Native 下载离线包到本地后如何进行更新,离线包如何灰度、何时全量下线等,在实际业务场景下,都是需要考量的。从技术层面,这些控制策略以配置信息的形式和离线包一起发布。
离线包方案引入 HybridID(离线包ID)概念,只有当页面访问时命中 HybridID,才会运行离线包相关逻辑,从而控制项目是否启用离线包加载。Native 加载离线包支持预加载、WIFI 预加载和点击加载,预加载、WIFI 预加载方式在 APP 启动或激活时主动下载离线包并完成加载,唯一的区别在于对用户网络环境的要求,预加载在任何网络环境均会触发,WIFI 预加载顾名思义了。

离线包下载到本地后需要做持久化处理,否则每次启动 APP 均需要下载,不但达不到提升性能的目的,还会占据请求连接数量,浪费用户流量。但持续化处理也带来一个问题 —— 离线包迭代发版,新下载的离线包和本地持久化的旧版本如何处理。为保证更新不会出错,全量替换是一个比较稳妥的办法,但是每次全量更新,在代码改动较小时会导致离线包过大、浪费用户流量、下载时间变长、下载失败率升高等;因此针对这些问题提出了增量更新的方案,为尽可能减小离线包大小,需要采用差分算法,计算出新发布的包与上一版离线包间的差量,然后下发给 APP。目前业内有在使用bsdiff算法做差分,感兴趣的可以自行研究,此处不做详细说明。

最后离线包状态支持灰度和全量,灰度规则以规则引擎为基础,支持机型白名单、APP 版本、地域规则、放量时长、用户白名单等8类灰度规则。考虑到整个规则比较复杂,篇幅限制等,具体方案设计将在离线包加载下篇中进行讨论。

考虑到离线包的稳定性和灵活性,对于上述的几类配置做了区分:离线包 URL、加载方式和更新方式等每次更新必须生成新版本;而离线包灰度规则,灰度全量状态切换,自动下线时间等调整比较灵活,无需新发版本即可生效。

总结

本文结合 H5 混合开发中台方案从整体上介绍了离线包方案,并详细介绍了前端的方案细节和部分 server 端的方案设计。下篇中将继续完善灰度规则、离线包更新策略等相关方案细节,以及 Native 层拦截策略等,从而能对离线包方案有一个完整的认识和理解。最后如果行文中存在错漏,欢迎斧正。

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.