基于splitChunk的React-Native的分包與加載

摘要

對React-Native包進行劃分是優化App啟動和內存占用的關鍵處理步驟,為此提出了一種基于splitChunk的分包方式。對原始React-Native項目的多入口entryPoint進行分包,而這些多入口entryPoint之間的共同依賴通過設置splitChunk配置來提取到新的bundle中,在加載一個entryPoint對應的bundle時,首先遞歸加載該bundle依賴的其他bundle,然后再加載entryPoint自身Bundle。使用splitChunk進行分包管理,可以便捷地管理多個Bundle之間的依賴引用關系,保證在加載Bundle時僅僅加載當前Bundle所依賴的模塊,避免加載多余模塊。實驗結果表明,本方法能夠對React-Native包進行合理劃分,并最小化App啟動時基礎包的體積,提高App啟動速度,并減少App啟動時的內存占用。

相關工作

React-Native在分包時主要工作集中在依賴管理,目前項目的分包方案只拆分出了一部分業務模塊,在打包啟動包之前,將這部分拆分出來的模塊的引用依賴添加到了啟動包中,但是啟動包并不依賴這些引用,所以App啟動時加載了本不需要加載的module;此外,項目還存在一個體積較大的老業務模塊,由于手工拆分引用復雜,所以暫時也放在了啟動包中,這也造成了啟動包過大的問題。

Webpack4自帶的SplitChunksPlugin插件實現了Bundle包之間的依賴管理,借助于這一工具,可以方便地管理多Bundle之間的依賴關系,在分包的時候可以直接將entryPoint抽取出來,而entryPoint的依賴則由splitChunk去分析;而且SplitChunksPlugin提供的多種配置參數,為Bundle的管理提供更多的靈活性。

采用splitChunk進行分包,分包體積總和由8063KB增加到8166KB,但啟動包體積占比由95%降低到了16%,啟動包加載時間降低了61%,啟動后在WebKit Malloc Zone上的resident size降低了約72%,在iPhone6 iOS 11.3真機測試中,內存降低了約85MB。

splitChunk的分包與加載

splitChunk

先了解一下splitChunk的相關概念[1]

  • chunkGroup,由chunk組成,一個chunkGroup可以包含多個chunk,在生成/優化chunk graph時會用到;
  • chunk,由module組成,一個chunk可以包含多個module,它是編譯打包后輸出的最終文件;
  • module,就是不同的資源文件,包含了你的代碼中提供的例如:js/css/圖片等文件,在編譯環節,webpack會根據不同module之間的依賴關系去組合生成chunk。
    splitchunk是webpack4中的SplitChunksPlugin插件,webpack4使用SplitChunksPlugin插件來分析,先來看一下通過SplitChunksPlugins可以實現的功能,對于如下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
}

當前設置splitChunk參數如下:

optimization: {
    runtimeChunk: {
      name: 'bundle'
    }
  }

如果以a.js為入口進行打包,最后的分包結果如下所示:


chunkGroup關系圖.png

上述4個腳本文件在編譯之后,生成如圖所示的結果:

  • 生成了兩個chunkGroup,entryPoint和chunkGroup2;
  • entryPoint這個chunkGroup只包含一個chunk,該chunk中包含a.js,b.js和d.js這3個module;
  • entryPoint依賴chunkGroup2,chunkGroup2只包含一個chunk,該chunk中包含c.js這個module。

最終結果就是a.js,b.js和c.js合并打包為bundle1,c.js單獨打包為bundle2,在進入entryPoint時,由于entryPoint依賴于chunkGroup2,所以需要先加載chunkGroup2的chunk,即bundle2,然后再加載entryPoint的chunk,即bundle1。

分包

在splitChunk編譯之后,可以得到chunkGroup之間的依賴關系,以及chunkGroup中的chunk的基本信息,其中"modules"字段為當前chunk所包含的所有module。由于chunk是打包的最終輸出,所以我們可以通過Metro對chunk包含的module信息進行打包。

// 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加載應該是以業務邏輯為單位的,所以加載時應該以entryPoint為單位,而加載entryPoint則是通過加載其內部的chunks來實現的。

"entrypoint": {
    "chunks": [
        0
    ],
}

上述打包結果entryPoint只有1個chunk,id為0,所以就加載該chunk對應的bundle;當entryPoint包含多個chunk時,按照順序從前往后加載chunk。

加載chunk

entryPoint之間的依賴關系體現在了chunk的"children"這一字段中,children里面是當前chunk所在的chunkGroup依賴的chunkGroup的chunks,源代碼看起來更清晰一些:

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時需要將children中包含的chunk先加載進來,所以加載chunk是一個遞歸加載的過程。如下所示,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",
        }
    ]
}

實驗

我們在打包之前,先打一個引用react和react-native的包,包名為platformBase.ios.bundle。

// platformBase.ios.bundle
import 'react';
import 'react-native';

啟動包打包

在原方案中,由于一個老業務模塊的引用關系管理比較復雜,直接將這個3.7MB左右的老業務模塊包含到了啟動包中。此外,一些新模塊的引用被直接提取出來放在了啟動包中,而這些依賴并不是啟動包必須引用的。

首先我們將老業務模塊的引用和新模塊依賴的引用從啟動包中刪除掉,然后把啟動入口JS文件作為entryPoint進行打包,因為這是啟動包,我們也不需要使用splitChunk去提取公共引用,直接將結果打在一個包中。此時打包結果只有1個chunkGroup,內部包含1個chunk,將該chunk的打包結果記為0.ios.bundle。所以App在啟動時需要加載platformBase.ios.bundle和0.ios.bundle兩個包。

Bundle 體積
platformBase.ios.bundle 645KB
0.ios.bundle 703KB

經過實驗測試,依次加載兩個Bundle比合并起來加載要耗費更多的時間,所以我們將platformBase.ios.bundle和0.ios.bundle合并起來作為啟動包,記為merge.ios.bundle,體積為約為1.3MB。

業務包打包

我們為老業務模塊創建一個模塊注冊入口頁,

import { AppRegistry } from 'react-native';
import BBB from '../xxx/pages';

AppRegistry.registerComponent('AAAA', () => BBB);

剩余的模塊入口頁保持不變,將這些入口頁分別作為entryPoint,進行打包,

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'
},

同時配置splitChunk參數如下,

splitChunks: {
    minSize: 0,
    cacheGroups: {
        commons: {
            name: 'commons',
            chunks: 'all',
            minChunks: 2,
            priority: -20
        }
    }
}

目的是將這些入口模塊中引用至少2次的模塊抽取的commons里,單獨作為一個chunk,單獨打一個Bundle。這時需要注意,在commons chunk中可能會包含啟動包merge.ios.bundle中已經引用的module,所以在啟動包打包時,需要記錄下啟動包中包含的module,后續commons chunk在打包時需要過濾掉這些module。業務包打包結果如下:

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為業務模塊的公用依賴包,1.ios.bundle為老業務包,其他包為新的業務包。

結果分析

啟動包體積

原打包方案打包結果如下,

Bundle 體積
a.ios.bundle 7.6MB
b.ios.bundle 41KB
c.ios.bundle 142KB
d.ios.bundle 98KB

所有分包加起來體積為8063KB,其中a.ios.bundle作為啟動包,體積有7.3MB;而新的分包方案總分包加起來體積為8166KB,其啟動包merge.ios.bundle體積僅有1.3MB,體積縮小了82%。

App啟動Bundle加載時間對比

在iOS 11.3系統下iPhone6真機上測試啟動包加載時間,兩種方案各進行5次測試,原分包方案平均加載時間為4.17s,新分包方案平均加載時間為1.62s,將加載時間降低了61%。


啟動Bundle加載時間比較.png

App啟動內存占用對比

在iOS 11.3系統下iPhone6真機上,原方案在App啟動后首頁露出physical footprint為155MB,而新分包方案physical footprint為69MB,所以由縮小啟動包直接帶來了約85MB的內存優化。

再通過iPhone XS iOS13.5模擬器查看App啟動后首頁露出時的Memory Graph對比,

Physical footprint對比
splitChunk分包 原分包方案
Physical footprint 88.3M 141.8M
Physical footprint (peak) 129.7M 202.7M
MALLOC ZONE對比
新分包方案
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的角度來看,新分包方案減少的內存主要集中在WebKit Malloc Zone,RESIDENT SIZE減少了約72%。

總結

splitChunk可以構建React-Native分包之間的依賴關系,并提供了更多的分包配置選項,靈活控制地Bundle的拆分,最終實現降低啟動Bundle的體積,加快App啟動的目的,并且減少App啟動時非必要的內存分配,提高App的存活幾率。

參考文獻

[1]: webpack系列之六chunk圖生成

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。