React Native 拆分业务包 bundle拆包 分包 携程方案

引言

React Native以其独到的特性,吸引着互联网公司纷纷为之投入或多或少的人力。在实际的开发过程中,开发者们也确实尝到了甜头,它的组件化思想、热更新机制 以及jsx和es6等的引入,都给开发者们带来了很大的便利。也难怪在npm和github上,每天都会有很多react-native的新模块出现。这也充分表明了各大公司对其的看好。

然而,从目前qq群、微信公众号、社区、论坛等各大信息交流平台中了解到,大家都是保持在研究和观望状态,顶多把某个不重要的页面交给React Native来练手。其中缘由纷繁复杂。

今天我们这里主要是探讨——bundle文件太大

不想看原理,直接看怎么使用请点击这里,开源链接

现状

React Native应用的开发者们,在项目开发完后,都会遇到一个问题,生成的bundle文件太大。一个AwesomeProject项目,在没有什么逻辑代 码的情况下,打完之后约530k。随着业务的增多,业务复杂性的上升,文件的大小势必会急剧增大。react-native打包成一个bundle的做 法,必定是要得到解决的。

分析

react-native默认提供的打包方式有两种:

· 离线打包

react-native bundle

--entry-file index.ios.js

--platform ios

--dev true

--bundle-output dest/main.jsbundle

--assets-dest dest

· 在线打包

http://localhost:8081/index.ios.bundle?platform=ios&dev=true

具体有哪些参数可以打开如下文件进行查看:

$youProjectRoot/AwesomeProject/node_modules/react-native/local-cli/bundle/bundleCommandLineArgs.js

如:

module.exports = [{

command: 'entry-file',

description: 'Path to the root JS file,either absolute or relative to JS root',

type: 'string',

required: true,},

......

官网中还给出了一些其它的使用方式,地址:

https://github.com/facebook/react-native/tree/master/packager

不过,不论哪种方式都是只有一个“entry-file”,然后根据“entry-file”去进行依赖分析、文件压缩等操作,最后输出在 “bundle-output”中。然后通过NSBundle的URLForResource方法来指定加载打好的的bundle文件。如:

jsCodeLocation = [[NSBundlemainBundle] URLForResource:@"main"withExtension:@"jsbundle"];

这样的打包模式,对用户体验来说是非常不错的。但是考虑到国内的网络状况以及对App size的控制,打成一个Bundle的模式在国内还是行不通的。

思考

在传统的Hybrid开发中,要解决文件太大的问题,我们常常会想到如下几个办法:

·  进行拆包

·  按需加载本地文件

·  按需加载线上文件

那么,能否把Hybrid开发中的经验应用在React Native项目中呢。在React Native项目中,针对文件大的问题,我们做了如下尝试:

·  多业务进行拆包

借助gulp、grunt等工具,通过配置不同的任务,在调用React Native提供的打包命令,可以将App打包成多个文件。

·  按需加载本地文件

在开发环境的情况下,React Native是支持加载本地文件的。这里想要做的是,在打包完的bundle中也可以加载本地文件,这就需要对require进行扩张了。

·   按需加载线上文件

在开发Hybrid时,为了减少包体积。开发者们经常会将一些不重要的页面或文件,走线上动态获取的方式。这个功能只有在web端的 requirejs中有,ReactNative和webpack中都是不支持的。要实现此项功能,需要对React Native中的require进行扩张。

·   按需加载react-native模块

不论是reactjs还是react-native,在代码的组织方式上都是按模块进行划分的。可能Facebook也意识到react框架太大了。这个模块划分的方式,给开发者们的按需载入创造了机会。

实现

这里简单阐述下部分功能的实现思路:

·   React Native自身模块拆分

在打完的main.jsbundle中,常常会看到好多polyfills的文件,那这些文件从哪里来的呢。打开

node_modules/react-native/packager/react-packager/src/Resolver/index.js

文件,会看到这些polyfills文件都是在这里设置的,

path.join(__dirname,'polyfills/String.prototype.es6.js'),

path.join(__dirname,'polyfills/Array.prototype.es6.js'),

......

由名字可以看出,这些是用来对es6、es7进行适配的。所以代码中如果只有es5的语法是不是就可以不需要这些文件了呢,这也是个优化点,不过看起来量不大。

有人可能经常会有这样的想法:我们实际项目中用到的React Native模块其实并没几个,我们在打包的时候,是否可以只打包我们需要的模块呢。我们找到文件

/node_modules/react-native/Libraries/react-native/react-native.js

可以看到所有React Native的模块定义都是在这里了,包括Components、APIs等等。

var ReactNative = {

// Components

get ActivityIndicatorIOS() { return require('ActivityIndicatorIOS'); },

get ART() { return require('ReactNativeART'); },

......

所以,可以在打包的时候,根据实际情况,通过脚本等手段,注释掉一些用不到或不常用的模块以减少输出的体积。当然也可以把部分不常用的模块,抽出来单独作为一个文件,在需要的时候,通过按需加载的方式引入进来。

·   业务模块拆分

App的设计一般都是按照业务线划分的。每个业务都对应一套自己的逻辑。当然也有部分业务线会出现依赖情况。按React Native提供的打包方法,将所有业务线的逻辑都打在一起,势必会造成好多业务线代码的浪费。有可能那个业务线就根本不会被用户访问到。所以我们就想着能不能将一些基础的、公共的业务线打在一起,其它独立的业务线都各自独立成包。

React Native提供的打包方法允许输入一个入口文件,那么这个入口文件可以是整个App的入口,也可以是各业务线自己的入口。由此我们可以将各业务线单独成包,但这样的结果并不能直接投入使用。可以想到,这里并没有过滤机制,各业务线之间一些模块会被重复的打进去也包括react-native模块。而 React Native打包提供的参数中也只有blacklist会涉及一些过滤,但却无法满足我们的需求。

还好packager为我们提供了很多可以的API。通过参考

/node_modules/react-native/local-cli/bundle/buildBundle.js

中的打包逻辑,我们以一个入口文件的打包为例,可以将打包逻辑设计成如下

1、加载打包需要用到的模块

var config = require('config.js')

var ReactPackager =require('react-native/packager/react-packager')

var Bundle =require('react-native/packager/react-packager/src/Bundler/Bundle')

var saveAssets =require('react-native/local-cli/bundle/saveAssets')

var outputBundle =require('react-native/local-cli/bundle/output/bundle')

2、创建client

var client =ReactPackager.createClientFor({

projectRoots: config.projectRoots,

blacklistRE: config.blacklistRE,

...})

3、调用outputBundle进行打包,将打包后的bundle返回

outputBundle.build(client, {

entryFile: config.file,

......})

4、分析bundle中的module,将符合条件的module加入到新的bundle中

定义一个新的bundle

var newBundle = new Bundle();

bundle.getModules().forEach(function(module) {

if(filter(module.sourcePath)){

newBundle._modules.push(module);

}......})

5、定义过滤机制

function filter(path){

var ret = true;

if(

(path.indexOf('/react-native/')!=-1)||

(sourcePath.indexOf('/fbjs/')!=-1)||

......){ret = false;}return ret;}

上只是个简单的过滤,在复杂的过滤中,还需要调用ReactPackager.getDependencies找到每个模块的依赖,然后在过滤的时候调用过滤模块的依赖,依次递归才能达到真正的滤掉。

6、对module进行合并、替换等处理

newBundle.finalize()

7、调用outputBundle输出新的bundle

outputBundle.save(newBundle, {},false)

到此,一个带有过滤功能的打包脚本就基本成型了,之后的多文件入口同时打包的功能,也就是要在上面做些扩扩展就可以了。

在打包方面,其实也也可走网络打包,packager的网络打包逻辑中,凡是请求以.bundle结尾的文件,都会对这个文件进行打包。而其它格式 的文件,则请求什么返回什么。所以可以根据该特性来实现打包。可以将过滤条件作为querystring的方式传递过去,然后在

react-native/packager/react-packager/src/Bundler/index.js

文件中对querystring进行拦截,并实现其过滤功能。

然而在实际的拆包中会发现,packager中打出的包都会将模块名称替换为数字id。这导致拆出的包中,引入不到某些模块,因为不是在一起打包,模块的id都对不上,或者会出现重复的情况。

我们的思路是打包的时候不进行id的替换,依然使用原有的模块名称,做到类似在web中requireJS使用的那样。 找到文件

node_modules/react-native/packager/reat-packager/src/Resolver/index.js

将如下代码中的moduleName,替换为model的绝对路径

functiondefineModuleCode(moduleName, code, verboseName = '') {

return [

`__d(`,

`${JSON.stringify(moduleName)}/*<-替换的地方*/ /* ${verboseName} */, `,

`function(global, require, module, exports){`,

`${code}`,

'\n});',

].join('');

}

这样只完成了define(如:define(0,...))中的名称替换,我们还需要找到require(如:require(0))中的名称替换,于是找到如下文件

node_modules/react-native/packager/reat-packager/src/Bundle/Bundle.js

在super(BundleBase)中,定义一个获取模块的方法getModuleName,将下面的super.getMainModuleId替换为super.getModuleName,这样在_addRequireCall就可以拿到模块的绝对路径了

_addRequireCall(moduleId) {

const code =`;require(${JSON.stringify(moduleId)});`;

const name = 'require-' + moduleId;

......

}finalize(options) {

options = options || {};

if (options.runMainModule) {

options.runBeforeMainModule.forEach(this._addRequireCall,this);

this._addRequireCall(super.getMainModuleId());/*<-替换的地方*/

}super.finalize();}

这样就完成了模块名称的保留,我们就可以愉快的使用我们的拆包模块了。

·  按需加载实现

经过上面的介绍,我们已经完成了模块的拆分。那么光有独立的模块还是不能让App运行起来,需要有一种能力将这些模块联系起来,这就是模块加载机制。

常规的加载会有如下两种场景:

1、本地模块

有时候为了加快页面打开速度,我们常常会选择将首页和非首页的页面进行分开打包,在App启动时,只加载首页的模块,待首页模块加载完毕后,再去异步的加载后续页面的模块。这里的本地模块加载就是用在这种场景中。那么在React Native中该如何实现这种加载方式呢。

要读写本地文件,光有javascript是办不到的,所以一定要借助native的能力。简单的代码实现如下:

#import"RCTBridgeModule.h"

@implementation RequireLocal

RCT_EXPORT_MODULE()

RCT_EXPORT_METHOD(loadPath:(NSString*)path callback:(RCTResponseSenderBlock)callback)

{

NSString *filePath = [[NSBundle mainBundle] pathForResource:pathofType:nil];

if ([[NSFileManager defaultManager] fileExistsAtPath:filePath]) {

NSString *content = [[NSString alloc]initWithContentsOfFile:filePath

......

}

@end

代码的流程为:按照React Native中对native模块封装的规范,实现RCTBridgeModule协议,并通过定义宏RCT_EXPORT_MODULE、RCT_EXPORT_METHOD将native模块的功能暴露给javascript来调用。

在native的模块中,采用NSBundle的 pathForResource方法,将文件路拿到。再借助NSString的initWithContentsOfFile方法获取到文件的内容。然后在javascript中,将拿到的内容,进行一次包装,如:

var str='__d("'+filePath+'", function(global, require, module, exports) {'+

content+

'})'

最后调用eval,便可将拿到的内容执行到当前的jscontext中。

2、线上模块

在App的开发中经常会为了控制size大小而发愁,尤其是苹果的100m限制,所以各业务线都在绞尽脑汁的想办法减size。自然而然的大家就想到了将一些资源放在服务端,在需要的时候将其异步加载下来,也就是常常听说的直连。对于服务器异步加载的实现,代码如下:

fetch(filePath)

.then((response) =>response.text())

.then((responseText) => {

......

代码的流程为:采用React Native提供的fetch方法,将需要的模块异步的从服务器上拉回来,接下来的动作,和上面的“本地模块”的逻辑一样。在实际的模块加载中,我们还需要对模块进行缓存,以提高模块的访问速度。

后续

在经过上面的介绍中,我们应该大概知道拆包和按需加载的实现原理。但是大家也都看到了,这要侵入react-native的代码中,进行很多地方的修改。这样不利于之后对react-native的版本升级。所以我们需要想一种更合理的解决办法。也就是我们现在正在做的一个尝试。

将React Native中的cmd模块,在线下或运行时编译为AMD模块,然后调用r.js的来对其进行打包,以达到干净的完成拆包和按需加载的功能。而且r.js 的打包配置的灵活度我觉得比packager、webpack、browserify等工具都灵活好使。

Q&A

问:是否考虑过多个业务公用一套rn的基础库?

魏晓军:是。

问:如果有,怎么做版本控制?

魏晓军:目前通文件夹控制,在我们的app中,基础框架一般只维护2个版本,再要有新的版本就会推动下掉一个老版本 。

问:线上资源的更新策略是什么样的?例如携程酒店和机票是公用一套rn的底层库吗?

魏晓军:更新策略,通过md5对比,差分到文件级别。酒店和机票现在还没上rn版本,若上,则都是公用一套rn底层。

问:用r.js打包react-native比webpack灵活在哪里呢?

魏晓军:这都是相对的,webpack有它独特的优势。这里我只拿r.js中的path、module等属性的概念来做对比,webpack在这方就相对弱了,拆包也只能通过代码中的require.entry来识别。

问:官方 RN 是在不断的迭代更新的,想请问下携程实际使用的是什么版本,和官方 RN 有差异吗?

魏晓军:我们目前是基于0.23开发的。

问:和官方 RN 保持同步更新吗?策略又是怎样的?

魏晓军:不同步更新,也没法同步更新。只有看到某些特别的亮点后,会选择跨越式的更新,如从0.23可能直接到0.32。

原文

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,185评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,445评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,684评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,564评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,681评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,874评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,025评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,761评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,217评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,545评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,694评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,351评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,988评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,778评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,007评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,427评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,580评论 2 349

推荐阅读更多精彩内容