基于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图生成

你可能感兴趣的:(基于splitChunk的React-Native的分包与加载)