在網(wǎng)上看到攜程之前拆分的一些經(jīng)驗(yàn)
先來說一組數(shù)據(jù),一個(gè)Helloorld的App,如果使用0.30 RN 官方命令react-native bundle打包出來的JSBundle文件大小大約為531KB塘辅,RN框架JavaScript本身占了530KB, zip壓縮之后也有148KB沛善。
如果只有一兩個(gè)業(yè)務(wù)使用惠桃,這點(diǎn)大小算不了什么,但是對(duì)于我們這種動(dòng)輒幾十個(gè)業(yè)務(wù)的場(chǎng)景惰爬,如果每個(gè)業(yè)務(wù)的JSBundle都需要這么大的一個(gè)RN框架本身喊暖,那將是不可接受的。
因此撕瞧,我們需要對(duì)RN官方的打包腳本做改造陵叽,將框架代碼拆分出來,讓所有業(yè)務(wù)使用一份框架代碼丛版。
開始拆分之前, 我們先以HelloWorld的RNApp為基礎(chǔ)介紹幾個(gè)背景知識(shí)巩掺。
上述是一個(gè)HelloWorld RNApp代碼的結(jié)構(gòu),基本分為3部分
頭部:各依賴模塊引用部分硼婿;
中間:入口模塊和各業(yè)務(wù)模塊定義部分锌半;
尾部:入口模塊注冊(cè)部分;
上述是HelloWorld RNApp打包之后JSBundle文件的結(jié)構(gòu)寇漫,基本分為3部分 頭部:全局定義刊殉,主要是define,require等全局模塊的定義州胳; 中間:模塊定義记焊,RN框架和業(yè)務(wù)的各個(gè)模塊定義; 尾部:引擎初始化和入口函數(shù)執(zhí)行栓撞;
__d是RN自定義的define遍膜,符合CommonJS規(guī)范,__d后面的數(shù)字是模塊的id瓤湘,是在RN打包過程中瓢颅,解析依賴關(guān)系,自增長生成的弛说。
如果所有業(yè)務(wù)代碼挽懦,都遵照一個(gè)規(guī)則:入口JS文件首先require的都是react/react-native, 則打包生成的JSBundle里面react/react-native相關(guān)的模塊id都是固定的。
拆分方案一
基于上面2點(diǎn)背景知識(shí)介紹木人,我們很容易發(fā)現(xiàn)信柿,如果將打包之后的JSBundle文件冀偶,拆分成2部分(框架部分+業(yè)務(wù)模塊部分),使用的時(shí)候合并起來渔嚷,然后去加載进鸠,即可實(shí)現(xiàn)拆分功能。
具體實(shí)現(xiàn)步驟:
創(chuàng)建一個(gè)空工程形病,入口文件只需要2行代碼客年,require react/react-native即可;
使用react-native bundle命令窒朋,打包該入口文件搀罢,生成common.js;
使用react-native bundle打包業(yè)務(wù)工程(有一點(diǎn)要保證,業(yè)務(wù)工程入口文件前面2行代碼也是require react/react-native), 生成business_all.js侥猩;
開發(fā)工具,從business_all.js里面刪除common.js的內(nèi)容抵赢,剩下的就是business.js;
App加載的時(shí)候?qū)ommon.js和business.js合并在一起欺劳,然后加載;
貌似功能完成铅鲤,可是回到Dive into React Native performance划提, 這么做還是優(yōu)化不了JSBundle的執(zhí)行時(shí)間,因?yàn)槲覀儾荒馨巡鸱珠_的2個(gè)文件分別執(zhí)行邢享,因?yàn)榧虞dcommon.js會(huì)提示找不到RNApp的入口鹏往,先執(zhí)行business.js,會(huì)提示一堆依賴的RN模塊找不到。
顯然骇塘,這種拆分方式不能滿足我們這種需要伊履。
那這個(gè)方案就完全沒有價(jià)值嗎?不是的款违,如果你做的是一個(gè)純RNApp唐瀑,native只是一個(gè)殼,里面業(yè)務(wù)全是RN開發(fā)的插爹,完全可以使用這種方式做拆分哄辣,這種方案簡單,無侵入赠尾,實(shí)現(xiàn)成本低力穗,不需要修改任何RN打包代碼和RN Runtime代碼。
拆分方案二
RN框架部分文件(common.js)大小530KB气嫁,如此大的js文件当窗,占用了絕大部分的JS執(zhí)行時(shí)間,這塊時(shí)間如果能放到后臺(tái)預(yù)先做完杉编,進(jìn)入業(yè)務(wù)也只需執(zhí)行業(yè)務(wù)頁面的幾個(gè)JS文件超全,將可以大大提升頁面加載速度咆霜,參考上面的RN性能瓶頸圖,預(yù)估可以提升100%嘶朱。
按照這個(gè)思路蛾坯,能后臺(tái)加載的JS文件, 實(shí)際上是就是一個(gè)RNApp,因此 我們?cè)O(shè)計(jì)了一個(gè)空白頁面的FakeApp疏遏,這個(gè)FakeApp做一件事情脉课,就是監(jiān)聽要顯示的真實(shí)的業(yè)務(wù)JS模塊,收到監(jiān)聽之后财异,渲染業(yè)務(wù)模塊倘零,顯示頁面。
FakeApp設(shè)計(jì)如下:
為了實(shí)現(xiàn)該拆包方案戳寸,需要改造react-native的打包命令呈驶;
基于FakeApp打common.js包的時(shí)候, 需要記錄RN各個(gè)模塊名和模塊id之間的mapping關(guān)系疫鹊;
打業(yè)務(wù)模塊包的時(shí)候袖瞻,判斷,如果已經(jīng)在mapping文件里面的模塊拆吆,不要打包到業(yè)務(wù)包中
改造頁面加載流程:
因?yàn)橐軌蚝笈_(tái)加載聋迎,所以需分離UI和JS加載引擎<iOS-RCTBridge, Android-ReactInstanceManager>;
進(jìn)入業(yè)務(wù)RN頁面時(shí)候,獲取預(yù)加載好的JS引擎枣耀,然后發(fā)送消息給FakeApp霉晕,告知該渲染的業(yè)務(wù)JS模塊;
通過后臺(tái)預(yù)加載捞奕,省去了絕大部分的JS加載時(shí)間牺堰,似乎問題已經(jīng)完美解決。
但是缝彬,如果隨著業(yè)務(wù)不斷膨脹萌焰,一個(gè)RN業(yè)務(wù)JS代碼也達(dá)到500KB,進(jìn)入這個(gè)業(yè)務(wù)頁面谷浅,500多KB JS文件讀取出來扒俯,執(zhí)行,整個(gè)JS執(zhí)行的時(shí)間瓶頸會(huì)再次出現(xiàn)一疯。
拆分方案三
正在此時(shí)撼玄,我們研究RN在Facebook App里面的使用情況,發(fā)現(xiàn)了Unbundle墩邀,簡單點(diǎn)說掌猛,就是將所有的JS模塊都拆分成獨(dú)立的文件。
下面截圖就是unbundle打包的文件格式:
entry.js就是global部分定義+RNApp入口;
UNBUNDLE文件是用于標(biāo)識(shí)這是一個(gè)unbundle包的flag荔茬;
12.js,13.js就是各個(gè)模塊废膘,文件名就是模塊id;
在業(yè)務(wù)執(zhí)行慕蔚,需要加載模塊(require)的時(shí)候丐黄,就去磁盤查找該文件,讀取孔飒、執(zhí)行灌闺。
RN里面加載模塊流程說明,以require(66666)模塊為例:
首先從__d<就是前文提到的define>的緩存列表里面查找是否有定義過模塊66666,如果有坏瞄,直接返回桂对,如果沒有走到下面第二步的nativeRequire;
nativeRequire根據(jù)模塊id鸠匀,查找文件所在路徑蕉斜,讀取文件內(nèi)容;
定義模塊缀棍,_d(66666)=eval(JS文件內(nèi)容)蛛勉,會(huì)將這個(gè)模塊ID和JS代碼執(zhí)行結(jié)果記錄在define的緩存列表里面;
打包通過react-native unbundle 命令睦柴,可以給android平臺(tái)打出這樣的unbundle包。
順便提一下毡熏,這個(gè)unbundle方案坦敌,只在android上有效,打ios平臺(tái)的unbundle包痢法,是打不出來的狱窘,在RN的打包腳本上有一行注釋,大致意思是在iOS上眾多小文件讀取财搁,文件IO效率不夠高蘸炸,android上沒這樣的問題,然后判斷如果是打iOS的unbundle包的時(shí)候尖奔,直接return了搭儒。
相對(duì)應(yīng)的,iOS開發(fā)了一個(gè)prepack的打包模式提茁,簡單點(diǎn)說淹禾,就是把所有的JS模塊打包到一個(gè)文件里面,打包成一個(gè)二進(jìn)制文件茴扁,并固定0xFB0BD1E5為文件開始铃岔,這個(gè)二進(jìn)制文件里面有個(gè)meta-table,記錄各個(gè)模塊在文件中的相對(duì)位置峭火,在加載模塊(require)的時(shí)候毁习,通過fseek智嚷,找到相應(yīng)的文件開始,讀取纺且,執(zhí)行盏道。
在Unbundle的啟發(fā)下,我們修改打包工具隆檀,開發(fā)了CRNUnbunle摇天,做了簡單的優(yōu)化,把眾多零散的JS文件做了簡單的合并恐仑。
將common部分的JS文件泉坐,合并成一個(gè)common_ios(android).js.
_crn_config記錄了這個(gè)RNApp的入口模塊ID以及其他配置信息,詳見下圖:
main_module為當(dāng)前業(yè)務(wù)模塊入口模塊ID裳仆;
module_path為業(yè)務(wù)模塊JS文件所在當(dāng)前包的相對(duì)路徑腕让;
666666=0.js,說明666666這個(gè)模塊在0.js文件里面;
做完這個(gè)拆包和加載優(yōu)化之后歧斟,我們用自己的幾個(gè)業(yè)務(wù)做了下測(cè)試纯丸,下圖是當(dāng)時(shí)的測(cè)試驗(yàn)證數(shù)據(jù)。
可以看出静袖,iOS和android基本都比官方打包方式的加載時(shí)間觉鼻,減少了50%。
這是自己單機(jī)測(cè)試的數(shù)據(jù)队橙,那上線之后坠陈,數(shù)據(jù)如何呢?
下圖捐康,是我們分析一天的數(shù)據(jù)仇矾,得出的平均值<排除掉了5s以上的異常數(shù)據(jù),后面實(shí)測(cè)下來5s以上數(shù)據(jù)極少>解总;
看到這個(gè)數(shù)據(jù)贮匕,發(fā)現(xiàn)和我們自己測(cè)試的基本一致,但是還有一個(gè)疑問花枫,加載的時(shí)間分布刻盐,是否服從正態(tài)分布,會(huì)不會(huì)很離散乌昔,快的設(shè)備很快隙疚,慢的設(shè)備很慢呢?
然后我又進(jìn)一步分析這一天的數(shù)據(jù)磕道,按照頁面加載時(shí)間區(qū)間分布統(tǒng)計(jì)供屉。
看圖上數(shù)據(jù),很明顯,iOS&Android基本一致伶丐,將近98%的用戶都能在1s內(nèi)加載完成頁面悼做,符合我們期望的正態(tài)分布,所以bundle拆分到此基本完成哗魂。
實(shí)踐
我先用bundle打包命令打一個(gè)bundle出來
react-native bundle --platform android --dev false --entry-file index.android.js --bundle-output finalbundle/index.android.bundle --assets-dest finalbundle/
只有一個(gè)簡單的3k左右的index.android.js,打出了一個(gè)五百多k的index.android.bundle肛走,看看里面是些什么
密密麻麻但又有規(guī)則
- !function打頭的是公共的頭部部分
- _d(function是JS文件,用ctrl+s搜索welcome录别,找到我們的index.android.js,原來是在第一行的_d(function,而且結(jié)尾有個(gè)參數(shù)0朽色,其余部分其實(shí)都是公共的js
- ;require(120),是基礎(chǔ)文件的配置入口组题,require(0)則是業(yè)務(wù)的入口
基于以上葫男,能想到一個(gè)辦法:
- 內(nèi)置一個(gè)common.js文件,里面包含了bundle文件公共部分的代碼崔列,
- 業(yè)務(wù)代碼單獨(dú)生成一個(gè)js文件
- 在需要展示加載某一個(gè)頁面的時(shí)梢褐,將common.js和當(dāng)前頁面需要加載的業(yè)務(wù)js合并,然后再加載
這個(gè)辦法解決了一部分問題赵讯,但加載時(shí)還是一個(gè)整體盈咳。如果common部分能重用,就能大大提升效率边翼。所以就來試試上面提到的unbundle命令
react-native unbundle --platform android --dev false --entry-file index.android.js --bundle-output build/index.android.bundle
生成的bundle只有14行了
但多了一個(gè)js-modules文件夾鱼响,里面的xx.js里面的內(nèi)容就是將之前的__d(xx)抽出來單獨(dú)放到一個(gè)文件里面,通過require(xx)加載到內(nèi)存供調(diào)用
基于unbundle命令再設(shè)計(jì)一個(gè)上面提到的fake頁面用來加載相應(yīng)的業(yè)務(wù)模塊组底,這個(gè)頁面可以預(yù)先在后臺(tái)初始化js引擎热押,將公共部分的common.js文件讀取到內(nèi)存,然后設(shè)置一個(gè)監(jiān)聽事件斤寇,通過emmit方式,當(dāng)需要加載某個(gè)頁面的的module的時(shí)候講這個(gè)頁面的module的id傳遞過來拥褂,然后通過require方法調(diào)用這個(gè)模塊娘锁。
思路差不多是這樣了,來試試看實(shí)現(xiàn)起來有沒什么坑饺鹃。
首先
我拿例子 跑了一下莫秆,瞬間明白了流程是怎么回事,有幾個(gè)關(guān)鍵:
- DeviceEventEmitter
前端發(fā)起監(jiān)聽悔详,后端需要用的時(shí)候調(diào)用emit觸發(fā)镊屎,通過返回模塊id,然后return React.createElement(返回的模塊ID,this.props)即可定制加載 - 配置文件
這個(gè)配置文件之前不是很理解為什么好多等于0.js、等于1.js,現(xiàn)在明白其實(shí)就是不同bu的入口JS,因?yàn)槎际菃雾撀酚傻男问角洋Γ贿^這個(gè)配置其實(shí)是一套打包的一個(gè)流程缝驳,不在這里做,以后研究打包工具的時(shí)候加上。
然后
我試著把這樣融入到之前的demo里用狱。
先建兩個(gè)test頁面运怖,用于測(cè)試切換。
import React, { Component } from 'react';
import {
AppRegistry,
StyleSheet,
Text,
View,
} from 'react-native';
class testeight extends Component {
render() {
return (
<View style={styles.container}>
<Text style={styles.welcome}>
Welcome to Test 8888
</Text>
</View>
);
}
}
const styles = 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
*/
import React, { Component } from 'react';
import {
AppRegistry,
StyleSheet,
Text,
View,
Image,
NativeModules,
DeviceEventEmitter,
} from 'react-native';
export default class AwesomeProject extends Component {
constructor(props){
super(props);
this.state = {
content:null,showModule:false
};
DeviceEventEmitter.addListener("test", (result) => {
let mainComponent = 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 (
<View style={styles.container}>
<Text style={styles.welcome}>
Welcome to React Native!
</Text>
<Text style={styles.instructions}>
To get started, edit index.android.js
</Text>
<Text style={styles.instructions}>
Double tap R on your keyboard to reload,{'\n'}
Shake or press menu button for dev menu
</Text>
<Text style={styles.instructions} onPress={() => this.showToast()}>
點(diǎn)我調(diào)用原生
</Text>
<Text style={styles.instructions} onPress={() => this.updateBundle()}>
點(diǎn)我更新bundle
</Text>
<Text style={styles.instructions} onPress={() => this.goNine()}>
點(diǎn)我加載頁面9999
</Text>
<Text style={styles.instructions} onPress={() => this.goEight()}>
點(diǎn)我加載頁面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和兩個(gè)test頁面都用unbundle打包
react-native unbundle --platform android --dev false --entry-file index.android.js --bundle-output unbundle/index.android.bundle
react-native unbundle --platform android --dev false --entry-file bundletest1.js --bundle-output unbundle/index.android.bundle1
react-native unbundle --platform android --dev false --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里溺忧,這個(gè)講的估計(jì)不是很明白咏连,但去看看代碼就懂了。
然后建一個(gè)觸發(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祟滴。