今天我想和你聊聊在 React Native 应用中实现 JSBundle 的拆包和应用场景。为什么想说这个话题呢?我们都知道在 React Native 应用中通常会使用 JavaScript 做为主要的开发语言,并且在发布前会将整体逻辑打包成一个 JSBundle 文件,供 App 端解析及运行。而 JavaScript 作为解释型语言,本身无需进行编译,也就是说,我们只需要替换这个 JSBundle 文件,对应的 React Native 应用也就随之更新了,省去了提交应用市场的等待审核时间,所以热更新也就成为了 React Native 应用中比较主流的发布方式。那么,在热更新中,如何减小 JSBundle 的文件大小,从而减少用户等待更新的时间,也就成为了一个优化的重点。
和在 Web 浏览器中 JS 文件的优化策略类似,我们也会从拆包这个层面来进行对应的优化,例如,如何将一个较大的 JSBundle 拆解成数个较小的文件,从而减少首屏白屏的等待时间?如何在更新过程中进行按需更新而不是全量更新,从而减少下载的时间?下面,我们就来具体讲解下 React Native 中 JSBundle 的拆包方案。我们先了解 JSBundle 中有哪些内容,然后通过三个步骤,了解 JSBundle 的拆分细节。
JSBundle 中都包含哪些内容?
首先,我们可以用一个简单的例子先展示一下通常每个 JSBundle 中都包含哪些内容,这样会有助于我们理解拆包的原理和拆包最小颗粒度。
我们可以创建两个文件,例如 index.js 和 module.js, 其中 index.js 需要引入 module.js 定义的 sayHiHi 函数并执行。
index. js:
const sayHiHi = require('./module')
sayHiHi()
module.js:
function sayHiHi() {
console.log('Hi Hi');
}
module.exports = sayHiHi;
然后,我们使用 react-native 打包命令行工具对这两个 JS 文件进行构建,生成一个 JSBundle 文件。除了将两个文件代码合并之外,你有没有发现其中多了一些额外的代码?为了方便你理解和展示,我们对 JSBundle 中的代码进行了适当的简化。
define(function (global, metroRequire, module, exports, _dependencyMap) {
var sayHiHi = metroRequire(_dependencyMap[0], "./module");
sayHiHi();
},0,[1]);
define(function (global, metroRequire, module, exports, _dependencyMap) {
function sayHiHi() {
console.log('Hi Hi');
}
module.exports = sayHiHi;
},1,[]);
metroRequire(0)
代码中我们看到两个陌生的函数,分别是 define 和 metroRequire, 那这两个函数起到了什么作用呢?
可以看出,我们定义的两个模块 index.js 和 module.js 都被封装进了一个命名为 define 的函数中,并且 index.js 中的 require 方法也被替换成了 metroRequire 方法。
function define(factory, moduleId, dependencyMap) {
if (modules[moduleId] != null) { return; }
var mod = {
dependencyMap: dependencyMap,
factory: factory,
isInitialized: false,
// ...
};
modules[moduleId] = mod;
}
其中,define 函数接受了三个参数,从命名上我们也可以看出是工厂函数、模块 ID 和模块依赖,分别用于记录模块实现函数、标记模块唯一标志和查找对应依赖等功能。
而 metroRequire 则可以简化为下面的关键代码,通过模块 ID 来查找对应依赖,如果对应依赖尚未初始化,则调用之前保存的 factory 函数,再进行导出。
function metroRequire(moduleId) {
var module = modules[moduleId];
if (module && module.isInitialized) {
return module.publicModule.exports;
} else {
// 初始化module
module.factory()
return module.publicModule.exports;
}
}
所以整体来讲,整个 JSBundle 的运行过程就是先声明 modules 对象、define、metroRequire 等核心函数,并在每个模块中,通过 metroRequire 和 dependencyMap 来引入对应的依赖。如果对应依赖尚未初始化,就调用依赖模块自身的 factory 函数进行初始化,并返回 exports 结果,最终完成当前模块的初始化。
从这个过程中,我们可以看出 define 函数定义的模块其实都是相对独立的,模块之间的引用是通过调用 metroRequire 函数传入对应的 moduleId 来获取的。所以只要我们能明确 moduleId,即使 define 函数定义的模块在不同的 JSBundle 中,我们也可以实现模块间正常的互相调用,从而达到拆包的效果。
JSBundle 具体怎么拆分?
在了解了 JSBundle 的本质和模块间的引用依赖之后,我们就该考虑如何进行具体的拆分了。通常来讲,我们的代码会包含 react-native 和其他的三方库依赖,加上我们自身的业务代码。所以我们可以先构建一个只包含依赖库的 JSBundle,再构建出其他仅包含业务代码的 JSBundle.
为了实现这个的拆分效果,我们将整个过程分三个步骤来进行说明,分别是构建依赖模块的 JSBundle、构建业务模块的 JSBundle 和加载多个 JSBundle。
第一步,构建依赖模块的 JSBundle
在 React Native 默认提供的打包方案中,moduleId 是在每个 JSBundle 中从 0 开始自增的方式来给模块赋值的,这样也可以保证每个模块的标识唯一。这种方式在构建单个 Bundle 的情况下不会产生什么问题,但是如果我们想构建多个 JSBundle 这样通过从 0 开始自增的方式还可以保证模块标识唯一吗?答案是否定的,因为每次构建都会从 0 开始自增,所以会存在不同 JSBundle 中模块 id 重复。对此,react-native bundle 命令行工具也提供了为每个模块生成 moduleId 的工厂函数,我们可以通过自定义 moduleId 生成规则来保证每个模块构建生成的 moduleId 一致且不重复。
由于 React Native 的依赖模块路径相对固定,所以一般我们可以将模块的路径作为其唯一标识。比如,我们可以在项目根目录下创建一个 commonDepConfig.js 文件,用来作为依赖模块打包的配置并实现其中的自定义 moduleID 逻辑。
// 根据path生成ModuleId
function createModuleIdFactory() {
return function createModuleId(path) {
const projectRootPath = __dirname;
let moduleId = path.substr(projectRootPath.length + 1);
return moduleId;
};
}
module.exports = {
serializer: {
createModuleIdFactory: createModuleIdFactory
}
}
需要注意的事,在生成 moduleId 的时候,我们可以将公共依赖模块的 moduleId 保存到本地,这样是为了给后续构建业务包的时候做过滤使用,避免重复生成模块。然后我们在项目的根目录下创建 common.js 文件,在这个文件中引入所有不经常改动的第三方依赖,这个文件就会作为我们构建公共依赖包的入口文件。
// ./comment.js
import "react"
import "react-native"
// ... 你的其他第三方依赖
最后使用react-native 命令行工具进行构建:
$ npx react-native bundle --reset-cache --platform [目标平台] --dev false --entry-file ./common.js --bundle-output [目标目录] --assets-dest [目标目录] --config ./commonDepConfig.js
第二步,构建业务模块的 JSBundle
在构建完公共依赖模块之后,我们就需要开始构建业务模块了。同样也需要使用 createModuleIdFactory 函数,来保证 moduleId 唯一且不变。另外,react-native 提供的打包命令行工具可以接受 processModuleFilter 函数做为模块的过滤器,通过这个函数的返回值为 true 或 false 来决定这个模块将是否将打进 JSBundle 中,这样我们就可以在构建业务 JSBundle 的时候把已经构建到公共依赖 JSBundle 中的模块给过滤掉。
与第一步类似,我们也可以在项目根目录下创建一个 bizConfig.js 文件来作为业务包的 metro 配置,其中需要实现具体的 createModuleIdFactory 和 processModuleFilter 方法。
// 根据path生成ModuleId
function createModuleId(path) {
// ... 和./commonDepConfig.js保持一致
};
// 模块过滤器
function processModuleFilter(module) {
const moudleId = createMudoduleId(module.path)
if (依赖包模块中包含moudleId) {
return false
}
return true
}
function createModuleIdFactory() {
return createModuleId;
}
module.exports = {
serializer: {
createModuleIdFactory: createModuleIdFactory,
processModuleFilter: processModuleFilte
}
}
那接下来还需要自行定义业务包的入口文件,由于 AppRegistry 是 React Native 应用入口的最小单位,所以 React Native 应用的业务最小拆分单元是 AppRegistry.registerComponent 方法注册的 Component。如果你想拆分成多个业务包的话,就可以定义入口文件:
import { AppRegistry } from 'react-native';
import App from './App';
AppRegistry.registerComponent('buz', App);
最后还是使用 React Native 命令行工具进行构建出我们业务的 JSBundle。
第三步,加载多个 JSBundle
通常一个 React Native 应用仅包含一个 JSBundle,但在当前的方案中,我们的应用会存在多个 JSBundle,这也要求我们在 iOS 和 Android 平台上对 JS 的加载做一些额外的处理。
怎么处理呢?有没有加载的先后顺序要求呢?针对这个疑问,我们可以思考一下这两个包的依赖关系,假设 A 包依赖于 B 包的模块,如果先加载 A 包会导致 A 包的加载过程中找不到依赖 B 包中的模块而抛出异常。所以通常,我们会先加载公共依赖包,再加载对应的业务包。
在 iOS 端,你可以使用[RCTBridge initWithBundleURL: moduleProvider: launchOptions:]方法,传入公共依赖包的本地路径,就可以完成 RCTBridge 的实例化,也就相当于启动 React Native 应用。而在 Android 端,需要在创建 ReactNativeHost 实例的时候重写 getBundleAssetName() 方法或 getJSBundleFile() 方法,返回公共依赖包的本地路径,然后再调用 ReactNativeHost 实例的 getReactInstanceManager() 方法触发 ReactInstanceManager 实例的创建,最后调用 createReactContextInBackground() 方法来触发 ReactNative 的初始化流程。
// ios
NSURL *main = // 公共依赖包URL
RCTBridge *bridge = [[RCTBridge alloc] initWithBundleURL:main moduleProvider:nil launchOptions:launchOptions];
// android
ReactNativeHost RNHost = new ReactNativeHost(application) {
@Nullable
@Override
protected String getBundleAssetName() {
return "Assets目录下的JSBundle路径";
}
@Nullable
@Override
protected String getJSBundleFile() {
return "本地磁盘中的JSBundle路径";
}
}
ReactInstanceManager bridge = RNHost.getReactInstanceManager();
if(!bridge.hasStartedCreatingInitialContext()) {
bridge.createReactContextInBackground();
}
并且,React Native 也提供了 JS 加载完成的回调函数,iOS 端会发送事件名称是 RCTJavaScriptDidLoadNotification 的全局通知,Android 端则会向 ReactInstanceManager 实例中注册的所有 ReactInstanceEventListener 回调 onReactContextInitialized() 方法。这样,我们也就能实现加载公共依赖完成之后再加载业务模块。
不过,需要注意的是 React Native 在 iOS 中并没有直接暴露增量加载 JSBundle 的方法,而从源码中,我们可以得知 RCTCxxBridge 可以通过调用 executeSourceCode 方法在当前 React Native 实例上下文中执行一段 JS 源码,也就相当于可以增量加载我们的业务模块。所以我们可以先使用 Category 将 RCTCxxBridge 的 executeSourceCode: sync: 方法暴露出来,从而加载我们的业务包。
// Category
@interface RCTBridge (MYBridge) // 暴露私有接口
- (void)executeSourceCode:(NSData *)sourceCode sync:(BOOL)sync;
@end
// ios
NSURL *buzBundleURL = // 业务包URL
NSError *error = nil;
NSData *sourceData = [NSData dataWithContentsOfURL:buzBundleURL options:NSDataReadingMappedIfSafe error:&error];
if (error) { return }
[bridge.batchedBridge executeSourceCode:sourceData sync:NO]
Android端可以使用刚刚建立好的ReactInstanceManager实例,通过getCurrentReactContext()获取到当前的 ReactContext 上下文对象,再调用上下文对象的getCatalystInstance()方法获取媒介实例,最终调用媒介实例的loadScriptFromFile(String fileName, String sourceURL, boolean loadSynchronously)方法完成业务JSBundle的增量加载。
ReactContext context = RNHost.getReactInstanceManager().getCurrentReactContext();
CatalystInstance catalyst = context.getCatalystInstance();
String fileName = "本地业务包路径"
catalyst.loadScriptFromFile(fileName, fileName, false);
那么到现在这一步,我们就已经完成了 JSBundle 的拆包和多个 JSBundle 的加载,之后就可以使用当前的 React Native 实例来进行 UI 渲染和事件交互了。而在接下来的版本迭代中,如果公共依赖的 没有变更,那么我们就只需要替换业务模块的 JSBundle,就可以完成应用的更新和发布了。
总结
那么讲到这里,今天的分享其实也就结束了,但是你发现了吗?在上述的场景中,我们其实在一开始就会加载所有的公共依赖和业务模块,这样的做法显然是存在优化空间的。如果说更进一步的话,我们可以按照用户进入的时机来按需加载对应的业务模块,从而减少初始化时的运行时间,使用户能更快的访问到服务。而在一些可能包含多个 React Native 的实例的平台类 App 中,通过拆包合并依赖等方式也能避免构建冗余代码,从而达到减小 APP 包体积的目的。
总而言之,React Native 的拆包策略最终都是为了抽取公共代码,减少单个 JSBundle 体积,从而达到降低应用初始化时间,以及做到按需加载。而每个应用具体的策略则需要我们根据实际的业务场景来进行调整。