1 背景
动态化是 React Native 的核心特点之一。在使用 React Native 的过程中,动态化是一个不可回避的话题。随着业务开发的快速迭代和新业务线的不断加入,子工程的体积不断增加,导致主工程的体积也随之增大。如果热更新以全量包的形式进行,会大大增加用户更新时的流量消耗,因此业务拆包势在必行。
拆包有以下优点:
1. 减小生成的包体积:降低包传输过程中的异常率,进而提升用户体验。
2. 基础包和业务包分离:使基础包更稳定、业务包更纯粹,提高开发效率。
3. 优化包加载:可以进行基础包预加载,减少页面白屏时间。
通过拆包,可以有效提升应用的性能和用户体验,同时提高开发效率。本文基于 React Native 0.72.10 版本,分享了在 Soul App 中落地使用拆包动态化方案以及遇到的一些问题。
2 包体分析
React Native 使用 Metro 来构建 JavaScript 代码和资源,简单来说,它是一个模块打包器。在制定拆包动态化具体方案之前,我们需要了解其打包流程。要了解 Metro 的打包流程,我们可以从分析构建产物着手,先看看产物的构成。通过分析构建产物,我们可以更好地理解 Metro 的打包机制,从而为拆包动态化方案的制定提供依据。
import { StyleSheet, Text, View, AppRegistry } from "react-native";
class RNDemo extends React.Component {
render() {
return (
<View style={styles.container}>
<Text style={styles.hello}> RNDemo </Text>
</View>
);
}
}
const styles = StyleSheet.create({
container: {
height: 44,
position: 'relative',
justifyContent: 'center',
alignItems: 'center',
},
hello: {
fontSize: 20,
color: 'blue',
},
});
AppRegistry.registerComponent("Soul_Rn_Demo", () => RNDemo);
npx react-native bundle --platform ios --dev false --minify false --entry-file RNDemo.js --bundle-output index.ios.bundle
这里以 iOS 为例, 同时注意这里不要混淆, 方便我们分析
查看 index.ios.bundle
文件内容,可以发现包内容还是很规整的,整体上主要由三部分构成:
1. 环境变量 和 require define 方法的预定义(polyfills)
2. 模块代码定义(module define)
3. 执行(require 调用)
下面我们就针对这三块内容来分析一下,它们具体做了什么。
polyfills 代码很多(1行-865行),其中主要部分又分成了环境获取、方法定义、模块入口、模块定义。
这部分我们了解下就可以了,也有人把这边单独划分一层:声明层。
var __BUNDLE_START_TIME__=this.nativePerformanceNow?nativePerformanceNow():Date.now(),__DEV__=false,process=this.process||{},__METRO_GLOBAL_PREFIX__='';process.env=process.env||{};process.env.NODE_ENV=process.env.NODE_ENV||"production";
重点关注 __d 和 __r, 它们分别对应模块定义和模块执行的函数, 这两个是关键收口函数。
global.__r = metroRequire;
global[`${__METRO_GLOBAL_PREFIX__}__d`] = define;
global.__c = clear;
global.__registerSegment = registerSegment;
metroRequire 是执行模块的起点,这里只需要记住方法流程就可以,metroRequire -> guardedLoadModule -> loadModuleImplementation -> factory。也就是最终通过 factory 来加载和执行模块的。
function metroRequire(moduleId) {
//$FlowFixMe: at this point we know that moduleId is a number
var moduleIdReallyIsNumber = moduleId;
var module = modules[moduleIdReallyIsNumber];
return module && module.isInitialized ? module.publicModule.exports : guardedLoadModule(moduleIdReallyIsNumber, module);
}
function guardedLoadModule(moduleId, module) {
if (!inGuard && global.ErrorUtils) {
inGuard = true;
var returnValue;
try {
returnValue = loadModuleImplementation(moduleId, module);
} catch (e) {
// TODO: (moti) T48204692 Type this use of ErrorUtils.
global.ErrorUtils.reportFatalError(e);
}
inGuard = false;
return returnValue;
} else {
return loadModuleImplementation(moduleId, module);
}
}
function loadModuleImplementation(moduleId, module) {
if (!module && moduleDefinersBySegmentID.length > 0) {
var _definingSegmentByMod;
var segmentId = (_definingSegmentByMod = definingSegmentByModuleID.get(moduleId)) != null ? _definingSegmentByMod : 0;
var definer = moduleDefinersBySegmentID[segmentId];
if (definer != null) {
definer(moduleId);
module = modules[moduleId];
definingSegmentByModuleID.delete(moduleId);
}
}
var nativeRequire = global.nativeRequire;
if (!module && nativeRequire) {
var _unpackModuleId = unpackModuleId(moduleId),
_segmentId = _unpackModuleId.segmentId,
localId = _unpackModuleId.localId;
nativeRequire(localId, _segmentId);
module = modules[moduleId];
}
if (!module) {
throw unknownModuleError(moduleId);
}
if (module.hasError) {
throw module.error;
}
// We must optimistically mark module as initialized before running the
// factory to keep any require cycles inside the factory from causing an
// infinite require loop.
module.isInitialized = true;
var _module = module,
factory = _module.factory,
dependencyMap = _module.dependencyMap;
try {
var moduleObject = module.publicModule;
moduleObject.id = moduleId;
// keep args in sync with with defineModuleCode in
// metro/src/Resolver/index.js
// and metro/src/ModuleGraph/worker.js
factory(global, metroRequire, metroImportDefault, metroImportAll, moduleObject, moduleObject.exports, dependencyMap);
// avoid removing factory in DEV mode as it breaks HMR
{
// $FlowFixMe: This is only sound because we never access `factory` again
module.factory = undefined;
module.dependencyMap = undefined;
}
return moduleObject.exports;
} catch (e) {
module.hasError = true;
module.error = e;
module.isInitialized = false;
module.publicModule.exports = undefined;
throw e;
} finally {}
}
define 方法负责模块定义,会收拢模块参数,生成 mod 对象,然后存储在全局变量 modules 中。
function define(factory, moduleId, dependencyMap) {
if (modules[moduleId] != null) {
// prevent repeated calls to `global.nativeRequire` to overwrite modules
// that are already loaded
return;
}
var mod = {
dependencyMap: dependencyMap,
factory: factory,
hasError: false,
importedAll: EMPTY,
importedDefault: EMPTY,
isInitialized: false,
publicModule: {
exports: {}
}
};
modules[moduleId] = mod;
}
找到 RNDemo
定义的位置,看下模块 RNDemo
是怎么定义的:
__d(function (global, _$$_REQUIRE, _$$_IMPORT_DEFAULT, _$$_IMPORT_ALL, module, exports, _dependencyMap) {
var _classCallCheck2 = _$$_REQUIRE(_dependencyMap[0])(_$$_REQUIRE(_dependencyMap[1]));
var _createClass2 = _$$_REQUIRE(_dependencyMap[0])(_$$_REQUIRE(_dependencyMap[2]));
var _possibleConstructorReturn2 = _$$_REQUIRE(_dependencyMap[0])(_$$_REQUIRE(_dependencyMap[3]));
var _getPrototypeOf2 = _$$_REQUIRE(_dependencyMap[0])(_$$_REQUIRE(_dependencyMap[4]));
var _inherits2 = _$$_REQUIRE(_dependencyMap[0])(_$$_REQUIRE(_dependencyMap[5]));
var _reactNative = _$$_REQUIRE(_dependencyMap[6]);
function _callSuper(t, o, e) { return o = (0, _getPrototypeOf2.default)(o), (0, _possibleConstructorReturn2.default)(t, _isNativeReflectConstruct() ? Reflect.construct(o, e || [], (0, _getPrototypeOf2.default)(t).constructor) : o.apply(t, e)); }
function _isNativeReflectConstruct() { try { var t = !Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})); } catch (t) {} return (_isNativeReflectConstruct = function _isNativeReflectConstruct() { return !!t; })(); }
var BU1 = /*#__PURE__*/function (_React$Component) {
function BU1() {
(0, _classCallCheck2.default)(this, BU1);
return _callSuper(this, BU1, arguments);
}
(0, _inherits2.default)(BU1, _React$Component);
return (0, _createClass2.default)(BU1, [{
key: "render",
value: function render() {
return /*#__PURE__*/(0, _$$_REQUIRE(_dependencyMap[7]).jsx)(_reactNative.View, {
style: styles.container,
children: /*#__PURE__*/(0, _$$_REQUIRE(_dependencyMap[7]).jsx)(_reactNative.Text, {
style: styles.hello,
children: " RNDemo "
})
});
}
}]);
}(React.Component);
var styles = _reactNative.StyleSheet.create({
container: {
height: 44,
position: 'relative',
justifyContent: 'center',
alignItems: 'center'
},
hello: {
fontSize: 20,
color: 'blue'
}
});
_reactNative.AppRegistry.registerComponent("Soul_Rn_Demo", function () {
return BU1;
});
},0,[1,2,3,7,9,10,12,194]);
可以看到,就是调用 polyfills
里面的 define
方法,把参数传递进去。第一个参数就是 metroRequire
最终需要使用的 factory
方法。0 代表 MoudleId
(MoudleId
后面会详细说明),后面的数组是当前模块的依赖项。总结起来,就是将整体模块生成一个模块数据对象,然后放在全局变量 modules
里面。
现在只是构建了对象数据结构,那么什么时候使用呢?
这一步其实就简单了, 在文件最后。可以看到执行了初始化了两个 module
。45 是系统核心模块,0 就是对应我们的 RNDemo
模块。__r 就会执行 metroRequire
, 最终执行上面 function
方法。
__r(45);
__r(0);
生成的 bundle
文件里面其实就是通过 MoudleId
来关联模块。如果我们知道模块唯一标识 MoudleId
生成规则,然后把不同页面的放在不同包里面,也通过 MoudleId
来关联模块,就可以进行分包动态化了。那么 MoudleId
是怎么生成的呢?我们可以看看运行打包命令后,引擎内部做了什么?
MoudleId
生成规则,这里只简单介绍下打包流程。运行命令 react-native
的时候,首先触发的是 react-native-community
var cli = require('@react-native-community/cli');
if (require.main === module) {
cli.run();
}
CLI 启动后,会加载内置的命令,这里我们只需要关注 bundle 打包命令。整体调用流程: run -> setupAndRun -> require("./commands") -> bundle
合并 Metro 默认配置和自定义配置。
解析配置,构建 requestOpts 对象, 作为打包函数入参。
实例化 Metro Server(React Native 的打包工具和开发服务器)。
启动 Metro Server 构建 bundle。
处理资源文件。
关闭 Metro Server。
最终,我们知道实际打包是通过 Metro Server 去实现的。
https://metrobundler.dev/docs/concepts/
5. MoudleId 生成流程MoudleId
的生成流程就是在上面加载 buildBundle 的过程中,下面省略了非关键的代码。列举了 MoudleId
生成流程。
async function buildBundle(args, ctx, output = outputBundle) {
const config = await (0, _loadMetroConfig.default)(ctx, {
maxWorkers: args.maxWorkers,
resetCache: args.resetCache,
config: args.config
});
return buildBundleWithConfig(args, config, output);
}
----------------------------------------------------------------------------------------
async function loadMetroConfig(ctx, options = {}) {
const overrideConfig = getOverrideConfig(ctx);
if (options.reporter) {
overrideConfig.reporter = options.reporter;
}
const cwd = ctx.root;
/** **/
/** 特别注意这边 **/
return (0, _metroConfig().mergeConfig)(await (0, _metroConfig().loadConfig)({
cwd,
...options
}), overrideConfig);
}
----------------------------------------------------------------------------------------
// 注意 argvInput 里面包含了,命令的参数。
async function loadConfig(argvInput = {}, defaultConfigOverrides = {}) {
const argv = {
...argvInput,
config: overrideArgument(argvInput.config),
};
const configuration = await loadMetroConfigFromDisk(
argv.config,
argv.cwd,
defaultConfigOverrides
);
/** **//
// Set the watchfolders to include the projectRoot, as Metro assumes that is
// the case
// $FlowFixMe[incompatible-variance]
// $FlowFixMe[incompatible-indexer]
// $FlowFixMe[incompatible-call]
return mergeConfig(configWithArgs, overriddenConfig);
}
----------------------------------------------------------------------------------------
async function loadMetroConfigFromDisk(path, cwd, defaultConfigOverrides) {
// 这里的 path 就是打包传递的配置参数,如果传递了,则直接加载该文件;否则遍历下面4个文件,加载。
// "metro.config.js",
// "metro.config.cjs",
// "metro.config.json",
// "package.json",
const resolvedConfigResults = await resolveConfig(path, cwd);
const { config: configModule, filepath } = resolvedConfigResults;
const rootPath = dirname(filepath);
const defaults = await getDefaultConfig(rootPath);
/** **/
/** 加载完成和默认配置合并 **/
// $FlowFixMe[incompatible-variance]
// $FlowFixMe[incompatible-call]
return mergeConfig(defaultConfig, configModule);
}
----------------------------------------------------------------------------------------
function getDefaultConfig(
projectRoot /*: string */
) /*: ConfigT */ {
const config = {
/** **/
};
/** 这里两个地方的默认配置 **/
return mergeConfig(
getBaseConfig.getDefaultValues(projectRoot),
config,
);
}
----------------------------------------------------------------------------------------
const getDefaultValues = (projectRoot) => ({
/** **/
serializer: {
polyfillModuleNames: [],
getRunModuleStatement: (moduleId) => `__r(${JSON.stringify(moduleId)});`,
getPolyfills: () => [],
getModulesRunBeforeMainModule: () => [],
processModuleFilter: (module) => true,
createModuleIdFactory: defaultCreateModuleIdFactory,
experimentalSerializerHook: () => {},
customSerializer: null,
isThirdPartyModule: (module) =>
/(?:^|[/\\])node_modules[/\\]/.test(module.path),
},
/** **/
});
6. 两个核心方法
最终我们看到了和 ModuleId
相关的方法 createModuleIdFactory
, 这个其实就是一个自增方法,从 0 开始。
function createModuleIdFactory() {
const fileToIdMap = new Map();
let nextId = 0;
return (path) => {
let id = fileToIdMap.get(path);
if (typeof id !== "number") {
id = nextId++;
fileToIdMap.set(path, id);
}
return id;
};
}
如果我们在打包时添加自己的配置文件路径参数,并实现这个方法,按照页面规则生成自定义的 ID,那么就可以进行拆包了。
同时,我们关注到了 processModuleFilter
方法,它和 createModuleIdFactory
一样,可以通过配置文件重写。当该方法返回 false
时,代表不打入包,对应下面的 filter
方法。否则,就打入包。我们正是通过这个方法来控制如何分包。
function processModules(
modules,
{
filter = () => true,
createModuleId,
dev,
includeAsyncPaths,
projectRoot,
serverRoot,
sourceUrl,
}
) {
return [...modules]
.filter(isJsModule)
.filter(filter)
.map((module) => [
module,
wrapModule(module, {
createModuleId,
dev,
includeAsyncPaths,
projectRoot,
serverRoot,
sourceUrl,
}),
]);
}
createModuleIdFactory
方法,也是从 0 开始计算,通过路径为 Key,递增生成对应 MoudleId
。function createModuleIdFactory() {
const fileToIdMap = new Map();
let nextId = 0;
return (path) => {
let id = fileToIdMap.get(path);
if (typeof id !== "number") {
id = nextId++;
fileToIdMap.set(path, id);
writeBuildInfo(
"./soulCommonInfo.json",
path,
fileToIdMap.get(path)
);
}
return id;
};
}
module.exports = {
serializer: {
createModuleIdFactory: createModuleIdFactory,
},
};
但是这样有个弊端:一旦调整了库引入位置或者增加了新引用,由于引入顺序变化,会导致上面 Path 参数的顺序变化,最终导致 ModuleID
发生变化。一旦用户使用的是新基础包,原先的页面就会出现找不到方法的情况。
那么如何在这两种情况下兼容老包呢?
其实也很简单,我们每次生成基础包时不要重新生成,而是在之前的基础上生成。这样历史 ModuleID
从缓存读取,新 ModuleID
在之前的基础上重新生成。脚本修改如下:
const fs = require('fs');
function createModuleIdFactory() {
const fileToIdMap = new Map();
let nextId = 0;
const file = "./soulCommonInfo.json";
const stats = fs.statSync(file);
if (stats.size === 0) {
clean(file);
} else {
const cache = require(file);
if (cache && cache instanceof Object) {
for (const [path, id] of Object.entries(cache)) {
nextId = Math.max(nextId, id+1);
fileToIdMap.set(path, id);
}
}
}
return (path) => {
let id = fileToIdMap.get(path);
if (typeof id !== "number") {
id = nextId++;
fileToIdMap.set(path, id);
if (!hasBuildInfo(file, path)) {
writeBuildInfo(
file,
path,
fileToIdMap.get(path)
);
}
}
return id;
};
}
module.exports = {
serializer: {
createModuleIdFactory: createModuleIdFactory,
},
};
基础包是整个 React Native 容器运行的基础,所有业务包都依赖基础包。
一个引擎,基础包只加载一次。一个页面,业务包也只加载一次。
基础包内容较多,基础包包体(1M+)会比业务包(几十 KB)大很多。
正常打开一个 React Native 页面,需要保证基础包和对应业务包都加载完成。
基于上述特点,应用启动后,我们可以先预加载基础包,然后在打开对应页面时加载对应的业务包。这样既保证了页面打开速度,又避免了一次加载过多业务包的问题。
以 iOS 为例,引擎只提供了一个 sourceURLForBridge
回调方法来设置包体路径。设置完基础包后,如何再加载业务包?
如何确定基础包或者业务包已经加载完成?
打开页面时,如果基础包没有加载完成,如何处理?
基于上述问题,我们逐步分析:
2.1 基础包加载:基础包通过 sourceURLForBridge
回调方法来设置。至于业务包的加载,以 iOS 为例,可以使用 RCTCxxBridge
的 executeSourceCode
方法在当前的 RN 实例上下文中执行一段 JS 代码,以此达到增量加载的目的。需要注意的是,executeSourceCode
是 RCTCxxBridge
的私有方法,需要我们用 Category 将其暴露出来。
2.2 判断加载完成:判断基础包加载完成,可以通过 RCTJavaScriptDidLoadNotification
。对于业务包加载是否完成,仅判断 sourceURLForBridge
是否执行完成是不够的,可能包已经加入,但由于某些原因显示失败。这种情况虽然可以在 JS 侧使用错误边界等方式捕获,但实践中有些情况 JS 侧捕获不到。我们需要一种机制保证容器准确知道是否加载异常。这里我们通过在 JS 侧注册完成页面后,由 JS 侧主动通知原生侧,以感知注册结果。
2.3 处理未加载完成的情况:打开页面时,判断基础包是否加载完成。如果基础包加载完成,直接打开页面;否则,我们会先缓存打开页面的信息。如果此时基础包还没有开始加载,主动唤起加载。如果已经在加载,就等基础包加载完成后再读取缓存打开页面。
基础包内置:考虑到基础包改动频率很低,基础包可以内置在 App 中,这样提高了加载成功率,同时也方便预加载。
业务包兜底:内置一份业务包作为兜底,当首次打开页面或出现异常时,加载兜底业务包。
业务包更新:打开页面时,我们会校验是否需要更新业务包。考虑到包体大小,业务包是经过压缩的。同时,我们兼顾了安全性:业务包需要签发后才能分发,本地会对包体进行校验。
平台支撑:我们开发了自己的构建平台和灰度平台。构建平台负责构建和 CDN 部署,灰度平台负责下发,端侧从灰度平台拉取更新数据,整体形成自动化闭环。
基于上述设计,目前 Soul App 已经全面拥抱 React Native,同时在内部其他 App 中使用。
以上即为本次分享的内容。
感谢您的阅读,如果喜欢欢迎多多关注/留言/点赞。