在網(wǎng)上看到攜程之前拆分的一些經(jīng)驗
先來說一組數(shù)據(jù),一個Helloorld的App,如果使用0.30 RN 官方命令react-native bundle打包出來的JSBundle文件大小大約為531KB,RN框架JavaScript本身占了530KB, zip壓縮之后也有148KB。
如果只有一兩個業(yè)務使用,這點大小算不了什么,但是對于我們這種動輒幾十個業(yè)務的場景,如果每個業(yè)務的JSBundle都需要這么大的一個RN框架本身,那將是不可接受的。
因此,我們需要對RN官方的打包腳本做改造,將框架代碼拆分出來,讓所有業(yè)務使用一份框架代碼。
開始拆分之前, 我們先以HelloWorld的RNApp為基礎(chǔ)介紹幾個背景知識。

上述是一個HelloWorld RNApp代碼的結(jié)構(gòu),基本分為3部分
頭部:各依賴模塊引用部分;
中間:入口模塊和各業(yè)務模塊定義部分;
尾部:入口模塊注冊部分;

上述是HelloWorld RNApp打包之后JSBundle文件的結(jié)構(gòu),基本分為3部分 頭部:全局定義,主要是define,require等全局模塊的定義; 中間:模塊定義,RN框架和業(yè)務的各個模塊定義; 尾部:引擎初始化和入口函數(shù)執(zhí)行;
__d是RN自定義的define,符合CommonJS規(guī)范,__d后面的數(shù)字是模塊的id,是在RN打包過程中,解析依賴關(guān)系,自增長生成的。
如果所有業(yè)務代碼,都遵照一個規(guī)則:入口JS文件首先require的都是react/react-native, 則打包生成的JSBundle里面react/react-native相關(guān)的模塊id都是固定的。
拆分方案一
基于上面2點背景知識介紹,我們很容易發(fā)現(xiàn),如果將打包之后的JSBundle文件,拆分成2部分(框架部分+業(yè)務模塊部分),使用的時候合并起來,然后去加載,即可實現(xiàn)拆分功能。
具體實現(xiàn)步驟:
創(chuàng)建一個空工程,入口文件只需要2行代碼,require react/react-native即可;
使用react-native bundle命令,打包該入口文件,生成common.js;
使用react-native bundle打包業(yè)務工程(有一點要保證,業(yè)務工程入口文件前面2行代碼也是require react/react-native), 生成business_all.js;
開發(fā)工具,從business_all.js里面刪除common.js的內(nèi)容,剩下的就是business.js;
App加載的時候?qū)ommon.js和business.js合并在一起,然后加載;
貌似功能完成,可是回到Dive into React Native performance, 這么做還是優(yōu)化不了JSBundle的執(zhí)行時間,因為我們不能把拆分開的2個文件分別執(zhí)行,因為加載common.js會提示找不到RNApp的入口,先執(zhí)行business.js,會提示一堆依賴的RN模塊找不到。
顯然,這種拆分方式不能滿足我們這種需要。
那這個方案就完全沒有價值嗎?不是的,如果你做的是一個純RNApp,native只是一個殼,里面業(yè)務全是RN開發(fā)的,完全可以使用這種方式做拆分,這種方案簡單,無侵入,實現(xiàn)成本低,不需要修改任何RN打包代碼和RN Runtime代碼。
拆分方案二
RN框架部分文件(common.js)大小530KB,如此大的js文件,占用了絕大部分的JS執(zhí)行時間,這塊時間如果能放到后臺預先做完,進入業(yè)務也只需執(zhí)行業(yè)務頁面的幾個JS文件,將可以大大提升頁面加載速度,參考上面的RN性能瓶頸圖,預估可以提升100%。
按照這個思路,能后臺加載的JS文件, 實際上是就是一個RNApp,因此 我們設(shè)計了一個空白頁面的FakeApp,這個FakeApp做一件事情,就是監(jiān)聽要顯示的真實的業(yè)務JS模塊,收到監(jiān)聽之后,渲染業(yè)務模塊,顯示頁面。
FakeApp設(shè)計如下:

為了實現(xiàn)該拆包方案,需要改造react-native的打包命令;
基于FakeApp打common.js包的時候, 需要記錄RN各個模塊名和模塊id之間的mapping關(guān)系;
打業(yè)務模塊包的時候,判斷,如果已經(jīng)在mapping文件里面的模塊,不要打包到業(yè)務包中
改造頁面加載流程:
因為要能夠后臺加載,所以需分離UI和JS加載引擎<iOS-RCTBridge, Android-ReactInstanceManager>;
進入業(yè)務RN頁面時候,獲取預加載好的JS引擎,然后發(fā)送消息給FakeApp,告知該渲染的業(yè)務JS模塊;
通過后臺預加載,省去了絕大部分的JS加載時間,似乎問題已經(jīng)完美解決。
但是,如果隨著業(yè)務不斷膨脹,一個RN業(yè)務JS代碼也達到500KB,進入這個業(yè)務頁面,500多KB JS文件讀取出來,執(zhí)行,整個JS執(zhí)行的時間瓶頸會再次出現(xiàn)。
拆分方案三
正在此時,我們研究RN在Facebook App里面的使用情況,發(fā)現(xiàn)了Unbundle,簡單點說,就是將所有的JS模塊都拆分成獨立的文件。
下面截圖就是unbundle打包的文件格式:

entry.js就是global部分定義+RNApp入口;
UNBUNDLE文件是用于標識這是一個unbundle包的flag;
12.js,13.js就是各個模塊,文件名就是模塊id;
在業(yè)務執(zhí)行,需要加載模塊(require)的時候,就去磁盤查找該文件,讀取、執(zhí)行。
RN里面加載模塊流程說明,以require(66666)模塊為例:
首先從__d<就是前文提到的define>的緩存列表里面查找是否有定義過模塊66666,如果有,直接返回,如果沒有走到下面第二步的nativeRequire;
nativeRequire根據(jù)模塊id,查找文件所在路徑,讀取文件內(nèi)容;
定義模塊,_d(66666)=eval(JS文件內(nèi)容),會將這個模塊ID和JS代碼執(zhí)行結(jié)果記錄在define的緩存列表里面;
打包通過react-native unbundle 命令,可以給android平臺打出這樣的unbundle包。
順便提一下,這個unbundle方案,只在android上有效,打ios平臺的unbundle包,是打不出來的,在RN的打包腳本上有一行注釋,大致意思是在iOS上眾多小文件讀取,文件IO效率不夠高,android上沒這樣的問題,然后判斷如果是打iOS的unbundle包的時候,直接return了。
相對應的,iOS開發(fā)了一個prepack的打包模式,簡單點說,就是把所有的JS模塊打包到一個文件里面,打包成一個二進制文件,并固定0xFB0BD1E5為文件開始,這個二進制文件里面有個meta-table,記錄各個模塊在文件中的相對位置,在加載模塊(require)的時候,通過fseek,找到相應的文件開始,讀取,執(zhí)行。
在Unbundle的啟發(fā)下,我們修改打包工具,開發(fā)了CRNUnbunle,做了簡單的優(yōu)化,把眾多零散的JS文件做了簡單的合并。

將common部分的JS文件,合并成一個common_ios(android).js.
_crn_config記錄了這個RNApp的入口模塊ID以及其他配置信息,詳見下圖:

main_module為當前業(yè)務模塊入口模塊ID;
module_path為業(yè)務模塊JS文件所在當前包的相對路徑;
666666=0.js,說明666666這個模塊在0.js文件里面;
做完這個拆包和加載優(yōu)化之后,我們用自己的幾個業(yè)務做了下測試,下圖是當時的測試驗證數(shù)據(jù)。

可以看出,iOS和android基本都比官方打包方式的加載時間,減少了50%。
這是自己單機測試的數(shù)據(jù),那上線之后,數(shù)據(jù)如何呢?
下圖,是我們分析一天的數(shù)據(jù),得出的平均值<排除掉了5s以上的異常數(shù)據(jù),后面實測下來5s以上數(shù)據(jù)極少>;

看到這個數(shù)據(jù),發(fā)現(xiàn)和我們自己測試的基本一致,但是還有一個疑問,加載的時間分布,是否服從正態(tài)分布,會不會很離散,快的設(shè)備很快,慢的設(shè)備很慢呢?
然后我又進一步分析這一天的數(shù)據(jù),按照頁面加載時間區(qū)間分布統(tǒng)計。

看圖上數(shù)據(jù),很明顯,iOS&Android基本一致,將近98%的用戶都能在1s內(nèi)加載完成頁面,符合我們期望的正態(tài)分布,所以bundle拆分到此基本完成。
實踐
我先用bundle打包命令打一個bundle出來
react-nativebundle --platform android --devfalse--entry-file index.android.js --bundle-output finalbundle/index.android.bundle --assets-dest finalbundle/
只有一個簡單的3k左右的index.android.js,打出了一個五百多k的index.android.bundle,看看里面是些什么


密密麻麻但又有規(guī)則
!function打頭的是公共的頭部部分
_d(function是JS文件,用ctrl+s搜索welcome,找到我們的index.android.js,原來是在第一行的_d(function,而且結(jié)尾有個參數(shù)0,其余部分其實都是公共的js
;require(120),是基礎(chǔ)文件的配置入口,require(0)則是業(yè)務的入口
基于以上,能想到一個辦法:
內(nèi)置一個common.js文件,里面包含了bundle文件公共部分的代碼,
業(yè)務代碼單獨生成一個js文件
在需要展示加載某一個頁面的時,將common.js和當前頁面需要加載的業(yè)務js合并,然后再加載
這個辦法解決了一部分問題,但加載時還是一個整體。如果common部分能重用,就能大大提升效率。所以就來試試上面提到的unbundle命令
react-nativeunbundle --platform android --devfalse--entry-file index.android.js --bundle-output build/index.android.bundle
生成的bundle只有14行了

但多了一個js-modules文件夾,里面的xx.js里面的內(nèi)容就是將之前的__d(xx)抽出來單獨放到一個文件里面,通過require(xx)加載到內(nèi)存供調(diào)用

基于unbundle命令再設(shè)計一個上面提到的fake頁面用來加載相應的業(yè)務模塊,這個頁面可以預先在后臺初始化js引擎,將公共部分的common.js文件讀取到內(nèi)存,然后設(shè)置一個監(jiān)聽事件,通過emmit方式,當需要加載某個頁面的的module的時候講這個頁面的module的id傳遞過來,然后通過require方法調(diào)用這個模塊。
思路差不多是這樣了,來試試看實現(xiàn)起來有沒什么坑。
首先
我拿例子跑了一下,瞬間明白了流程是怎么回事,有幾個關(guān)鍵:
DeviceEventEmitter
前端發(fā)起監(jiān)聽,后端需要用的時候調(diào)用emit觸發(fā),通過返回模塊id,然后return React.createElement(返回的模塊ID,this.props)即可定制加載
配置文件

這個配置文件之前不是很理解為什么好多等于0.js、等于1.js,現(xiàn)在明白其實就是不同bu的入口JS,因為都是單頁路由的形式,不過這個配置其實是一套打包的一個流程,不在這里做,以后研究打包工具的時候加上。
然后
我試著把這樣融入到之前的demo里。
先建兩個test頁面,用于測試切換。
importReact, { Component }from'react';
import{
AppRegistry,
StyleSheet,
Text,
View,
}from'react-native';
classtesteightextendsComponent{
render() {
return(
<Viewstyle={styles.container}>
<Textstyle={styles.welcome}>
Welcome to Test 8888
</Text>
</View>
);
}
}
conststyles = StyleSheet.create({
welcome: {
fontSize:20,
textAlign:'center',
margin:10,
}
});
module.exports = testeight;
然后在index.android.js加入切換按鈕
/**
* Sample React Native App
* https://github.com/facebook/react-native
* @flow
*/
importReact, { Component }from'react';
import{
AppRegistry,
StyleSheet,
Text,
View,
Image,
NativeModules,
DeviceEventEmitter,
}from'react-native';
exportdefaultclassAwesomeProjectextendsComponent{
constructor(props){
super(props);
this.state = {
content:null,showModule:false
};
DeviceEventEmitter.addListener("test", (result) => {
letmainComponent =require(result.name);
this.setState({
content:mainComponent,
showModule:true
})
});
}
render() {
let_content =null;
if(this.state.content){
_content = React.createElement(this.state.content,this.props);
return_content;
}else{
return(
<Viewstyle={styles.container}>
<Textstyle={styles.welcome}>
Welcome to React Native!
</Text>
<Textstyle={styles.instructions}>
To get started, edit index.android.js
</Text>
<Textstyle={styles.instructions}>
Double tap R on your keyboard to reload,{'\n'}
Shake or press menu button for dev menu
</Text>
<Textstyle={styles.instructions}onPress={()=>this.showToast()}>
點我調(diào)用原生
</Text>
<Textstyle={styles.instructions}onPress={()=>this.updateBundle()}>
點我更新bundle
</Text>
<Textstyle={styles.instructions}onPress={()=>this.goNine()}>
點我加載頁面9999
</Text>
<Textstyle={styles.instructions}onPress={()=>this.goEight()}>
點我加載頁面8888
</Text>
<Image
source={require('./img/music_play.png')}
style={{width:92,height:92}}
/>
</View>
);
}
}
updateBundle () {
NativeModules.updateBundle.check("5.0.0");
}
showToast () {
//調(diào)用原生
NativeModules.RNToastAndroid.show('from native',100);
}
goNine () {
NativeModules.BundleLoad.goPage(9999);
}
goEight () {
NativeModules.BundleLoad.goPage(8888);
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#F5FCFF',
},
welcome: {
fontSize: 20,
textAlign: 'center',
margin: 10,
},
instructions: {
textAlign: 'center',
color: '#333333',
marginBottom: 5,
},
});
AppRegistry.registerComponent('rnandnative', () => AwesomeProject);
然后把index.android和兩個test頁面都用unbundle打包
react-nativeunbundle --platform android --devfalse--entry-file index.android.js --bundle-output unbundle/index.android.bundle
react-nativeunbundle --platform android --devfalse--entry-file bundletest1.js --bundle-output unbundle/index.android.bundle1
react-nativeunbundle --platform android --devfalse--entry-file bundletest2.js --bundle-output unbundle/index.android.bundle2
然后把index.android.bundle1、index.android.bundle2中除了_d的那句打頭的去掉,把__d(0的0改為9999、8888,把文件名改為9999.js和8888.js丟到j(luò)s-modules里,這個講的估計不是很明白,但去看看代碼就懂了。
然后建一個觸發(fā)emit的方法
public class RNBundleLoadModule extends ReactContextBaseJavaModule{
private ReactApplicationContext reactApplicationContext;
public RNBundleLoadModule(ReactApplicationContext reactApplicationContext){
super(reactApplicationContext);
}
@Override
public String? getName(){
return "BundleLoad";
}
@ReactMethod
public void goPage(final Integer pageid){
System.out.print("########"+pageid+"########");
// failedCallback.invoke();
WritableMap params = Arguments.createMap();
params.putInt("name", pageid);
reactApplicationContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
.emit("test", params);
}
}
跑起來,一切OK。
參考