图片来源:https://unsplash.com/photos/9...本文作者:ssskkk背景随着业务的发展,每个ReactNative应用的代码量都在不断增加.bundle的规模不断扩大,对应用性能的负面影响越来越明显。虽然我们可以使用ReactNative官方工具Metro将其解包拆分为基础包和业务包进行一定程度的优化,但是对于不断增长的业务代码我们无能为力,急需解决方案减少我们的ReactNative应用程序的体积。多业务包首先想到的就是拆分多业务包。既然拆分成一个业务包还不够,那我可以拆分成几个业务包。当一个ReactNative应用拆分成多个业务包时,相当于拆分成多个应用,只是代码在同一个仓库。这虽然可以解决单个应用的持续扩展问题,但是也有很多局限性。接下来一一分析:链接替换,不同的应用需要不同的地址,替换成本高。页面之间的通信以前是单页应用,不同的页面可以直接通信;拆分后,不同的应用程序需要使用客户端桥接来相互通信。性能损失。打开每个拆分的业务包需要一个单独的ReactNative容器。容器的初始化和维护需要消耗内存和CPU。粒度不够,最小的维度也是页面,页面中的组件无法进一步拆分。重复打包,不同页面之间共享的部分工具库,将包含在每个业务包中。打包效率,每个业务包的打包过程都要经过完整的Metro打包过程,拆分多个业务包的打包时间翻倍。动态导入想到前端的另一种解决方案自然是动态导入(Dynamicimport)。基于其动态特性,可以避免多服务包的诸多缺点。此外,通过动态导入,我们可以实现页面按需加载、组件懒加载等能力。但是Metro官方并没有支持动态导入,所以Metro需要深度定制,这也是本文要介绍的ReactNative中动态导入的实现。Metro打包原理在介绍具体方案之前,我们先了解一下Metro的打包机制及其构建产品。封装过程如下图所示。Metro包装会经历三个阶段,即Resolution、Transformation和Serialization。Resolution的作用是从入口构建依赖图;Transformation与Resolution阶段同时执行,其目的是将所有模块(一个模块就是一个模块)转换成目标平台可识别的语言,其中包括高级JavaCript语法的转换(DependingonBaBel),有也是针对特定平台(如Android)的特定polyfill。这两个阶段主要生产供最后阶段消耗的中间产物IR。序列化就是把所有的模块组合起来生成一个bundle。这里需要特别注意MetroAPI文档中SerializerOptions中的两个配置:signature为createModuleIdFactory,type为()=>(path:string)=>number。该函数为每个模块生成一个唯一的moduleId,默认是一个自增数。所有依赖项都依赖于此moduleId。签名是processModuleFilter,类型是(module:Array)=>boolean。该函数用于过滤模块,决定是否进入bundle。Bundle分析一个典型的ReactNativebundle可以从上到下分为三部分:第一部分是polyfills,主要是一些全局变量,比如__DEV__;以及通过IIFE声明的一些重要的全局函数,如:__d、__r等;第二部分是各个模块的定义,以__d开头,所有业务代码都在这个块;第三部分是应用程序的初始化__r(react-native/Libraries/Core/InitializeCore.jsmoduleId)和__r(${entrymoduleId})。下面看具体函数的分析__dfunctionfunctiondefine(factory,moduleId,dependencyMap){constmod={dependencyMap,factory,hasError:false,importedAll:EMPTY,importedDefault:EMPTY,isInitialized:false,publicModule:{exports:{}}};modules[moduleId]=mod;}__d其实就是一个define函数,可以看到它的实现很简单,就是声明一个mode,同时在moduleId和mode之间做一层映射,所以你可以通过moduleId模块实现来获取它。我们看下__d如何使用:__d(function(global,_$$_REQUIRE,_$$_IMPORT_DEFAULT,_$$_IMPORT_ALL,module,exports,_dependencyMap){var_reactNative=_$$_REQUIRE(_dependencyMap[0],"react-native");var_reactNavigation=_$$_REQUIRE(_dependencyMap[1],"react-navigation");var_reactNavigationStack=_$$_REQUIRE(_dependencyMap[2],"react-navigation-stack");var_routes=_$$_REQUIRE(_dependencyMap[3],"./src/routes");var_appJson=_$$_REQUIRE(_dependencyMap[4],"./appJson.json");varAppNavigator=(0,_reactNavigationStack.createStackNavigator)(_routes.RouteConfig,(0,_routes.InitConfig)());varAppContiner=(0,_reactNavigation.createAppContainer)(AppNavigator);_reactNative.AppRegistry.registerComponent(_appJson.name,function(){returnAppContiner;});},0,[1,552,636,664,698],"index.android.js");这是__d的唯一用途处,定义一个模块。这里是对输入参数的解释。第一个是函数,它是模块的工厂函数。所有的业务逻辑都在里面,在__r之后调用;第二个是moduleId,模块的唯一标识;第三部分是它所依赖的模块的moduleId;第四个是这个模块的文件名。__r函数函数metroRequire(moduleId){...constmoduleIdReallyIsNumber=moduleId;constmodule=modules[moduleIdReallyIsNumber];返回模块&&module.isInitialized?module.publicModule.exports:guardedLoadModule(moduleIdReallyIsNumber,moduleLoadtion,moduleLoadtion);module){...returnloadModuleImplementation(moduleId,module);}functionloadModuleImplementation(moduleId,module){...constmoduleObject=module.publicModule;moduleObject.id=moduleId;工厂(全球,metroRequire,metroImportDefault,metroImportAll,moduleObject,moduleObject.exports,dependencyMap);返回moduleObject.exports;...}__r实际上是require函数。如上简化代码所示,require方法首先判断要加载的模块是否已经存在并且已经初始化,如果存在则直接返回模块,否则调用guardedLoadModule方法,最后调用loadModuleImplementation方法。loadModuleImplementation方法获取模块定义时传入的工厂方法并调用,最后返回。基于以上对Metro及其产品bundle工作原理的分析,我们大致可以得出这样一个结论:ReactNative启动时,JS测试(bundle)会先初始化一些变量,然后声明核心方法define和require通过IIFE;然后通过define方法定义所有模块,通过moduleId维护各个模块的依赖关系,维护的链接是require;最后通过require应用的注册方法实现启动。实现动态导入自然需要对当前bundle进行重新拆分重组。整个方案的重点是:split和combine,split就是如何拆分bundle,需要拆分什么样的module,什么时候拆分,split后的bundle存放在哪里(涉及到后面如何获取);integration就是如何获取分离出来的bundle,获取之后依然在正确的context中执行。如前所述,Metro工作分为三个阶段,其中之一是Resolution。这个阶段的主要任务是从入口开始构建整个应用的依赖图,这里为了方便用树来代替。标识条目是一个依赖树,如上所示。正常情况下会生成一个bundle,包括模块A、B、C、D、E、F、G,现在想动态导入模块B、F,怎么办?第一步当然是识别。既然叫动态导入,那么在JavaScript语法中自然会想到动态导入。只需将importA从'.A'改为constA=import('A')即可,这需要引入Babel插件(),其实官方的Metro相关配置包metro-config已经集成了这个插件。官方做的还不止这些。在Transformation阶段,为动态导入的模块添加唯一标识Async=true。此外,Metro在最终产品包中提供了一个名为AsyncRequire.js的文件模板作为动态导入语法的polyfill。具体实现如下:constdynamicRequire=require;module.exports=function(moduleID){returnPromise.resolve().then(()=>dynamicRequire.importAll(moduleID));};总结一下Metro默认会如何处理动态导入:在Transformation中,使用Babel插件处理动态导入语法,并在中间产品中添加标识Async,在Serialization阶段使用Asyncrequire.js作为模板替换动态导入的语法,即constA=import(A);变成constA=function(moduleID){returnPromise.resolve().then(()=>dynamicRequire.importAll(moduleID));};Asyncrequire.js不仅关系到我们如何拆分,还关系到我们的最后的整合,这将在后面讨论。树的分裂通过上面我们知道在构建的过程中会生成一个依赖树,里面会识别出使用动态导入的模块,接下来就是如何分裂这棵树。树的一般处理方法是DFS。对上图的依赖树进行DFS分析后,可以得到如下分裂树,包括一棵主树和两棵异步树。收集每棵树的依赖关系,得到如下三组模块集:A、E、C;B、D、E、G;F,G。当然,在实际场景中,各个模块的依赖远比这复杂,甚至存在循环依赖。在做DFS的过程中,需要遵循两个原则:处理完的模块在遇到每棵异步树后直接退出循环所有依赖的非主树模块都需要包含在bundle生成中。通过这三组模块集合可以得到三个bundle(主树生成的bundle我们称为主bundle;异步树生成的称为异步bundle)。至于如何生成,使用上面提到的Metro中的processBasicModuleFilter方法即可。Metro最初只在构建期间的序列化阶段生成一次捆绑包。现在我们需要为每组模块生成一个包。这里有几个需要注意的问题:去重,一个是已经进入主bundle的模块的异步bundle不需要进入;另一种是同时存在于不同异步树中的模块,对于这个模块,我们可以将其标记为单独打包动态导入,见下图的生成顺序,需要先生成一个异步bundle,然后生成主包。因为需要将异步bundle的信息(如文件名、地址)和moduleId映射到主bundle中,这样真正需要的时候可以通过moduleId的映射获取异步bundle的地址信息。缓存控制,为了保证每个异步bundle在享受缓存机制的同时能够及时更新,需要在异步bundle存储的文件名中加上contenthash。异步bundle如何存储,是和主bundle一起存储还是单独存储,需要Time来获取。这个需要具体分析:对于bundle预加载,可以把异步bundle和主bundle放在一起,需要的时候直接从本地取(所谓预加载就是客户端启动的时候所有的bundle都已经下载好了,有用户打开ReactNative页面时无需下载包)。对于大多数非预加载技术,分离存储更为合适。至此我们已经获得了mainbundle和asynchronousbundle,大致结构如下:/*mainbundle*///moduleId和路径映射varREMOTE_SOURCE_MAP={${id}:${path},...}//IIFE之间__rclassdef(function(global){"usestrict";global.__r=metroRequire;global.__d=define;global.__c=clear;global.__registerSegment=registerSegment;...})(typeofglobal!=='undefined'?global:typeofwindow!=='undefined'?window:this);//业务模块__d(function(global,_$$_REQUIRE,_$$_IMPORT_DEFAULT,_$$_IMPORT_ALL,module,exports,_dependencyMap){var_reactNative=_$$_REQUIRE(_dependencyMap[0],"react-native");var_asyncModule=_$$_REQUIRE(_dependencyMap[4],"metro/src/lib/bundle-modules/asyncRequire")(_dependencyMap[5],"./asyncModule")...},0,[1,550,590,673,701,855],"index.ios.js");...//应用程序启动__r(91);__r(0);/*异步包*///业务模块__d(function(global,_$$_REQUIRE,_$$_IMPORT_DEFAULT,_$$_IMPORT_ALL,module,出口,_dependencyMap){var_reactNative=_$$_REQUIRE(_dependencyMap[0],"react-native");...},855,[956,1126],"asyncModule.js");其实这个阶段大部分工作已经做完了,接下来就是怎么组合了。如前所述,动态导入的语法将被生成的bundle中的AsyncRequire.js中的模板替换。仔细研究代码,发现是包裹了一层require(moduleIdwithPromise)来实现的。现在我们直接require(moduleId)肯定得不到真正的模块实现,因为还没有拿到异步bundle,还没有定义模块。但是AsyncRequire.js可以修改如下constdynamicRequire=require;module.exports=function(moduleID){returnfetch(REMOTE_SOURCE_MAP[moduleID]).then(res=>{//Line1newFunction(res)();//第2行返回dynamicRequire.importAll(moduleID)//第3行});};下一行分析第1行将之前mock的Promise替换为真实的Promise请求,首先获取bundle资源,生成REMOTE_SOURCE_MAP阶段写入主bundle的moduleId和异步bundle的资源地址之间的映射.fetch根据异步bundle的存储方式选择不同的方式获取真正的代码资源;第2行通过Function方法执行得到的代码,也就是模块的声明,这样最后返回模块的时候,已经定义好了;第3行返回实际的模块实现。这样我们就实现了集成,异步bundle的获取和执行都在AsyncRequire.js中完成。总结至此我们完成了ReactNative动态导入的改造。与多服务包相比,由于其动态特性,业务方在使用时,所有的修改都在同一个ReactNative应用的闭环中完成,没有外部感知,所以其诸多缺陷多服务包不存在。同时在构建时会充分利用第一个productionIR,让每个bundle不需要单独走完Metro的完整构建过程。当然,有一点是必须要考虑的,就是我们改造Metro之后,会不会影响后续的升级,导致只有ReactNative和Metro两个版本被锁。事实上,完全没有必要为此担心。从前面的分析可以知道,我们改造整个流程可以分为两个部分:构建时间和运行时间。我们在构建过程中确实添加了很多功能,例如新的分组算法和代码生成;但runtime完全基于现有版本能力的增强。这使得动态导入的运行时没有兼容性问题,即使升级到新版本也不会报错,但是动态导入的能力会在我们重新构建重新构建之前丢失。最后还有一些在生产环境中实际使用的工程化改造,比如:构建平台适配,提供快速访问组件等,限于篇幅,这里不再详述。本文由网易云音乐技术团队发布。未经授权禁止任何形式的转载。我们常年招聘各种技术岗位。如果你要跳槽,又恰好喜欢云音乐,那就加入我们吧grp.music-fe(at)corp.netease.com!