2019-04-03
本文的原作者是 Karan Thakkar,文章源链接 Lazy Bundle Loading in React Native 🔥,我将其翻译成中文。
在本文中将实现 React Native 的 Bundle 懒加载技术,会深入 Metro 和 React Native iOS 代码库的内部实现。
包懒加载技术主要包含以下几个难点:
Metro 本身没有自带像 Webpack 的 CommonsChunkPlugin 那样的通过动态 import 的包切分机制。
有一个叫 Haul 基于 Webpack 的 React Native 打包器能做这个事情,但是在本文中我们不打算切换打包器,而是仍使用 Metro。
在本文中,我们编写两个很简单的屏幕界面,先试图手动切分 Bundle。
项目的入口文件是 index.js,我创建了两个版本:
版本一:Bundle 中只有屏幕 A
版本二:Bundle 中有屏幕 A 和屏幕 B
这样,我们通过手动 diff 最终的 bundle,就能得到屏幕 B 的代码。
我们先来看两个版本的代码:
版本一:
版本二:
我试用这两个版本的入口文件通过下面的打包命令分别打出两个 Bundle 来:
react-native bundle \
--platform ios \
--entry-file index.js \
--bundle-output ./ios/main.jsbundle
下面我使用功能 diff 工具(如 DiffMerge) 对两个 Bundle 进行 diff 比较,会得出许多差异。
我们从上向下一块一块来看,首先是第一块:
其中:
左边是只有屏幕 A
右边是有屏幕 A 和屏幕 B
右图中红色部分是屏幕 B 所多出的部分
index.js 经过打包后就会变成这样。
在上面的 Diff 中,主要包含三个不同之处。
第一个不同是额外的 ScreenB 导入。
_dependencyMap
是这个函数收到的模块 ID 的数组。所以:_dependencyMap[0] = react-native
_dependencyMap[1] = ScreenA
_dependencyMap[2] = ScreenB
第二个不同是调用 AppRegistry 来注册 ScreenB
第三个不同是 Metro 给 ScreenB 模块指定的 ID 是 354,将这个 ID 传入方法
每个 __d(…)
函数都表示你的 React Native 应用中的一个文件/模块。
第二块 diff 如下:
这是 ScreenB.js 的代码,它在最终的 Bundle 中的模块 id 为 354。
因此,如果要创建一个单独的 ScreenB 包,我们需要做两件事:
① 更新 main.jsbundle
__d(…)
的 dependency map 中删除 ScreenB 的模块 id,354② 将代码迁移到新文件——ScreenB.jsbundle
我创建了一个新的 __d(…)
来进行模块注册。
我需要创建一个唯一的模块 ID,我选择了 355,因为 354 是 main.jsbundle 中的最后一个模块 ID。
ScreenB 现在是 dependencyMap 数组中的第二个元素,而不是第三个,因此我将 _dependencyMap[2]
改为 _dependencyMap[1]
。
修改后的 ScreenB.jsbundle 中这一部分如下:
现在 ScreenB.jsbundle 几乎要完成了。它向 Metro 注册了两个模块,但是我们希望同时也消费它。
这可以参考 main.jsbundle 来看消费是怎么回事:
在 main.jsbundle 中,真正的开始处是最后一行的 require(0)
。
它会触发应用来执行模块 id 为 0 的代码,即 React Native 的入口文件 index.js。
由于我们把 ScreenB 的注册移动到一个单独的模块中,我们需要触发它执行,以使他注册到 React Native 中。
因此我们在 ScreenB.jsbundle 的最后一行中加入句 require(355)
。
如下图所示:
现在我们的分割任务就完成了!🌟
每个 React Native APP 都是从创建 RCTBridge 实例开始的。
其中,React Native 加载 JavaScript,要么从本地打包器(调试模式),要么从预编译的 Bundle(发布模式)
,并且在 JavaScriptCore 中进行执行:
当调用 initWithBundleURL 时,会按顺序发生以下事件:
RCTBridge 实例被创建,并且 _bundleURL
被设为我们的传入值——Code
对象的 setUp 方法被调用,它会初始化 RCTCxxBridge (即 batched bridge)——Code
batched bridge 的 start 方法被调用——Code
这是 Bundle 真正被加载和执行的地方——Code
因此,在我们的自定义加载过程中,我们需要做两件事情:
① 设置 Bridge 来加载自定义 Bundle
我们需要将 _bundleURL
指向我们的差分包:ScreenB.jsbundle。
因此我们需要在 RCTBridge.m 中创建一个自定义方法,并在 RCTBridge.h 中暴露它。
如果我们想在用户业务代码中调到,只能这么干(改 React Native 的底层代码)。
暴露接口如下:
代码实现如下:
先不用担心 loadCustomBundle 中的这个 lazyStart,我们会在下文中实现它。🙂
② 加载并执行新 Bundle
我们将 RCTCxxBridge.mm 的 start 方法中加载和执行 JavaScript 的逻辑复制一份到我们的 loadCustomBundle 新方法中。
其中,start 方法中初始化了很多东西,它最终做了三件事:
基于这些,我们的 lazyStart 如下所示:
其中,我们不需要初始化 bridge 对象,因为它已经在首次加载 Bundle 时完成了。
在这里我们只需要做两件事:
dispatch_group_notify
下面我们需要暴露 RCTCxxBridge.mm 中的这个方法,使得在 RCTBridge.m 中也能调用 loadCustomBundle。
我们通过修改 RCTBridge+Private.h 接口来实现:
上述这些改变可以通过 GitHub diff 来查看:
https://github.com/karanjthakkar/react-native/compare/v0.56.0...rn_lazy_bundle_loading 🤓
现在我们既有了差分包,也有了加载它的代码,还需要做两件事情将它们封装完善:
① 将 ScreenB.jsbundle 添加到 XCode 的 bundle 资源下
react-native-cli 默认创建的 React Native 项目会把 main.jsbundle 放到 bundle 资源目录下。
由于 ScreenB.jsbundle 是一个非标准的文件,我们需要通过下面配置进行添加:
② 使用 React Native bridge 实例来触发懒加载
react-native-cli 默认创建的 React Native 项目,当我们调用 RCTRootView 的 initWithBundleURL 时会在内部创建 RCTBridge 实例:
在我们的场景中,loadCustomBundle 在 RCTBridge 能够调到。因此我们需要这个类的实例。
为了实现这个,我们将上面实现分为两部分:
在上面的代码中:
按照这种方式我们有了 bridge,现在你可以用它在任何地方加载自定义的 Bundle 了:
其中:加载名称为 ScreenB 的 bundle,即 ScreenB.jsbundle
你可以将 Bridge 存到一个静态类的属性中,作为一个单例,在任何地方访问。或者你也可以使用依赖注入技术。
关于本文的更加完整详细的示例请参见 Github。
感谢大家! 👋🏻 原作者的 Twitter 账号是 @geekykaran,喜欢的话大家可以去关注他。