搬运补充自《蒋宏伟-58RN页面秒开方案与实践》—2021全球大前端技术大会小册子
(似乎是内部资料了,网上搜不到买不到)
目录
性能优化方案
性能结构与瓶颈
在做任何性能优化钱,都需要先分析性能的结构,然后找到性能的瓶颈。
一个RN的性能结构,整体分为两个部分: Native和JavaScript
再细分,可以分为 6个耗时结构和3个瓶颈 :
动态更新 瓶颈:
- 版本请求
- 资源下载
框架初始化 瓶颈:
- Native初始化
- JS初始化
业务耗时 瓶颈:
- 业务请求
- 业务渲染
动态更新
互联网产品的一个特征就是 快速试错 ,这就要求业务能够 快速迭代 , 应用能够动态更新 。
动态更新需要 发送请求 ,发送请求就会拖慢性能,比如web。如果像native一样进行资源内置,性能会好很多,但又没办法动态更新了。
所以, 动态更新和性能似乎是一对矛盾体 ,有什么权衡之策呢?
第一版方案 是: 资源内置+静默更新。
资源内置用来提高页面性能。 用户首次进来时,已经有内置资源了,不会有请求,页面可以直接渲染出来。
与此同时,native线程会 并行进行静默更新 , 询问服务端 是否有最近版本,如果有就会下载bundle,更新cache。用户下次进来,就可以使用上次缓存的资源,直接渲染页面,同时在后台静默更新下下次的资源……
所以, 每次用户使用的都是上次缓存的资源,而不是线上最新的资源 。如果一个有严重bug的版本被用户缓存下来且无法得到更新,就完了。所以团队又设计了 强制更新功能 ,在静默更新成功后,由 native线程通知js线程是否强制更新 到最新的版本。
这个方案也有一些 缺点 :
- APP体积大。超级APP的体积已经很大了,再增加就很困难了
- 新版本覆盖率低。相比起web来说,可能只有60%的24小时新版本覆盖率。
- 版本碎片化严重。多个内置版本和多次动态更新,会导致线上同时存在多个活跃版本,不利于维护。
所以出现了 第二版方案 ,用资源预加载替代资源内置。
资源预加载+静默更新
- 资源内置 是 传统的 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拆成下面三个部分:
-
react内置包 (React built-in pack)
- 也叫 : 基础包 (Base Bundle) 或 框架包(Framework Pack) 。
- 答案 :它包含了 永远不变 或 极少变化 的代码。
- 详解 :具体就是
React、React Native框架本身的代码,以及其他一些非常稳定的第三方库(比如react-navigation)。 - 为什么叫“内置” :因为它非常稳定,所以它被 “内置”在App 的安装包 (
.apk/.ipa) 里 ,伴随 App一起发布。
-
biz动态包 (biz dynamic pack)
- 也叫 : 业务包 (BusinessBundle) 。
- 答案 :它包含了你 经常修改 的业务逻辑代码。
- 详解 :你写的所有的页面、组件、业务逻辑都在这里。
- 为什么叫“动态” :因为这部分代码经常变动,所以它最适合用来做 热更新(OTA) 。它从服务器上 动态下载 ,而不是内置在 App安装包里。
-
patch文件 (Patch File)
- 答案 : 增量更新包 ,让“动态”变得更轻更快。
- 详解 :假设你的
biz动态包有5MB。你现在改了一个 bug,只改了 10 行代码。 - 传统热更新 :用户需要重新下载一个 完整的、新的5MB
biz动态包。 - 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 的 动态更新包 。
注意,这个动态更新包 不是patch 。 patch是文本布丁,无法单独执行 ,而这个是代码补丁,由打包器metro直接生成,是 可以直接执行的。
业务请求瓶颈
= 业务请求 + 业务渲染
业务请求相对好优化,又很多常用方案:
- 业务数据缓存
- 在上一个页面预加载下一个页面的恶业务数据
不过,项目中用了更加通用的方案:Init和业务请求 目前是穿行的 , 可否改为并行?
简单来说,在传统的 RN/Web页面里,加载一个有数据的页面,是 串行 的两大步:
-
[等待A]:加载和执行 JS
- Native(App 外壳)启动 RN/Webview。
- 开始下载、解析、执行你的
biz业务包(JSBundle)。 - React 开始初始化,
render组件,最后在useEffect或componentDidMount里触发下一步。
-
[等待B]:JS 发起业务请求
- 直到
useEffect执行时,JS才 第一次 有机会发起网络请求(比如fetch(...))去获取业务数据。 - 等待网络数据返回。
- 数据返回后,调用
setState,React 再次render,页面才最终显示完整。
- 直到
改进思路:用Native代替JS, 在用户进入页面时直接并行地请求业务数据 。即等待A等待B一起做。
- 在Native下载的资源文件中,会同时包含biz业务包和原始的业务请求的URL
- 原始URL中会包含动态的业务参数,该变量会按事先约定的规则进行转换,如将
58.con/api?user=${user}转换为58.com/api?user=GTMC - Native并行执行Biz包渲染页面,和发起URL请求获取业务数据
- 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 启动 就能拿到这个值。) - 这个数据请求现在已经在网络上“飞”了。
最后,两路任务汇合:
-
数据先到了 (Native Thread) :
cb? -> Invoke cb- 翻译 :Native的网络请求先完成了!它拿到了数据(
cb?),并把这个数据先“攥在手里”(Invoke cb)。
-
JS 终于启动好了 (JS Thread) :
Registry -> PreFetch(cb)- 翻译 :你的 JS代码(
Biz)终于运行起来了。它不再调用fetch(),而是调用一个你们约定好的“桥接”函数PreFetch(cb)。 - 这个函数的意思是:“嘿,Native!你是不是已经帮我把我 预先(Pre) 抓取 (Fetch) 好的数据 拿到了?拿到了就通过这个 回调 (cb) 传给我。”
-
瞬间交付 (Native -> JS) :
- Native 通过
Invoke cb,把刚才“攥在手里”的数据,瞬间传递给了 JS 的PreFetch回调。
- Native 通过
-
渲染 :
- JS 拿到了数据,调用
setState,页面瞬间渲染完成。
- JS 拿到了数据,调用
代码执行瓶颈
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个部分:
- Project: 一个项目由若干文件组成,文件间有相互依赖关系
- Graph: 每个文件会生成一个对应的module,所有module及其之间的依赖组成了一个graph
- Modules: 给 dynamic module 的集合着色,进行区分
- Bundles: 将多个module的集合打包成多个bundle
- CDN: 将bundle上传到CDN
其中最关键的关节,是 给 dynamic module 的集合着色。
- 分解着色:一个Graph的着色可以分解为多个基础的case,这些case的着色方案是已经确定的。
- dynamic map:着色完成后,会将不同颜色的模块的根路径记录下来,并和其bundle的CDNURL地址组成一个dynamic map
- Path to URL: dynamic map会打包到“白色”的Biz业务包中,因此在runtime调用import()时,可以通过Path找到对应的URL
其他细节,作者团队放到了开源工具metro-code-split中。



原文存档:Notion 原文