找回密码
 会员注册
查看: 17|回复: 0

ReactNative在「SoulApp」的实践-拆包与热更新

[复制链接]

3

主题

0

回帖

10

积分

新手上路

积分
10
发表于 2024-10-9 17:58:29 | 显示全部楼层 |阅读模式
React Native 在「Soul App」的实践 - 拆包与热更新 Soul技术团队 Soul技术团队 Soul的技术实践分享 6篇内容 2024年09月30日 13:20 北京 1 背景动态化是 React Native 的核心特点之一。在使用 React Native 的过程中,动态化是一个不可回避的话题。随着业务开发的快速迭代和新业务线的不断加入,子工程的体积不断增加,导致主工程的体积也随之增大。如果热更新以全量包的形式进行,会大大增加用户更新时的流量消耗,因此业务拆包势在必行。拆包有以下优点:1. 减小生成的包体积:降低包传输过程中的异常率,进而提升用户体验。2. 基础包和业务包分离:使基础包更稳定、业务包更纯粹,提高开发效率。3. 优化包加载:可以进行基础包预加载,减少页面白屏时间。通过拆包,可以有效提升应用的性能和用户体验,同时提高开发效率。本文基于 React Native 0.72.10 版本,分享了在 Soul App 中落地使用拆包动态化方案以及遇到的一些问题。2 包体分析React Native 使用 Metro 来构建 JavaScript 代码和资源,简单来说,它是一个模块打包器。在制定拆包动态化具体方案之前,我们需要了解其打包流程。要了解 Metro 的打包流程,我们可以从分析构建产物着手,先看看产物的构成。通过分析构建产物,我们可以更好地理解 Metro 的打包机制,从而为拆包动态化方案的制定提供依据。产物构建1. 新建验证模块import { StyleSheet, Text, View, AppRegistry } from "react-native";class RNDemo extends React.Component {render() { return ( RNDemo );}}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 调用)下面我们就针对这三块内容来分析一下,它们具体做了什么。polyfillspolyfills 代码很多(1行-865行),其中主要部分又分成了环境获取、方法定义、模块入口、模块定义。1.1 环境信息这部分我们了解下就可以了,也有人把这边单独划分一层:声明层。var __BUNDLE_START_TIME__=this.nativePerformanceNownativePerformanceNow()ate.now(),__DEV__=false,process=this.process||{},__METRO_GLOBAL_PREFIX__='';process.env=process.env||{};process.env.NODE_ENV=process.env.NODE_ENV||"production";1.2 方法定义重点关注 __d 和 __r, 它们分别对应模块定义和模块执行的函数, 这两个是关键收口函数。 global.__r = metroRequire; global[`${__METRO_GLOBAL_PREFIX__}__d`] = define; global.__c = clear; global.__registerSegment = registerSegment;1.3 模块入口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 {} }2. 模块定义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 里面。现在只是构建了对象数据结构,那么什么时候使用呢?3. 模块执行这一步其实就简单了, 在文件最后。可以看到执行了初始化了两个 module 。45 是系统核心模块,0 就是对应我们的 RNDemo 模块。__r 就会执行 metroRequire, 最终执行上面 function 方法。__r(45);__r(0);MoudleId生成的 bundle 文件里面其实就是通过 MoudleId 来关联模块。如果我们知道模块唯一标识 MoudleId 生成规则,然后把不同页面的放在不同包里面,也通过 MoudleId 来关联模块,就可以进行分包动态化了。那么 MoudleId 是怎么生成的呢?我们可以看看运行打包命令后,引擎内部做了什么?2构建流程了解整个 Metro 打包流程是相对复杂的,考虑我们其实最关心的是MoudleId 生成规则,这里只简单介绍下打包流程。CLI 入口运行命令 react-native 的时候,首先触发的是 react-native-communityvar cli = require('@react-native-community/cli');if (require.main === module) { cli.run();}bundle 命令CLI 启动后,会加载内置的命令,这里我们只需要关注 bundle 打包命令。整体调用流程: run -> setupAndRun -> require("./commands") -> bundle3. 加载 buildBundlebundle 命令最终会加载 buildBundle 方法, 其中核心是 buildBundleWithConfig 。整体上,它主要做了以下几件事:合并 Metro 默认配置和自定义配置。解析配置,构建 requestOpts 对象, 作为打包函数入参。实例化 Metro Server(React Native 的打包工具和开发服务器)。启动 Metro Server 构建 bundle。处理资源文件。关闭 Metro Server。最终,我们知道实际打包是通过 Metro Server 去实现的。4.Metro ServerMetro 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, }), ]);}3 拆包实现参考业内做法,我们重新实现了 createModuleIdFactory 方法,也是从 0 开始计算,通过路径为 Key,递增生成对应 MoudleId 。function createModuleIdFactory() { const fileToIdMap = new Map();letnextId=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;constfile="./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, },};4 热更新完成拆包后,接下来的问题是如何将生成好的基础包、业务包与热更新流程配合使用。1. 包加载顺序和时机首先,我们梳理一下包之间的特点和关系:基础包是整个 React Native 容器运行的基础,所有业务包都依赖基础包。一个引擎,基础包只加载一次。一个页面,业务包也只加载一次。基础包内容较多,基础包包体(1M+)会比业务包(几十 KB)大很多。正常打开一个 React Native 页面,需要保证基础包和对应业务包都加载完成。基于上述特点,应用启动后,我们可以先预加载基础包,然后在打开对应页面时加载对应的业务包。这样既保证了页面打开速度,又避免了一次加载过多业务包的问题。2. 分包加载流程确定了包加载顺序和时机后,还有以下问题需要解决:以 iOS 为例,引擎只提供了一个 sourceURLForBridge 回调方法来设置包体路径。设置完基础包后,如何再加载业务包?如何确定基础包或者业务包已经加载完成?打开页面时,如果基础包没有加载完成,如何处理?基于上述问题,我们逐步分析:2.1 基础包加载:基础包通过 sourceURLForBridge 回调方法来设置。至于业务包的加载,以 iOS 为例,可以使用 RCTCxxBridge 的 executeSourceCode 方法在当前的 RN 实例上下文中执行一段 JS 代码,以此达到增量加载的目的。需要注意的是,executeSourceCode 是 RCTCxxBridge 的私有方法,需要我们用 Category 将其暴露出来。2.2 判断加载完成:判断基础包加载完成,可以通过 RCTJavaScriptDidLoadNotification。对于业务包加载是否完成,仅判断 sourceURLForBridge 是否执行完成是不够的,可能包已经加入,但由于某些原因显示失败。这种情况虽然可以在 JS 侧使用错误边界等方式捕获,但实践中有些情况 JS 侧捕获不到。我们需要一种机制保证容器准确知道是否加载异常。这里我们通过在 JS 侧注册完成页面后,由 JS 侧主动通知原生侧,以感知注册结果。2.3 处理未加载完成的情况:打开页面时,判断基础包是否加载完成。如果基础包加载完成,直接打开页面;否则,我们会先缓存打开页面的信息。如果此时基础包还没有开始加载,主动唤起加载。如果已经在加载,就等基础包加载完成后再读取缓存打开页面。3. 热更新流程确定包加载后,我们开始整体热更新流程的建设:基础包内置:考虑到基础包改动频率很低,基础包可以内置在 App 中,这样提高了加载成功率,同时也方便预加载。业务包兜底:内置一份业务包作为兜底,当首次打开页面或出现异常时,加载兜底业务包。业务包更新:打开页面时,我们会校验是否需要更新业务包。考虑到包体大小,业务包是经过压缩的。同时,我们兼顾了安全性:业务包需要签发后才能分发,本地会对包体进行校验。平台支撑:我们开发了自己的构建平台和灰度平台。构建平台负责构建和 CDN 部署,灰度平台负责下发,端侧从灰度平台拉取更新数据,整体形成自动化闭环。基于上述设计,目前 Soul App 已经全面拥抱 React Native,同时在内部其他 App 中使用。5 后续规划动态化只是 React Native 的一小步,但它是基础建设中重要的一环。后续我们将结合 Soul App 的实际情况,在 React Native 的稳定性、性能等方面进行进一步的深入探究。以上即为本次分享的内容。感谢您的阅读,如果喜欢欢迎多多关注/留言/点赞。
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 会员注册

本版积分规则

QQ|手机版|心飞设计-版权所有:微度网络信息技术服务中心 ( 鲁ICP备17032091号-12 )|网站地图

GMT+8, 2024-12-31 04:43 , Processed in 0.491330 second(s), 25 queries .

Powered by Discuz! X3.5

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表