您的位置:首页 > Web前端 > React

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

2017-04-24 00:00 337 查看
本文的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 )。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: