乐趣区

关于javascript:58RN-页面秒开方案与实践

文中 metro-code-split 工具已开源,反对 RN 拆包和 dynamic import,欢送大家 start。

https://github.com/wuba/metro…

以下是注释。

明天和大家分享的主题是《58RN 页面秒开计划与实际》。先自我介绍一下,我叫蒋雄伟。我在 2015 年入职的 58,在 2016 年开始在 RN 方向上开始摸索,这几年来,也推动不少 RN 性能计划的落地。在落地的过程中,一个被常常到的问题是:

做性能优化,耗时是升高了,但对业务来说有什么收益呢?

第一次,我的确被问住了。我就带着这个疑难,做了一个试验,去统计首屏工夫和拜访流失率的关系。我发现了一个有意思的法则。

首屏工夫每升高 1 s,拜访流失率升高 6.9%。

预测回头看,实际效果真有 6.9% 这么好吗?

咱们拿着 6.9% 的收益数据,推动了几个业务进行了落地。整体来讲,预测的流失率收益跟理论的流失率收益其实是差不多的,但有分化。具体来讲,性能好的页面比预期收益差,性能差的页面比预期收益好。这也很好了解,性能好的页面,流失率曾经很低了,进一步优化空间曾经很小了。

当咱们晓得性能优化的收益会分化后,天然把更多的关注重点,落在了那些还没有实现秒开的页面上。咱们就设计了几个指标,包含流失率、首屏工夫、秒开收益。

流失率、首屏工夫是预先指标。而秒开收益指标,是一个事先指标,它会通知你,如果你的页面实现了秒开,你的流失率会升高多少?咱们心愿用这一系列的指标,来驱动业务进行性能优化。同时,咱们也会给业务提供一些低成本、甚至无老本的优化计划,帮忙业务的节约优化老本。

指标驱动业务,业务抉择计划,计划进步收益,这就是咱们构想的一个收益驱动的模型。

首屏工夫的采集计划

本次的分享也会围绕着计划和指标这两块进行具体的开展。先说下指标这一块,最重要的指标是首屏工夫,首屏工夫算进去了,流失率和秒开收益其实就算进去了。因而,本次分享分为以下三个局部。

  • 第一局部讲的是,首屏工夫的采集计划
  • 第二局部再具体讲讲,性能优化的计划
  • 最初跟大家总结和瞻望一下。

咱们先来看一个页面的加载流程,它大略有 5 个阶段:

  1. 0 ms:用户进入
  2. 410 ms:首次内容绘制 FCP
  3. 668 ms:业务组件 DidUpdate
  4. 784 ms:最大内容绘制 LCP
  5. 928 ms:可视区加载实现

第 2、3、4、5 个工夫点,都能够定义为首屏工夫。首屏工夫定义不一样,耗时也不一样,而且差距十分大。因而,咱们须要先抉择一个指标,作为首屏工夫的定义。

首屏工夫咱们抉择的是 LCP 这个指标。为什么呢?

  • 第一,是因为 LCP 是最大内容绘制,这个时候页面中的次要元素其实曾经展现进去。
  • 第二,是因为 LCP 能够实现非侵入式的采集,不须要业务手动的去埋点。
  • 第三,是因为 LCP 是 W3C 的草案,这是一个重要的起因。你通知他人,你的首屏指标是 LCP,他人就懂了,不必过多解释。

为了能让大家更好的了解 LCP 算法的实现,先给大家铺垫一下。

简略来讲,LCP 就是你看失去的最大元素,它渲染进去的工夫。然而这里存在一个问题,比方咱们第 2 张图片的最大元素,和第 5 张图片的最大元素,不是同一个元素。不同的元素,渲染进去的工夫是不一样的,LCP 也不一样。也就是说,一个页面有多个 LCP,上报哪个 LCP 呢?应该上报最终收敛的 LCP 值,也就是可视区加载实现时的 LCP 值。

LCP 是 Web 的规范,在 RN 中并没有实现,应该怎么实现呢?

整体上讲,实现上大抵分为 5 个步骤:

  1. 在用户进入时,由 Native 线程记录 Start 工夫戳。
  2. 由 Native 线程将 Start 工夫戳注入到 JS Context 中。
  3. 由 JS 线程,监听页面中渲染元素的布局事件。
  4. 由 JS 线程,在页面渲染过程中进行计算,并不断更新 LCP 值。
  5. 由 JS 线程,计算失去 End 工夫戳,并上报最终的 LCP 值。

此时,最终上报的 LCP = End Time – Start Time。

其中难点是怎么收敛 LCP,也就是如何判断可视区齐全加载。咱们采纳的规定是,当所有元素都加载实现,且底部的元素也曾经加载实现时,可视区加载实现。元素有一个调用周期,先调用 render,再调用 layout。只调用了 render 的元素,是没有加载实现的元素。调用了 render 且调用了 layout 的元素,是加载实现的元素。可能判断一个元素是否加载实现了,也就可能判断可视区是否加载实现了。

性能优化计划

讲具体计划之前,先来讲一下,咱们性能优化的整体思路。

做任何性能优化之前,咱们要先剖析性能的构造是什么,而后找到性能瓶颈,依据瓶颈来出具体的优化计划。

一个 RN 利用的性能构造,整体上看,分为 2 个局部,Native 局部和 JS 局部。再具体一点,又能够分为 6 个局部。以下是一个未优化的、比较复杂的、动静更新的 RN 利用的耗时构造:

  1. 版本申请 200 ms
  2. 资源下载 470 ms
  3. Native 初始化 350 ms
  4. JS 初始化 380 ms
  5. 业务申请 420 ms
  6. 业务渲染 460 ms

从大体上讲,上述 6 个构造,能够分为 3 个瓶颈。

  1. 动静更新瓶颈,占比为 29%。
  2. 初始化瓶颈,占比为 32%。
  3. 业务耗时瓶颈,占比 39%。

瓶颈一:动静更新

互联网产品有一个特色就是疾速试错,这就要求业务可能疾速迭代。为了反对业务疾速迭代,这就要求利用可能动静更新。动静更新,必定要发申请,要发申请就会拖慢性能,比方 Web。如果和 Native 一样,进行资源内置,性能会好很多,但又如何动静更新呢?

动静更新和性能仿佛是一对矛盾体,有什么衡量之策吗?

咱们开始想到的计划是,通过资源内置来进步页面性能,通过静默更新来动静更新。

当用户首次进来时,因为曾经有内置资源了,所以不会有申请,页面能够间接渲染进去。与此同时,Native 线程会并行静默更新,询问服务端是否有最新版本,如果有就会下载 bundle,更新 cache。这样当用户下次进来的时候,能够应用到上次缓存的资源,间接渲染页面,同时并行静默更新。依此类推,用户每次进入时,都没有申请,能够间接渲染页面。

设计静默更新时有一个小细节须要留神。用户每次都应用的是上次缓存的资源,而不是线上最新的资源。因而存在一种危险,一个有重大 BUG 的版本被用户缓存下来了,且不能失去更新。为此,咱们又设计了强制更新性能。在静默更新胜利后,由 Native 线程告诉 JS 线程,由业务依据具体的状况决定是否强制更新到最新版本。

资源内置 + 静默更新计划也有一些毛病:

  1. 减少 App 体积。对于超级 App 而言,体积曾经十分大了,要减少体积很难。
  2. 新版本覆盖率低。72 小时新版本覆盖率为 60% 左右,绝对于 Web 计划来说比拟低。
  3. 版本碎片化重大。多个内置版本和屡次的动静更新,会导致版本碎片化的问题,推高了保护老本。

因而,咱们进行了一些改进。

用资源预加载,代替了资源内置。这就很大水平防止了包体积、覆盖率和碎片化的问题。静默更新仍旧保留了,来更新可能呈现的 BUG 版本。

资源预加载的话题其实曾经讲烂了,我这里只从“权力”的角度,帮大家剖析一下。

谁应该有预加载的权力?是 RN 框架,还是具体业务?把权限给框架,框架是能够把所有页面的资源都预加载了,但这样做显著效率很低,对于平台级 App 而言,一个 App 有几十个甚至上百个 RN 利用,大部分预加载的资源用户用不上,这就造成了节约。把权限给业务,让具体业务一个个加载,又十分麻烦。

信息即权力,谁领有信息,权力就给谁。最开始,框架没有任何有用信息,但业务能够依据业务数据,晓得跳转到具体页面的比例,因而调用预加载的权力应该给业务。当用户曾经应用过某个 RN 利用后,框架时晓得这个信息的,这时权力应该给框架。框架能够在 App 启动后,进行版本预申请。

针对动静更新瓶颈,咱们应用了资源预加载和静默更新的计划。耗时从未优化的 2280 ms,升高到了 1610 ms,降幅 29%。

瓶颈二:框架初始化瓶颈

首先,咱们剖析一下为什么框架初始化很慢。

JS 线程和 Native 线程是异步的通信的,每次通信都是通过 Bridge 进行序列化和反序列化实现的。在通信之前,因为不在一个 Context 中,JS 线程和 Native 线程是互相不晓得彼此存在的。因为 Native 不晓得 JS 会应用哪个 NativeModule,所以 Native 须要初始化所有的 NativeModule,而不是按需初始化,这就是初始化性能慢的起因。

在 RN 新架构中,有打算把异步 Bridge 通信替换成同步 JSI 通信,从而实现按需初始化。但当初按需初始化性能还没有实现,因而,框架初始化的优化咱们还是要做的。

咱们给出的思路是,拆包内置和框架预执行。

咱们的 App 是混合利用,首页用的不是 RN。因而,能在 App 启动后,先执行 RN 内置包,初始化所有的 NativeModules。在用户真正进入到 RN 页面时,性能天然会快上很多。

该计划最大的难点是拆包。如何把一个残缺的 bundle 包,正确的拆成内置包和动静更新包呢?

刚开始咱们踩了一个坑,心愿能帮大家防止。

原来咱们用的是 google 的 diff-match-patch 算法,该算法会比照新旧文本的区别,生成一个 patch 文件。同理,能够应用 diff-match-patch 算法,比照业务包和内置包的区别,生成一个 patch 动静更新包。

然而,patch 理论是一个“文本补丁”,“文本补丁”是不能独自执行的。不能满足先执行内置包,再执行动静更新包的要求。

起初,咱们革新了 metro 实现了正确拆包,从而实现了框架预加载。

一个残缺的 bundle,由若干 module 组成,怎么辨别某个 module 是属于内置包,还是动静更新包呢?内置 module 的门路或者说 ID,有一个特色,它是在 node_modules/react/xxx 或 node_modules/react-native/xxx 门路下的。能够先提前记录所有的内置 module 的 IDs,在打包时,将属于内置 module 都过滤掉,生成只蕴含业务 module 的动静更新包。

metro 拆包的动静更新包是“代码补丁”,能够间接执行,可能满足先执行内置包,再执行动静更新包的要求。

其中有一个细节是,内置包中要减少一行 require(InitializeCore) 的代码,来调用内置包中 defined 的 modules。减少这一行代码,首屏耗时大略能够多缩小 90 ms。

针对框架初始化瓶颈,咱们应用了拆包内置和框架预执行的计划。耗时从未优化的 1610 ms,升高到了 1300ms,整体降幅 43%。

瓶颈三:业务申请瓶颈

动静更新瓶颈、框架耗时瓶颈优化完后,再来看一下业务瓶颈。业务瓶颈次要由业务申请和业务渲染两局部组成,申请是比拟好优化的,所以咱们先针对业务申请瓶颈做优化。

业务申请的优化,其实有很多罕用计划。

  • 业务数据缓存
  • 在上一个页面预加载下一个页面的业务数据

然而,不是每个利用它都适宜做缓存,不是每个利用它的数据都适宜在上个页面预加载。因而,咱们须要一种更加通用的计划。仔细观察一下,Init 局部和业务申请局部是串行的,是不是能够改为并行?

咱们的思路是,由 Native 代替 JS,在用户进入页面时间接并行的申请业务数据。

具体计划如下。

  1. 在 Native 下载的资源文件中,会同时蕴含 Biz 业务包和原始的业务申请的 URL。
  2. 原始 URL 中会蕴含动静的业务参数,该变量会依据当时约定的规定进行转换。例如,58.com/api?user=${user} 将会转换为 58.com/api?user=GTMC
  3. Native 并行执行 Biz 包渲染页面,和发动 URL 申请获取业务数据。
  4. JS 侧间接调用 PreFetch(cb),即可取得 Native 侧申请的数据。

针对业务申请瓶颈,咱们应用了业务数据并行加载的计划。耗时从未优化的 1300 ms,升高到了 985 ms,整体降幅 57%。

利用上述计划,大部分页面都能够实现秒开。那还有性能优化的空间吗?

代码执行瓶颈

RN 页面渲染的慢的另外一个起因是,RN 须要执行残缺的 JS 文件,即便 JS 中有不须要执行的代码。

咱们来看一个案例。一个页面蕴含 3 个 tab,用户进来时只会看到 1 个 tab。实践上,只须要执行 1 个 tab 的代码即可。但实际上,另外 2 个看不见的 tab 的代码也会下载和执行,拖慢了性能。

RN 代码懒加载和懒执行的能力来进步性能,相似 Web 中的 Dynamic Import。

RN 官网并没有提供 dynamic import,于是咱们决定本人做。

目前,dynamic import demo 曾经在 RN 0.64 版本中跑通了。业务初始化时,能够只执行 Biz 业务包,在跳转到 Foo、Bar 两个 dynamic 页面时,才会动静的下载对应的 chunk 动静包。退出已进入的 dynamic 页面再次进入,不会再下载,会利用原有的缓存间接渲染 dynamic 页面。

RN 的 dynamic import 实现,咱们参考的是 TC39 的标准。

业务只须要写一行代码 import("./Foo"),就能够实现代码懒加载和懒执行。剩下的所有工作,都在框架层和平台层做了。

在 runtime 运行时,业务执行 import("./Foo") 之后,框架层会去判断 ./Foo 门路对应的 module 是否曾经 install。如果没有 install,就会通过 ./Foo 门路找到对应 chunk 包的 URL 地址,接着下载和执行 chunk,最初渲染 Foo Component。

Chunk 包的 URL 是一个 CDN 地址,显然上传 CDN 和记录 Path 和 URL 关系的工作,不是在 runtime 运行时做的,而是在 compile time 编译时做的。

在平台层的编译过程中,会将 Path 和 URL 的关系表存在 Biz 包中,这样 Runtime 能力通过 Path 找到对应的 URL。

实现这个过程,大抵分为 5 个局部。

  1. Project:一个我的项目由若干个文件组成,文件之间会有相互依赖关系。
  2. Graph:每个文件会生成一个对应的 module,所有 module 及其依赖关系组成了一个 graph。
  3. Modules:给 dynamic module 的汇合进行“着色”,进行辨别。
  4. Bundles:将多个 module 的汇合都打包成多个 bundle。
  5. CND:将 bundle 上传至 CDN。

其中最为要害的步骤是,给 dynamic module 的汇合进行着色。

  1. 合成着色:一个 Graph 的着色的状况能够合成为若干个根底的 case,这些根底 case 的着色计划是曾经确定下来的。
  2. dynamic map:着色实现后,会将“绿色”“蓝色”这些 dynamic module 的根门路 Path 记录下来,并和其 bundle 的 CDN URL 地址组成一个 dynamic map。
  3. Path to URL:Dynamic map 会打包到“红色”的 Biz 业务包中,因而在 runtime 调用 import() 时,能够通过 Path 找到对应的 URL。

上述很多细节没有开展讲,关注实现细节实现的同学能够关注一下咱们的开源工具 metro-code-split。

metro-code-split:https://github.com/wuba/metro…

  • 基于 metro
  • 反对 DLL 拆包
  • 反对 RN Dynamic Import

总结与瞻望

咱们通过剖析性能构造,找到了 3 类性能瓶颈,并产出了不同的优化计划。下图就是咱们秒开计划的汇合,图中列举(预期)收益、失效范畴和失效场景,心愿对大家的技术选型有所帮忙。

在最新版本中,RN 新架构的很多性能曾经成熟,咱们也在进行积极探索。其中最让人惊喜的是 Hermes 引擎,曾经能够同时在 iOS 和 Android 中应用了。Hermes 引擎绝对于原来的 JSCore 引擎最大的区别是,Hermes 会进行预编译,在编译时将 JS 文件编译成 bytecode 文件,这样在运行时就能间接应用 bytecode 文件进行执行了,可能大幅缩小 JS 执行耗时。通过测试咱们发现,一个耗时 140 ms 的页面,可能降到 40 ms,降幅 80%。

在咱们为业务提供性能优化计划的同时,咱们也须要关注业务的落地状况。为了能让更多业务实现秒开,咱们通过非侵入式收集的形式,收集了流失率、首屏工夫、秒开收益等指标。在咱们的实际中,这种将技术优化需要与业务收益挂钩的形式,更容易被业务承受,推动起来也更容易。

最初,心愿咱们的秒开计划和收益驱动的实际,能给大家带来启发,谢谢大家。

退出移动版