摘要
對(duì)React-Native包進(jìn)行劃分是優(yōu)化App啟動(dòng)和內(nèi)存占用的關(guān)鍵處理步驟,為此提出了一種基于splitChunk的分包方式。對(duì)原始React-Native項(xiàng)目的多入口entryPoint進(jìn)行分包,而這些多入口entryPoint之間的共同依賴通過(guò)設(shè)置splitChunk配置來(lái)提取到新的bundle中,在加載一個(gè)entryPoint對(duì)應(yīng)的bundle時(shí),首先遞歸加載該bundle依賴的其他bundle,然后再加載entryPoint自身Bundle。使用splitChunk進(jìn)行分包管理,可以便捷地管理多個(gè)Bundle之間的依賴引用關(guān)系,保證在加載Bundle時(shí)僅僅加載當(dāng)前Bundle所依賴的模塊,避免加載多余模塊。實(shí)驗(yàn)結(jié)果表明,本方法能夠?qū)eact-Native包進(jìn)行合理劃分,并最小化App啟動(dòng)時(shí)基礎(chǔ)包的體積,提高App啟動(dòng)速度,并減少App啟動(dòng)時(shí)的內(nèi)存占用。
相關(guān)工作
React-Native在分包時(shí)主要工作集中在依賴管理,目前項(xiàng)目的分包方案只拆分出了一部分業(yè)務(wù)模塊,在打包啟動(dòng)包之前,將這部分拆分出來(lái)的模塊的引用依賴添加到了啟動(dòng)包中,但是啟動(dòng)包并不依賴這些引用,所以App啟動(dòng)時(shí)加載了本不需要加載的module;此外,項(xiàng)目還存在一個(gè)體積較大的老業(yè)務(wù)模塊,由于手工拆分引用復(fù)雜,所以暫時(shí)也放在了啟動(dòng)包中,這也造成了啟動(dòng)包過(guò)大的問(wèn)題。
Webpack4自帶的SplitChunksPlugin插件實(shí)現(xiàn)了Bundle包之間的依賴管理,借助于這一工具,可以方便地管理多Bundle之間的依賴關(guān)系,在分包的時(shí)候可以直接將entryPoint抽取出來(lái),而entryPoint的依賴則由splitChunk去分析;而且SplitChunksPlugin提供的多種配置參數(shù),為Bundle的管理提供更多的靈活性。
采用splitChunk進(jìn)行分包,分包體積總和由8063KB增加到8166KB,但啟動(dòng)包體積占比由95%降低到了16%,啟動(dòng)包加載時(shí)間降低了61%,啟動(dòng)后在WebKit Malloc Zone上的resident size降低了約72%,在iPhone6 iOS 11.3真機(jī)測(cè)試中,內(nèi)存降低了約85MB。
splitChunk的分包與加載
splitChunk
先了解一下splitChunk的相關(guān)概念[1]:
- chunkGroup,由chunk組成,一個(gè)chunkGroup可以包含多個(gè)chunk,在生成/優(yōu)化chunk graph時(shí)會(huì)用到;
- chunk,由module組成,一個(gè)chunk可以包含多個(gè)module,它是編譯打包后輸出的最終文件;
- module,就是不同的資源文件,包含了你的代碼中提供的例如:js/css/圖片等文件,在編譯環(huán)節(jié),webpack會(huì)根據(jù)不同module之間的依賴關(guān)系去組合生成chunk。
splitchunk是webpack4中的SplitChunksPlugin插件,webpack4使用SplitChunksPlugin插件來(lái)分析,先來(lái)看一下通過(guò)SplitChunksPlugins可以實(shí)現(xiàn)的功能,對(duì)于如下a.js,b.js,c.js,d.js腳本:
// a.js
import add from './b.js
add(1, 2)
import('./c').then(del => del(1, 2))
// b.js
import mod from './d.js'
export default function add(n1, n2) {
return n1 + n2
}
mod(100, 11)
// c.js
import mod from './d.js'
mod(100, 11)
import('./b.js').then(add => add(1, 2))
export default function del(n1, n2) {
return n1 - n2
}
// d.js
export default function mod(n1, n2) {
return n1 % n2
}
當(dāng)前設(shè)置splitChunk參數(shù)如下:
optimization: {
runtimeChunk: {
name: 'bundle'
}
}
如果以a.js為入口進(jìn)行打包,最后的分包結(jié)果如下所示:

上述4個(gè)腳本文件在編譯之后,生成如圖所示的結(jié)果:
- 生成了兩個(gè)chunkGroup,entryPoint和chunkGroup2;
- entryPoint這個(gè)chunkGroup只包含一個(gè)chunk,該chunk中包含a.js,b.js和d.js這3個(gè)module;
- entryPoint依賴chunkGroup2,chunkGroup2只包含一個(gè)chunk,該chunk中包含c.js這個(gè)module。
最終結(jié)果就是a.js,b.js和c.js合并打包為bundle1,c.js單獨(dú)打包為bundle2,在進(jìn)入entryPoint時(shí),由于entryPoint依賴于chunkGroup2,所以需要先加載chunkGroup2的chunk,即bundle2,然后再加載entryPoint的chunk,即bundle1。
分包
在splitChunk編譯之后,可以得到chunkGroup之間的依賴關(guān)系,以及chunkGroup中的chunk的基本信息,其中"modules"字段為當(dāng)前chunk所包含的所有module。由于chunk是打包的最終輸出,所以我們可以通過(guò)Metro對(duì)chunk包含的module信息進(jìn)行打包。
// chunk中的module信息
{
"id": 0,
"modules": [
{
"id": 1,
"name": "./abc_test/b.js",
},
{
"id": 2,
"name": "./abc_test/d.js",
},
{
"id": 3,
"name": "./abc_test/a.js",
}
]
}
加載
加載entryPoint
React-Native的Bundle加載應(yīng)該是以業(yè)務(wù)邏輯為單位的,所以加載時(shí)應(yīng)該以entryPoint為單位,而加載entryPoint則是通過(guò)加載其內(nèi)部的chunks來(lái)實(shí)現(xiàn)的。
"entrypoint": {
"chunks": [
0
],
}
上述打包結(jié)果entryPoint只有1個(gè)chunk,id為0,所以就加載該chunk對(duì)應(yīng)的bundle;當(dāng)entryPoint包含多個(gè)chunk時(shí),按照順序從前往后加載chunk。
加載chunk
entryPoint之間的依賴關(guān)系體現(xiàn)在了chunk的"children"這一字段中,children里面是當(dāng)前chunk所在的chunkGroup依賴的chunkGroup的chunks,源代碼看起來(lái)更清晰一些:
const children = new Set();
const childIdByOrder = chunk.getChildIdsByOrders();
for (const chunkGroup of chunk.groupsIterable) {
for (const childGroup of chunkGroup.childrenIterable) {
for (const chunk of childGroup.chunks) {
children.add(chunk.id);
}
}
}
所以在加載chunk時(shí)需要將children中包含的chunk先加載進(jìn)來(lái),所以加載chunk是一個(gè)遞歸加載的過(guò)程。如下所示,chunk 0依賴于chunk 1,所以需要先加載chunk 1,再加載chunk 0。
{
"id": 0,
"children": [
1
],
"modules": [
{
"id": 1,
"name": "./abc_test/b.js",
},
{
"id": 2,
"name": "./abc_test/d.js",
},
{
"id": 3,
"name": "./abc_test/a.js",
}
]
}
實(shí)驗(yàn)
我們?cè)诖虬埃却蛞粋€(gè)引用react和react-native的包,包名為platformBase.ios.bundle。
// platformBase.ios.bundle
import 'react';
import 'react-native';
啟動(dòng)包打包
在原方案中,由于一個(gè)老業(yè)務(wù)模塊的引用關(guān)系管理比較復(fù)雜,直接將這個(gè)3.7MB左右的老業(yè)務(wù)模塊包含到了啟動(dòng)包中。此外,一些新模塊的引用被直接提取出來(lái)放在了啟動(dòng)包中,而這些依賴并不是啟動(dòng)包必須引用的。
首先我們將老業(yè)務(wù)模塊的引用和新模塊依賴的引用從啟動(dòng)包中刪除掉,然后把啟動(dòng)入口JS文件作為entryPoint進(jìn)行打包,因?yàn)檫@是啟動(dòng)包,我們也不需要使用splitChunk去提取公共引用,直接將結(jié)果打在一個(gè)包中。此時(shí)打包結(jié)果只有1個(gè)chunkGroup,內(nèi)部包含1個(gè)chunk,將該chunk的打包結(jié)果記為0.ios.bundle。所以App在啟動(dòng)時(shí)需要加載platformBase.ios.bundle和0.ios.bundle兩個(gè)包。
| Bundle | 體積 |
|---|---|
| platformBase.ios.bundle | 645KB |
| 0.ios.bundle | 703KB |
經(jīng)過(guò)實(shí)驗(yàn)測(cè)試,依次加載兩個(gè)Bundle比合并起來(lái)加載要耗費(fèi)更多的時(shí)間,所以我們將platformBase.ios.bundle和0.ios.bundle合并起來(lái)作為啟動(dòng)包,記為merge.ios.bundle,體積為約為1.3MB。
業(yè)務(wù)包打包
我們?yōu)槔蠘I(yè)務(wù)模塊創(chuàng)建一個(gè)模塊注冊(cè)入口頁(yè),
import { AppRegistry } from 'react-native';
import BBB from '../xxx/pages';
AppRegistry.registerComponent('AAAA', () => BBB);
剩余的模塊入口頁(yè)保持不變,將這些入口頁(yè)分別作為entryPoint,進(jìn)行打包,
config.entry = {
xxxx_entry0: './xxxxx/entry0.js',
xxxx_entry1: './xxxxx/entry1.js',
xxxx_entry2: './xxxxx/entry2.ts',
xxxx_entry3: './xxxxx/entry3.ts',
xxxx_entry4: './xxxxx/entry4.ts'
},
同時(shí)配置splitChunk參數(shù)如下,
splitChunks: {
minSize: 0,
cacheGroups: {
commons: {
name: 'commons',
chunks: 'all',
minChunks: 2,
priority: -20
}
}
}
目的是將這些入口模塊中引用至少2次的模塊抽取的commons里,單獨(dú)作為一個(gè)chunk,單獨(dú)打一個(gè)Bundle。這時(shí)需要注意,在commons chunk中可能會(huì)包含啟動(dòng)包merge.ios.bundle中已經(jīng)引用的module,所以在啟動(dòng)包打包時(shí),需要記錄下啟動(dòng)包中包含的module,后續(xù)commons chunk在打包時(shí)需要過(guò)濾掉這些module。業(yè)務(wù)包打包結(jié)果如下:
| Bundle | 體積 |
|---|---|
| 0.ios.bundle | 2.3MB |
| 1.ios.bundle | 3.7MB |
| 2.ios.bundle | 364KB |
| 3.ios.bundle | 192KB |
| 4.ios.bundle | 135KB |
其中0.ios.bundle為業(yè)務(wù)模塊的公用依賴包,1.ios.bundle為老業(yè)務(wù)包,其他包為新的業(yè)務(wù)包。
結(jié)果分析
啟動(dòng)包體積
原打包方案打包結(jié)果如下,
| Bundle | 體積 |
|---|---|
| a.ios.bundle | 7.6MB |
| b.ios.bundle | 41KB |
| c.ios.bundle | 142KB |
| d.ios.bundle | 98KB |
所有分包加起來(lái)體積為8063KB,其中a.ios.bundle作為啟動(dòng)包,體積有7.3MB;而新的分包方案總分包加起來(lái)體積為8166KB,其啟動(dòng)包merge.ios.bundle體積僅有1.3MB,體積縮小了82%。
App啟動(dòng)Bundle加載時(shí)間對(duì)比
在iOS 11.3系統(tǒng)下iPhone6真機(jī)上測(cè)試啟動(dòng)包加載時(shí)間,兩種方案各進(jìn)行5次測(cè)試,原分包方案平均加載時(shí)間為4.17s,新分包方案平均加載時(shí)間為1.62s,將加載時(shí)間降低了61%。

App啟動(dòng)內(nèi)存占用對(duì)比
在iOS 11.3系統(tǒng)下iPhone6真機(jī)上,原方案在App啟動(dòng)后首頁(yè)露出physical footprint為155MB,而新分包方案physical footprint為69MB,所以由縮小啟動(dòng)包直接帶來(lái)了約85MB的內(nèi)存優(yōu)化。
再通過(guò)iPhone XS iOS13.5模擬器查看App啟動(dòng)后首頁(yè)露出時(shí)的Memory Graph對(duì)比,
Physical footprint對(duì)比
| splitChunk分包 | 原分包方案 | |
|---|---|---|
| Physical footprint | 88.3M | 141.8M |
| Physical footprint (peak) | 129.7M | 202.7M |
MALLOC ZONE對(duì)比
新分包方案
| MALLOC ZONE | VIRTUAL SIZE | RESIDENT SIZE | DIRTY SIZE |
|---|---|---|---|
| DefaultMallocZone_0x1058fd000 | 128.0M | 9060K | 8948K |
| MallocHelperZone_0x1058eb000 | 79.6M | 17.0M | 17.0M |
| WebKit Malloc_0x1081d5000 | 26.0M | 21.4M | 20.2M |
| QuartzCore_0x107620000 | 16.0M | 340K | 340K |
| NWMallocZone_0x1081e1000 | 3072K | 40K | 40K |
| TOTAL | 252.6M | 47.5M | 46.3M |
原方案
| MALLOC ZONE | VIRTUAL SIZE | RESIDENT SIZE | DIRTY SIZE |
|---|---|---|---|
| DefaultMallocZone_0x109a9b000 | 128.0M | 9868K | 9668K |
| WebKit Malloc_0x118105000 | 80.0M | 77.4M | 69.6M |
| MallocHelperZone_0x1088a5000 | 79.6M | 16.8M | 16.7M |
| QuartzCore_0x10b7bd000 | 16.0M | 348K | 348K |
| NWMallocZone_0x1177d9000 | 3072K | 36K | 36K |
| TOTAL | 306.0M | 104.2M | 96.2M |
從MACLLOC ZONE的角度來(lái)看,新分包方案減少的內(nèi)存主要集中在WebKit Malloc Zone,RESIDENT SIZE減少了約72%。
總結(jié)
splitChunk可以構(gòu)建React-Native分包之間的依賴關(guān)系,并提供了更多的分包配置選項(xiàng),靈活控制地Bundle的拆分,最終實(shí)現(xiàn)降低啟動(dòng)Bundle的體積,加快App啟動(dòng)的目的,并且減少App啟動(dòng)時(shí)非必要的內(nèi)存分配,提高App的存活幾率。