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

本文的RN代码基于0.43版本

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

  • 页面启动速度&内存占用增加 这是不言而喻的,一个页面启动时会加载其他无关页面的代码,自然会有内存占用加大、启动时间增加的问题,这部分的消耗是不应该的。
  • 更新流量消耗增加 要更新某块代码必须下发整个bundle,尽管只更新其中1/10部分的代码。

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

Bundle代码结构一览

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

1. Polyfills

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

默认要注入的polyfill在packager/defaults.js中可以看到:

1
2
3
4
5
6
7
8
9
10
11
exports.polyfills = [
require.resolve('./src/Resolver/polyfills/polyfills.js'),
require.resolve('./src/Resolver/polyfills/console.js'),
require.resolve('./src/Resolver/polyfills/error-guard.js'),
require.resolve('./src/Resolver/polyfills/Number.es6.js'),
require.resolve('./src/Resolver/polyfills/String.prototype.es6.js'),
require.resolve('./src/Resolver/polyfills/Array.prototype.es6.js'),
require.resolve('./src/Resolver/polyfills/Array.es6.js'),
require.resolve('./src/Resolver/polyfills/Object.es7.js'),
require.resolve('./src/Resolver/polyfills/babelHelpers.js'),
];

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

    1. global.__DEV__的设置模块;
    1. 模块系统,模块定义函数、require函数,都在这里定义,这样javascript解释器才能拥有模块系统的功能。

它们被引用的地方在packager/src/Resolver/index.js,获取模块系统依赖时会将它们转换成polyfills,并在使用时插入到polyfills列表最前端。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
getModuleSystemDependencies({dev = true}: {dev?: boolean}): Array<Module> {
const prelude = dev
? pathJoin(__dirname, 'polyfills/prelude_dev.js')
: pathJoin(__dirname, 'polyfills/prelude.js');
const moduleSystem = defaults.moduleSystem; //这里返回 packager/src/Resolver/polyfills/require.js
return [
prelude,
moduleSystem,
].map(moduleName => this._depGraph.createPolyfill({ //将他们包装成polyfill
file: moduleName,
id: moduleName,
dependencies: [],
}));
}

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

1
2
3
4
5
6
7
function definePolyfillCode(code) {
return [
'(function(global) {',
code, // 这里是对应polyfill文件内的代码
`\n})(typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : this);`,
].join('');
}

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

2. Module Declaration

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

RN的packager使用babylon来处理&解析模块依赖,使用改版的node-haste来管理模块依赖树,对entry-file进行解析。

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

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

  • extract-dependencies.js 通过babylon&babel解析AST,获取单个模块依赖
  • Module.js 获取单个模块依赖,这里走下去会调用注入的transformCode,也就是上面的extract-dependencies.js模块去循环解析模块依赖。

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

1
2
3
4
5
6
7
8
9
10
11
function defineModuleCode(moduleName, code, verboseName = '', dev = true) {
return [
`__d(/* ${verboseName} */`,
'function(global, require, module, exports) {',
code, // 模块文件所包含的代码
'\n}, ',
`${JSON.stringify(moduleName)}`, // module id
dev ? `, null, ${JSON.stringify(verboseName)}` : '',
');',
].join('');
}

这里的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模块

1
2
3
4
5
6
7
8
9
10
11
finalize(options: FinalizeOptions) {
options = options || {};
if (options.runModule) {
// 在packager/defaults.js中声明了runBeforeMainModule = ['InitializeCore']
options.runBeforeMainModule.forEach(this._addRequireCall, this);
// entry file模块
this._addRequireCall(this.getMainModuleId());
}
super.finalize(options);
}

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

资源引用方式探索

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

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

1
2
<Image
src={require('packageName/src/assets/naruto.jpeg')} />

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

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

1
2
3
4
5
6
7
8
9
10
11
__d(/* example/src/assets/naruto.jpeg */function(global, require, module, exports) {
module.exports = require(173).registerAsset({ // 173 = react-native/Libraries/Image/AssetRegistry
"__packager_asset":true,
"httpServerLocation":"/assets/src/assets", // 图片的相对路径
"width":960, // 图片宽
"height":540, // 图片高
"scales":[1], // 图片适合的dpi
"hash":"58152c62118ac492f12163c5521041fd",
"name":"naruto", //图片名
"type":"jpeg"}); // 图片后缀
}, 409, null, "example/src/assets/naruto.jpeg");

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

那我们自然而然会去看AssetRegistry这个类,但是它里面功能很少,只是将资源json注册到一个全局变量中,返回它的id,可以随时拉取。我们可以去Image.js的render函数中看,在解析、使用资源时,用到的是resolveAssetResource.js这个模块。它会调用AssetResolver.defaultAsset()去解析图片uri,返回给图片。我们去看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
defaultAsset(): ResolvedAssetSource {
if (this.isLoadedFromServer()) { // 从网络上加载bundle时,则获取网络图片
return this.assetServerURL();
}
if (Platform.OS === 'android') {
return this.isLoadedFromFileSystem() ? // 由文件加载bundle,则从文件系统中获取
this.drawableFolderInBundle() :
this.resourceIdentifierWithoutScale(); // 否则在资源中寻找图片
} else {
return this.scaledAssetPathInBundle();
}
}

RN中有一个SourceCode模块,它是一个Native模块,持有常量scriptURL,意为bundle的路径。在JS中通过拿到这个路径,可以区分出是由网络、资源还是文件系统中加载的代码。那上面这个的返回逻辑比较清晰,只不过具体的实现细节比较多,我在这里归纳一下:

  1. 如果是由网络加载的图片,则将httpServerLocation拼接至sourceUrl上;
  2. 如果是由文件系统加载,则有如下两步:
    • httpServerLocation抹去前面的/assets/,并将’/‘替换为’_’,对于上面的例子,它会被转换为src_assets_naruto.jpeg
    • 将处理后的location拼接上scale对应的dpi drawable路径,再拼接到sourceURL上。对于上面的例子,它会被转换为sourceURL/drawable-mdpi/src_assets_naruto.jpeg
  3. 其他情况则直接去资源中查找,查找的资源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,或者UglifyJS来解析bundle,由于polyfill、module declaration、module call三种类型的代码格式是完全按照规范来,所以它们对应的也就是三种AST node,我们只需要按照按照对应规则来解析就好了,比如 module declaration:

1
2
3
4
5
6
7
8
9
10
function isModuleDeclaration(node : any) : boolean {
try {
return node.type === 'ExpressionStatement'
&& node.expression.type === 'CallExpression'
&& node.expression.callee.type === 'Identifier'
&& node.expression.callee.name === '__d';
} catch (e) {
return false;
}
}

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

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

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

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

拆分Bundle第二步 - 移动资源

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

1
2
3
root/
|- index.bundle
|- drawable-mdpi/src_assets_naruto.jpeg

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

1
2
3
4
5
6
7
root/
|- base/
|- index.bundle
|- drawable-mdpi/xxx.jpg
|-business/
|- index.bundle
|- drawable-mdpi/src_assets_naruto.jpeg

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

1
2
3
4
5
6
7
__d(/* example/src/assets/naruto.jpeg */function(global, require, module, exports) {
module.exports = require(173).registerAsset({
"httpServerLocation":"/assets/src/assets",
"name":"naruto",
"type":"jpeg",
"bundle"::"business"}); // <== 注入的代码
}, 409, null, "example/src/assets/naruto.jpeg");

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function resolveAssetSource(source: any): ?ResolvedAssetSource {
if (typeof source === 'object') {
return source;
}
var asset = AssetRegistry.getAssetByID(source);
if (!asset) {
return null;
}
const resolver = new AssetSourceResolver(getDevServerURL(), getBundleSourcePath(), asset);
if (_customSourceTransformer) {
return _customSourceTransformer(resolver); // <== 就是这里!
}
return resolver.defaultAsset();
}

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

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

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

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

主要的修改点主要在于生命周期的变动,启动时加载base module可以通过RN官方的做法,将它设给框架作为默认启动的bundle。但是子bundle页面就不能直接继承ReactActivity了,它必须自己负责加载起代码,并attach ReactRootView。见我的Demo代码(BaseSubBundleActivity)。

混淆代码

首先这个做法不支持RN自带的minify bundle,这样它会剔除一些我们要用到的信息(比如模块id对应的模块名字,虽然会保存在另外文件中,但是会对操作带来更多困难)。但是我们可以通过手动uglify对bundle进行混淆,此时需要注意保留两个值:__drequire,它们是我们解析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