React Native Bundle 懒加载(翻译)

2019-04-03

前言

本文的原作者是 Karan Thakkar,文章源链接 Lazy Bundle Loading in React Native 🔥,我将其翻译成中文。

在本文中将实现 React Native 的 Bundle 懒加载技术,会深入 Metro 和 React Native iOS 代码库的内部实现。

包懒加载技术主要包含以下几个难点:

  • 如何根据屏幕进行 React Native Bundle 切分
  • 跟踪代码执行过程,找出 JavascriptCore 中加载执行 Bundle 的代码,将它复制出来
  • 拦截 React Native 实现懒加载,在跳转到这个页面的时候再加载 JavaScript Bundle

使用 Metro 进行 Code Splitting

Metro 本身没有自带像 Webpack 的 CommonsChunkPlugin 那样的通过动态 import 的包切分机制。

有一个叫 Haul 基于 Webpack 的 React Native 打包器能做这个事情,但是在本文中我们不打算切换打包器,而是仍使用 Metro。

在本文中,我们编写两个很简单的屏幕界面,先试图手动切分 Bundle。

项目的入口文件是 index.js,我创建了两个版本:

  1. 版本一:Bundle 中只有屏幕 A

  2. 版本二: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 应用中的一个文件/模块。

  • 它的第二参数:在本例中为 0,表示 Metro 给我们的 index.js 的 id

第二块 diff 如下:

这是 ScreenB.js 的代码,它在最终的 Bundle 中的模块 id 为 354。

因此,如果要创建一个单独的 ScreenB 包,我们需要做两件事:

① 更新 main.jsbundle

  • 删除 ScreenB 的 import 和 registration
  • 从传到 __d(…) 的 dependency map 中删除 ScreenB 的模块 id,354
  • 删除编译后的 module 中删除 ScreenB.js 的代码(第二块 diff)

② 将代码迁移到新文件——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 中懒加载并执行差分包

每个 React Native APP 都是从创建 RCTBridge 实例开始的。

其中,React Native 加载 JavaScript,要么从本地打包器(调试模式),要么从预编译的 Bundle(发布模式)

,并且在 JavaScriptCore 中进行执行:

当调用 initWithBundleURL 时,会按顺序发生以下事件:

  1. RCTBridge 实例被创建,并且 _bundleURL 被设为我们的传入值——Code

  2. 对象的 setUp 方法被调用,它会初始化 RCTCxxBridge (即 batched bridge)——Code

  3. batched bridge 的 start 方法被调用——Code

  4. 这是 Bundle 真正被加载和执行的地方——Code

因此,在我们的自定义加载过程中,我们需要做两件事情:

① 设置 Bridge 来加载自定义 Bundle

我们需要将 _bundleURL 指向我们的差分包:ScreenB.jsbundle。

因此我们需要在 RCTBridge.m 中创建一个自定义方法,并在 RCTBridge.h 中暴露它。

如果我们想在用户业务代码中调到,只能这么干(改 React Native 的底层代码)。

暴露接口如下:

代码实现如下:

先不用担心 loadCustomBundle 中的这个 lazyStart,我们会在下文中实现它。🙂

② 加载并执行新 Bundle

我们将 RCTCxxBridge.mm 的 start 方法中加载和执行 JavaScript 的逻辑复制一份到我们的 loadCustomBundle 新方法中。

其中,start 方法中初始化了很多东西,它最终做了三件事:

  1. 初始化 bridge 对象以使得 Native 到 JS 的通信成为可能——Code
  2. 根据 Bundle URL 加载 Bundle——Code
  3. 由于上面这些步骤都是异步的,需要等待它们完成,者通过使用 dispatch_group_notify ,当异步调用完成后它会被调用。然后,它会使用第一步创建的 bridge 执行加载的 JavaScript Bundle String——Code

基于这些,我们的 lazyStart 如下所示:

其中,我们不需要初始化 bridge 对象,因为它已经在首次加载 Bundle 时完成了。

在这里我们只需要做两件事:

  1. 通过 loadSource 加载新的 JavaScript Bundle。这会在内部使用我们在 loadCustomBundle 方法中设置的 bundleURL
  2. 一旦加载完毕,我们执行 JavaScript 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 实例
  • 使用它创建一个 RCTRootView 实例

按照这种方式我们有了 bridge,现在你可以用它在任何地方加载自定义的 Bundle 了:

其中:加载名称为 ScreenB 的 bundle,即 ScreenB.jsbundle

你可以将 Bridge 存到一个静态类的属性中,作为一个单例,在任何地方访问。或者你也可以使用依赖注入技术。

关于本文的更加完整详细的示例请参见 Github

感谢大家! 👋🏻 原作者的 Twitter 账号是 @geekykaran,喜欢的话大家可以去关注他。