【React Native】一个简单的拆分Bundle&资源做法 您所在的位置:网站首页 react拆解 【React Native】一个简单的拆分Bundle&资源做法

【React Native】一个简单的拆分Bundle&资源做法

2024-02-17 14:59| 来源: 网络整理| 查看: 265

本文的RN代码基于0.43版本

一般应用React Native(RN)后,随着使用页面的增加,bundle包(携带资源)会逐渐加大,这会带来以下两个缺点:

页面启动速度&内存占用增加 这是不言而喻的,一个页面启动时会加载其他无关页面的代码,自然会有内存占用加大、启动时间增加的问题,这部分的消耗是不应该的。

更新流量消耗增加 要更新某块代码必须下发整个bundle,尽管只更新其中1/10部分的代码。

官方的打包并没有做类似拆分的事情,它打包出来就是一份bundle+资源。可能唯一值得一提的是它的unbundle ( https://github.com/facebook/react-native/blob/master/local-cli/bundle/output/unbundle/index.js ),它会将所有module进行拆分。那今天我就分享一下最近研究的成果,对RN打出来的bundle进行处理并自定义拆分代码&资源,一种无侵入式的后处理机制。不够完美,但是基本可用。

Bundle代码结构一览

RN打出来的Bundle其实就是一个js文件,如果设置了--assets-dest则会将引用到的资源输出,它的结构由上至下分为三部分,我们来分别探索一下:

1. Polyfills

它们是Bundle最开始的一段代码,主要是向Javascript解释器上下文注入一些能力,比如模块系统、require、console等都在这里注入。

默认要注入的polyfill在packager/defaults.js( https://github.com/facebook/react-native/blob/master/packager/defaults.js )中可以看到:

这些polyfills的用途根据其名字就大概能猜到了,有兴趣的朋友可以自行探索,这里不展开讲。除了它们,还会加入额外两个polyfill,它们相当于是元组件,是连这些polyfills都需要依赖的几个组件,会出现在bundle的最前面,它们是:

global.__DEV__的设置模块;

模块系统,模块定义函数、require函数,都在这里定义,这样javascript解释器才能拥有模块系统的功能。

它们被引用的地方在packager/src/Resolver/index.js( https://github.com/facebook/react-native/blob/master/packager/src/Resolver/index.js#L119 ),获取模块系统依赖时会将它们转换成polyfills,并在使用时插入到polyfills列表最前端。

这些polyfills生成到bundle的代码就是闭包的调用,生成规则在packager/src/Resolver/index.js中可以看到:

我们看到它是函数的定义&调用,通过注入global变量来将一些全局使用的元素attach到global上。

2. Module Declaration

这里通过解析入口模块(--entry-file指定的文件)的依赖,将所有引用到的模块转化成module list,按依赖顺序进行注册输出。

RN的packager使用babylon( https://github.com/babel/babylon )来处理&解析模块依赖,使用改版的node-haste( https://github.com/facebookarchive/node-haste/tree/master )来管理模块依赖树,对entry-file进行解析。

之所以说改版的node-haste,是因为这块代码已经不随原仓库,而是在RN packager中的一个子module独立维护了(见node-haste/index.js ( https://github.com/facebook/react-native/blob/master/packager/src/node-haste/index.js )),由于需要处理ES6、Flow,它需要通过babylon来处理源代码后,再对转码后的AST(词法分析树,Abstract Syntax Tree)解析模块依赖,还需要解析资源文件,这些在原版代码中都没有。

关于模块依赖树解析这里不讲太深,提出几个关键代码有兴趣的同学可以自己参考:

extract-dependencies.js ( https://github.com/facebook/react-native/blob/master/packager/src/JSTransformer/worker/extract-dependencies.js ) 通过babylon&babel解析AST,获取单个模块依赖

Module.js ( https://github.com/facebook/react-native/blob/master/packager/src/node-haste/Module.js#L171 ) 获取单个模块依赖,这里走下去会调用注入的transformCode,也就是上面的extract-dependencies.js模块去循环解析模块依赖。

那我们来看看模块注册的代码生成规则,还是在上面那个文件packager/src/Resolver/index.js中,我们可以看到函数 defineModuleCode,它负责生成模块注册部分的代码。

这里的code是已经被babel转码过的代码,关于这个__d,可以在之前的polyfills:polyfills/require.js中看到:global.__d = define;,这个define函数会将对应id的模块注册到一个全局变量modules里。

3. Module Calls

由于前面定义模块时并没有调用任何模块,它只是将模块代码放在闭包中注册给全局module。要让程序运行起来,就必须调用必要的代码。这最后部分Module Calls就是一些预定义的模块调用及入口模块(传入的--entry-file)调用。

这块代码的添加可以在packager/src/Bundler/Bundle.js中看到,它默认会加入的是InitializeCore模块

这里添加的代码就非常非常简单了,就是一个require(moduleId);。

资源引用方式探索

接下来再说说资源(主要指图片)是怎么被使用的。假如我们在代码中使用了随Bundle的资源,比如图片,那么它会被打到--asset-dest指定的目录中,随着--platform的不同,打出来资源路径也不同。在Android中会打出drawable-xdpi这样的目录,在iOS(默认platform)则基本直接是相对工程根目录的路径。

我以Android中资源引用为例,来聊聊这个话题。首先我有一个组件引用了资源,它是一个图片:

packageName是在package.json中声明的工程名,在RN中会被解析为项目根路径

首先,很明显的是这个资源引用会被解析为一个模块依赖,在node-haste解析到它时,会将它转换成一个资源模块AssetModule。是否是资源模块的判断很简单,就是查找匹配后缀,默认的资源后缀名可以在packager/defaults.js中看到,就是一些图片、视频、文档的后缀。资源模块生成代码的规则可以在packager/src/Bundler.index.js#_generateAssetObjAndCode中看到,我们直接拿一个打好的资源模块看看:

那么问题来了:RN是怎么找图片资源的呢? Bundle包可能在asset中,可能在文件系统,又有可能是开发者模式下的网络路径,它去哪里找对应的图片?要资源分包必须搞清楚这一点。

那我们自然而然会去看AssetRegistry这个类,但是它里面功能很少,只是将资源json注册到一个全局变量中,返回它的id,可以随时拉取。我们可以去Image.js的render函数中看,在解析、使用资源时,用到的是resolveAssetResource.js这个模块。它会调用AssetResolver.defaultAsset()去解析图片uri,返回给图片。我们去看看:RN中有一个SourceCode模块,它是一个Native模块,持有常量scriptURL,意为bundle的路径。在JS中通过拿到这个路径,可以区分出是由网络、资源还是文件系统中加载的代码。那上面这个的返回逻辑比较清晰,只不过具体的实现细节比较多,我在这里归纳一下:

如果是由网络加载的图片,则将httpServerLocation拼接至sourceUrl上;

如果是由文件系统加载,则有如下两步:

将httpServerLocation抹去前面的/assets/,并将’/‘替换为’_’,对于上面的例子,它会被转换为src_assets_naruto.jpeg

将处理后的location拼接上scale对应的dpi drawable路径,再拼接到sourceURL上。对于上面的例子,它会被转换为sourceURL/drawable-mdpi/src_assets_naruto.jpeg

其他情况则直接去资源中查找,查找的资源id是文件系统第一步中对location的改造后的id(src_assets_naruto)。

拆分Bundle第一步 - 解析&拆分代码

假设我们要拆分出两个bundle包:base/business。其中base包括react-native代码、部分自定义module代码;business包括业务代码。

首先我们要解析bundle,拆分出polyfill、module声明、module调用三部分代码,必须明确的几点是:

polyfill、react-native声明的、依赖的module要放在base里

自定义添加到base里的module、它们依赖的module要放在base里

business入口所依赖的任何非base的module放在拆分出的bundle里

这一步我们可以通过一些JS解析工具,比如babel&babylon( https://github.com/babel/babylon ),或者UglifyJS( https://github.com/mishoo/UglifyJS2 )来解析bundle,由于polyfill、module declaration、module call三种类型的代码格式是完全按照规范来,所以它们对应的也就是三种AST node,我们只需要按照按照对应规则来解析就好了,比如 module declaration:

可以看到这个判断非常简单,其实只要在解析的时候将它们打出来观察规律即可。然后从node的api中找到它所声明的模块值,记录下来。在解析模块声明时,还需要注意解析它直接依赖的模块,记录在案,方便后续收集模块依赖。

至于收集依赖的方法就比较见仁见智了,很多方法可以做,可以通过babel.traverse(ASTNode, callback),或者更简单的,由于bundle是已经被转码成es5代码,可以直接使用正则表达式在ASTNode所属的代码块中查找require字样(我使用了这个方法,表达式:/require\s?\(([0-9]+)[^)]*\)/g)。收集一级摸快依赖后,后续必须向下循环收集所有被依赖到的模块,这一块稍微需要一点技巧,可以到我的仓库中看。

同时如果被依赖的模块时资源时,还需要额外记录,在下一步中可以对资源进行操作。

这一步需要做到的目标就是解析出base包、business包各自所需要包含的所有代码,及各自包含的资源模块。

拆分Bundle第二步 - 移动资源

在默认拆分出的bundle中,它的目录是这样:

root/  |- index.bundle  |- drawable-mdpi/src_assets_naruto.jpeg

但是我们拆分出的bundle后,肯定不能资源搅在一起,我们希望的目录分级是这样:

root/  |- base/      |- index.bundle      |- drawable-mdpi/xxx.jpg  |-business/      |- index.bundle      |- drawable-mdpi/src_assets_naruto.jpeg

这下就不是特别好办了,所以我采用了注入bundle代码的形式来做资源引用。什么意思呢?就是当解析到资源模块时,我们向这个资源模块注入它所属的bundle名,例如:

我们通过一些代码操作trick可以做到这个事情,然后在资源使用处resolveAssetSource.js中发现有一段很有意思的代码:

我们发现它其实是可以自定义资源查找路径的,于是当然大有可为,我们就将这个resolve逻辑稍微进行修改,让它去找子module路径下的资源,而不是写死的直接找scriptURL路径下。这一步做法也很多,最简单的可以改SourceCode.scriptURL路径为bundle的上层路径,然后加入一层子bundle目录。

能够自己寻址,就很好了,我们在解析到资源module后直接将它的目标文件移动到对应的目录下即可。

拆分bundle的第三步 - 修改Native代码

首先RN框架的bundle加载是和它声明周期写死的,如果我们需要按需加载子module就对框架要有一些修改。

主要的修改点主要在于生命周期的变动,启动时加载base module可以通过RN官方的做法,将它设给框架作为默认启动的bundle。但是子bundle页面就不能直接继承ReactActivity了,它必须自己负责加载起代码,并attach ReactRootView。见我的Demo代码(BaseSubBundleActivity( https://github.com/desmond1121/react-native-split/blob/master/split-example/android/app/src/main/java/com/example/BaseSubBundleActivity.java ))。

混淆代码

首先这个做法不支持RN自带的minify bundle,这样它会剔除一些我们要用到的信息(比如模块id对应的模块名字,虽然会保存在另外文件中,但是会对操作带来更多困难)。但是我们可以通过手动uglify对bundle进行混淆,此时需要注意保留两个值:__d与require,它们是我们解析AST中比较需要用到的两个值。并且minify以后的闭包调用会变成!function(){}()这样的用法(比(function(){})()这样的用法少了一个字符),AST的解析规则也要对应的有一点修改。

后续的必要事情

对RN打包出的bundle进行拆分虽然做起来很简单,但是它还有一个大坑:在模块关系变动、新增&删减模块时很难保持一致性。

比如,我们在base里新增了一个模块,由于模块id是按依赖顺序生成的,那base里面的模块id就会不一样。这样就造成了一个比较蛋疼的后果:后面所有的business的模块在引用base时基本上都会受到影响,因为原先使用的base module id都被改动了,这就造成了升级base,其他bu也要升级;或者 一个bu会影响其他bu的这样一种结果。对于这种情况,也是可以见仁见智地处理。

我建议的做法是:直接舍弃module ID,将所有的module ID替换为module名(即字符串)。这样一来无论怎么升级都不会影响。主要是bundle体积会增大一点,但是我认为是值得的,因为这样比较无风险。做法也很简单,三件事情:

将Module声明的参数进行替换;

Module代码、Module调用的require(id)替换成require(name);

将require这个polyfill中对moduleId类型字符串的强制检查去掉;

其实通过ASTNode分析与一些字符串替换就能做到,在我的Example里已经做了,大家可以移步参考。

最后,talk is cheap, 还是直接看代码利索一点: react-native-split( https://github.com/desmond1121/react-native-split )。



【本文地址】

公司简介

联系我们

今日新闻

    推荐新闻

    专题文章
      CopyRight 2018-2019 实验室设备网 版权所有