RN页面秒开方案与实践(二)

**来源:**Notion **原发布时间:**发布日期: 2025-10-31; 创建时间: 2025-10-31 07:08:06.458Z; 最后编辑时间: 2025-10-31 07:15:31.781Z **标签:**搬运总结、FE

原文:https://almond-oxygen-ceb.notion.site/RN-29d8b1cc19aa801aa517f815c47a7722?pvs=25


搬运补充自《蒋宏伟-58RN页面秒开方案与实践》—2021全球大前端技术大会小册子

(似乎是内部资料了,网上搜不到买不到)

前面的内容:


目录

[table_of_contents]


性能优化方案

性能结构与瓶颈

在做任何性能优化钱,都需要先分析性能的结构,然后找到性能的瓶颈。

一个RN的性能结构,整体分为两个部分:Native和JavaScript

再细分,可以分为6个耗时结构和3个瓶颈

动态更新瓶颈:

  1. 版本请求
  2. 资源下载

框架初始化瓶颈:

  1. Native初始化
  2. JS初始化

业务耗时瓶颈:

  1. 业务请求
  2. 业务渲染

动态更新

互联网产品的一个特征就是快速试错,这就要求业务能够快速迭代应用能够动态更新

动态更新需要发送请求,发送请求就会拖慢性能,比如web。如果像native一样进行资源内置,性能会好很多,但又没办法动态更新了。

所以,动态更新和性能似乎是一对矛盾体,有什么权衡之策呢?

第一版方案是:资源内置+静默更新。

**资源内置用来提高页面性能。**用户首次进来时,已经有内置资源了,不会有请求,页面可以直接渲染出来。

与此同时,native线程会并行进行静默更新询问服务端是否有最近版本,如果有就会下载bundle,更新cache。用户下次进来,就可以使用上次缓存的资源,直接渲染页面,同时在后台静默更新下下次的资源……

所以,每次用户使用的都是上次缓存的资源,而不是线上最新的资源。如果一个有严重bug的版本被用户缓存下来且无法得到更新,就完了。所以团队又设计了强制更新功能,在静默更新成功后,由native线程通知js线程是否强制更新到最新的版本。

这个方案也有一些缺点

  1. APP体积大。超级APP的体积已经很大了,再增加就很困难了
  2. 新版本覆盖率低。相比起web来说,可能只有60%的24小时新版本覆盖率。
  3. 版本碎片化严重。多个内置版本和多次动态更新,会导致线上同时存在多个活跃版本,不利于维护。

所以出现了第二版方案,用资源预加载替代资源内置。

资源预加载+静默更新

  • 资源内置传统的 App更新模式。即将所有的 JS业务代码、图片、字体等资源全部打包.apk.ipa 文件中。用户只能通过应用商店(App Store / GooglePlay)下载新版本来获取更新。
  • 资源预加载则是热更新(Hot Update /OTA)模式。App 初始安装时只包含一个基础包。当用户打开 App时,App 会在后台静默地检查服务器是否有新的 JS包或资源(这就是“预加载”),下载它们,并在下次启动时应用。
  • 资源预加载可以绕过应用商店的审核,并在下次打开APP时强制推送给100%的活跃用户,而且将绝大部分的资源从安装包转移至安装后下载。

这里,作者提供了一个新视角:权利的角度。

谁应该拥有预加载的权利?是RN框架,还是具体业务?

把权利给框架,可以把页面的资源都预加载了,但是效率很低,还会造成浪费;

把权利给业务,让具体业务一个个加载,又非常麻烦。

所以正确的做法应该是:谁拥有信息,权利就给谁。

最开始,框架没有任何有用信息,但是业务可以根据业务数据,预估预加载资源的使用频率,因此预加载的权利应该给业务。

当用户已经使用过某个RN应用后,就有偏好信息了。框架知道偏好信息后,权利就应该给框架,在APP启动后,进行版本与请求。


框架初始化

框架初始化为什么很慢呢?

因为JS线程和Native线程是异步通讯的,每次通讯都要通过Bridge进行序列化和反序列化。

在通讯前,他们不在同一个Context中,是不知道彼此存在,native不知道JS需要使用哪些NativeModule,无法按需初始化,只能全部初始化。

我们先来了解RN打包生成的产物

bundle包 (Bundle Pack)

  • 这就是你的所有 JavaScript 代码
  • 详解:RN 是用 JS 写的。但在打包 App时,Metro(RN的打包工具)会把你所有的 JS文件(React库、RN库、你写的业务代码、第三方npm包)“打包”成一个巨大的、单独的**.js** 文件。这个文件就叫bundle
  • 问题:这个 bundle 包会非常大(10MB,20MB 甚至更多)。RN页面启动慢,90%的性能都消耗在加载、解析、执行这个巨大的 JS文件上

分包”策略就是为了解决这个“巨型bundle”问题而诞生的。它把一个大 bundle拆成下面三个部分:

  1. react内置包 (React built-in pack)
  • 也叫基础包 (Base Bundle) 或 框架包(Framework Pack)
  • 答案:它包含了永远不变极少变化的代码。
  • 详解:具体就是ReactReact Native框架本身的代码,以及其他一些非常稳定的第三方库(比如react-navigation)。
  • 为什么叫“内置”:因为它非常稳定,所以它被**“内置”在App 的安装包 (.apk / .ipa) 里,伴随 App一起发布。
  1. biz动态包 (biz dynamic pack)
  • 也叫业务包 (BusinessBundle)
  • 答案:它包含了你经常修改的业务逻辑代码。
  • 详解:你写的所有的页面、组件、业务逻辑都在这里。
  • 为什么叫“动态”:因为这部分代码经常变动,所以它最适合用来做热更新(OTA)。它从服务器上动态下载,而不是内置在 App安装包里。
  1. patch文件 (Patch File)
  • 答案增量更新包,让“动态”变得更轻更快。
  • 详解:假设你的 biz动态包 有5MB。你现在改了一个 bug,只改了 10 行代码。
  • 传统热更新:用户需要重新下载一个完整的、新的5MBbiz动态包
  • Patch 方式:服务器会对比新旧两个biz动态包,生成一个只包含差异patch文件(可能只有 2KB)。App 只需要下载这个 2KB 的 patch文件,然后在手机本地,通过算法将它和旧的 biz动态包“合并”,就能合成出最新的 biz动态包
  • 优势:极大地减小了热更新的体积,下载更快、更省流量。

🌌

所以,框架初始化的解决核心就是拆包。将bundle拆成框架包(react内置包)和业务包(biz动态包),然后执行拆包内置框架预执行

在APP启动后,先执行RN内置包,初始化所有的nativemodules,之后进入RN页面再执行RN业务包。

一个完整的bundle,由若干module组成,如何区分它属于那个包呢?

内置module路径/ID有一个特征,是在node_modules/react/XXX或node_modules/react-native/XXX下的。

可以提前记录所有内置模块的ID,在打包时用metro过滤掉他们,只生成包含业务module动态更新包

注意,这个动态更新包不是patchpatch是文本布丁,无法单独执行,而这个是代码补丁,由打包器metro直接生成,是可以直接执行的。


业务请求瓶颈

= 业务请求 + 业务渲染

业务请求相对好优化,又很多常用方案:

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

🌌

不过,项目中用了更加通用的方案:Init和业务请求目前是穿行的可否改为并行?

简单来说,在传统的 RN/Web页面里,加载一个有数据的页面,是串行的两大步:

  1. [等待A]:加载和执行 JS
  • Native(App 外壳)启动 RN/Webview。
  • 开始下载、解析、执行你的 biz业务包(JSBundle)。
  • React 开始初始化,render 组件,最后在useEffectcomponentDidMount里触发下一步。
  1. [等待B]:JS 发起业务请求
  • 直到 useEffect 执行时,JS才第一次有机会发起网络请求(比如fetch(...))去获取业务数据。
  • 等待网络数据返回。
  • 数据返回后,调用 setState,React 再次render,页面才最终显示完整。

🌌

改进思路:用Native代替JS,在用户进入页面时直接并行地请求业务数据。即等待A等待B一起做。

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

翻译:

当用户点击进入页面时,Native(原生线程)会“兵分两路”,同时开始做两件事

  • 任务A (Native Thread -> JS Thread):
  • Biz -> Execute(Biz)
  • 翻译:Native 线程开始加载并执行biz业务包(那个黄色的 Biz 框)。
  • 这会启动 JS Thread,React 开始初始化,跑你的 JS逻辑。
  • 任务B (Native Thread):
  • 58.con/api?user=${user} ->58.com/api?user=GTMC
  • 翻译:Native线程在同一时刻,不等 JS启动,自己先去发起业务数据请求
  • 它从资源文件里读取了那个原始 URL 模板(...${user})。
  • 它用原生代码的能力,把动态参数(${user}) 替换成一个真实的值。
  • (这里的 GTMC 可能是一个 Native已经知道的通用值,或者是从原生缓存里读取的用户ID等。总之,它不需要等JS 启动**就能拿到这个值。)
  • 这个数据请求现在已经在网络上“飞”了。

最后,两路任务汇合:

  1. 数据先到了 (Native Thread):
  • cb? -> Invoke cb
  • 翻译:Native的网络请求先完成了!它拿到了数据(cb?),并把这个数据先“攥在手里”(Invoke cb)。
  1. JS 终于启动好了 (JS Thread):
  • Registry -> PreFetch(cb)
  • 翻译:你的 JS代码(Biz)终于运行起来了。它不再调用fetch(),而是调用一个你们约定好的“桥接”函数PreFetch(cb)
  • 这个函数的意思是:“嘿,Native!你是不是已经帮我把我预先(Pre) 抓取 (Fetch) 好的数据拿到了?拿到了就通过这个回调 (cb)**传给我。”
  1. 瞬间交付 (Native -> JS):
  • Native 通过Invoke cb,把刚才“攥在手里”的数据,瞬间传递给了 JS 的PreFetch 回调。
  1. 渲染
  • JS 拿到了数据,调用 setState,页面瞬间渲染完成。

代码执行瓶颈

RN页面渲染慢的另一个原因是,它需要执行完整的JS文件,而这其中有不需要执行的代码。(原因就是native和JS不相通)

比如,一个页面包含3个tab,用户进来时只会看到1个tab,其实执行这一个的代码就行。但是实际上,另外两个看不到的代码也会下载和执行。

所以这对这方面的优化就是:将代码拆成两部分

首屏代码先执行,非首屏代码懒加载+懒执行,类似web中的DynamicImport,就可以提高首屏。(当然,RN官方没做动态导入功能,所以得自己做)

RN dynamicimport的实现,团队参考的是TC39的规范。业务只需要写一句import (”/.foo”),其他由框架层和平台层完成。

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

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

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

完成这个部分,大致需要5个部分:

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

其中最关键的关节,是 给 dynamic module 的集合着色。

  1. 分解着色:一个Graph的着色可以分解为多个基础的case,这些case的着色方案是已经确定的。
  2. dynamic map:着色完成后,会将不同颜色的模块的根路径记录下来,并和其bundle的CDNURL地址组成一个dynamic map
  3. Path to URL: dynamic map会打包到“白色”的Biz业务包中,因此在runtime调用import()时,可以通过Path找到对应的URL

其他细节,作者团队放到了开源工具metro-code-split中。

RN 页面秒开耗时结构图(原文图片补录)

RN 页面秒开并行请求优化图(原文图片补录)

RN 页面秒开资源路径图(原文图片补录)