React Native 拆包总结

在这里使用 Metro 的方式进行拆包,拆包的目的是可以按业务需要进行按需加载,同时可以热更新和热修复。

分包思路为:**基础包+业务包 1+业务包 2+…**。
Untitled

由上面可以看到,app 有原来的一个 bundle 分拆为多个,在 app 启动的时候,先加载公共部分 bundle,启动之后按需加载业务 bundle。每个 bundle 都对应一个容器,在 android 里面是 Activity,而在 iOS 里面是 ViewController。那么就可以理解为把之前单容器 bundle 的 app,拆分为多个容器加载。

规划代码拆分

在进行 bundle 拆分之前,我们要制定好方案:

首先我们的代码一般分为:主要代码+依赖+工具+资源

举个例子,如下图:
Untitled 1

红色框框里面的是工具代码,在打 bundle 的时候我们把他们排除,不打入 bundle 中。

白色框框里面的是依赖代码,我们选择性打入。

绿色框框里面的是主要代码,我们需要把他们打入到 bundle 中。

资源比较特殊,下面在进行讨论

在上面我们把 bundle 分为公共 bundle业务 bundle,在分包的时候也是把代码分成上面三个部分,然后装配到不同的 bundle 中的。如下图所示:
Untitled 2
从上图可以看出,公共 bundle 与业务 bundle 唯一的区别就是多了依赖代码。这么分包的好处是:

  1. 公共 bundle 可以做成没有 ui 页面的组件,然后异步加载。在进入业务页面的时候会非常快。
  2. 抽离业务模块的依赖部分,打出来的 bundle 会非常小,单独更新 bundle 时候,冗余量非常小。

但是也有一些缺点,你是需要知道的:

  1. 业务 bundle 也需要node_module依赖,在所有 bundle 中,如果 package.json 依赖版本不统一,有可能会引发白屏问题。

分包方案这部分要具体问题具体分析,解决痛点才是重要的。

演示

为了演示 demo,我们准备两个工程,一个主 bundle,一个业务 bundle。先来对比看一下这两个项目的目录结构。
Untitled 3
为了方便,我把原生工程(iOS 和 Android)放到公共 bundle 下面,在实际情况中,一般都会新增仓库,由不同的项目组分管。

在这里公共 bundle 仓库中,没有自有代码实现(没有 src 目录),因为 demo 的逻辑没有那么复杂,大家可以具体问题具体分析,放一些一些公共初始化的东西,如推送处理,后台服务等等。

注意:react native 在打包的过程中,如果一个依赖没有被使用到,即使你再 package.json 中声明了它,它也是不会被打包到 bundle 中的,所以为了把公共依赖都打到公共 bundle 中,我们需要在入口文件index.js中声明用到的依赖。

1
2
3
4
5
6
7
8
9
import "react";
import "react-native";
import "react-native-gesture-handler";
import "react-navigation";
import "react-navigation-redux-helpers";
import "react-redux";
import "redux";
import "redux-thunk";
import "react-native-screens";

小提示:如果在运行过成功发生白屏现象,检查依赖代码(node_modules)是否被打入到公共 bundle 中,如果没有,就要主动声明它们。

打包

现在假设你已经指定了自己的分包方案,那么看一下 React Native 是如何生产 bundle 的,又是如何分包的。

React Native 打包命令为:

1
2
3
4
5
6
7
8
9
node ./node_modules/react-native/local-cli/cli.js bundle
--platform android
--dev false
--entry-file index.js
--bundle-output ./bundles/base.android.bundle
--assets-dest ./bundles/res/android
--config ./package.config.js
--sourcemap-output ./bundles/base.android.bundle.map
--reset-cache

--platform android 指定运行平台,ios 平台则指定为 ios。

--dev false 指定为 release 版本。

--entry-file 指定入口文件,程序的主入口。

--bundle-output --assets-dest --sourcemap-output 分别为 bundle 输出路径,资源输出路径,debug 用到的映射文件输出路径,以上三个路径必须存在。

--config 在这里传入分包方案用到的 js 文件。

资源文件推荐直接输出到目的目录中,如果是 android 平台,输出到src/main/res/

分包

由上面可以知道,分包的重点就是 package.config.js 这个文件了。在这里可以把源码 clone 下来,打开 base 工程下的这个文件:

1
2
3
4
5
6
7
module.exports = {
serializer: {
createModuleIdFactory: createModuleIdFactory,
processModuleFilter: postProcessModulesFilter,
/* serializer options */
},
};

上面代码中,有两个很重要的方法createModuleIdFactorypostProcessModulesFiltercreateModuleIdFactory 是给每一个需要打包的文件去一个名字,避免多个 bundle 文件中代码冲突。而postProcessModulesFilter 就是用于标记哪些文件需要打入到 bundle 中的了。这个方法返回 false 表示不打入,true 表示打入。

1
2
3
4
5
6
7
// Base工程中 打包关键代码
if (module["path"].indexOf(pathSep + "node_modules" + pathSep) > 0) {
return true;
}
if (module["path"].indexOf("index") > 0) {
return true;
}

在公共依赖 base 工程,我们只打入路径中包含node_modules入口文件。而在业务工程只打入主要代码入口文件。如下所示:

1
2
3
4
5
6
7
8
9
10
11
// 业务工程中 打包关键代码
// 提前过滤依赖
if (module["path"].indexOf(pathSep + "node_modules" + pathSep) > 0) {
return false;
}
if (module["path"].indexOf(pathSep + "src" + pathSep) > 0) {
return true;
}
if (module["path"].indexOf("index") > 0) {
return true;
}

至此,两个 bundle 和资源文件就生成了。

后续

这时候,分包的主要工作已经完成,把打包的产物放到远程工程里面已经可以正常运行。但是工作远远没有结束。首先这两个 bundle 没有任何版本信息,也无法判断时候被伪装。

我们的 bundle 应该做到热替换,如果只在服务器上拉取最新的 bundle,那么就无法保证分区域加载,或者灰度加载。所以我们还需要一个配置文件,用于记录这个 bundle 的日期、版本、灰度区域以及他的 md5 信息。

这个配置文件为 bundle 打完包之后就要生成,同时可以被中台(原生工程读取)。为了方便,我们使用 json 作为文件结构格式。

1
2
3
4
5
6
{
"create_date":1595317939331,
"version":"1.0.1",
"update_desc":"新加了一个弹框",
"md5":"0ca175b9c0f726a831d895e269332461"
}

首先 create_date 就代表这个 config 配置生成时间, version 是当前 bundle 版本号,md5 为打包 bundle 的唯一标识。这个配置文件可以使用脚本生成。

在 package.json 里面新增一个 postxxx ,其中 xxx 为你的打包命令。我使用的是 make,那么就新增 postmake,这样,在执行完成打包命令之后,他会自动执行。如下图

1
2
3
4
5
"scripts": {
"make": "node ./node_modules/react-native/local-cli/cli.js bundle --platform android --dev false --entry-file index.js --bundle-output ./bundles/base.android.bundle --assets-dest ./bundles/res/android --config ./package.config.js --sourcemap-output ./bundles/base.android.bundle.map --reset-cache",
"makeiOS": "node ./node_modules/react-native/local-cli/cli.js bundle --platform ios --dev false --entry-file index.js --bundle-output ./bundles/base.ios.bundle --assets-dest ./bundles/res/ios --config ./package.config.js --sourcemap-output ./bundles/base.ios.bundle.map --reset-cache",
"postmake":"node aftermake.js"
},

那么这里的aftermake.js ,主要工作就是生成配置文件了,并且把它打包成最终文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
var fs = require("fs");
var crypto = require("crypto");
var compressing = require("compressing");

function generateMd5() {
return new Promise((resolve, rejects) => {
var stream = fs.createReadStream("bundles/base.android.bundle");
var fsHash = crypto.createHash("md5");

stream.on("data", function (d) {
fsHash.update(d);
});

stream.on("end", function () {
var md5 = fsHash.digest("hex");
resolve(md5);
});
});
}

async function readPackageInfo() {
return new Promise((resolve, rejects) => {
fs.readFile("package.json", function (err, data) {
if (err) {
rejects(err);
return;
}
var package = JSON.parse(data);
resolve([package.version, package.update_desc]);
});
});
}

async function writeToFile(info) {
fs.writeFile("bundles/config.json", JSON.stringify(info), function (err) {
if (err) {
return console.error(err);
}
});
}

async function main() {
let info = await readPackageInfo();
let md5 = await generateMd5();
const config = {
create_date: new Date().getTime(),
version: info[0],
update_desc: info[1],
md5: md5,
};
await writeToFile(config);
await compressing.zip.compressDir("bundles", "base.zip");
}

main();

生成 base.zip 。这 zip 就可以放到应用分发平台,等待上线。

源码地址:https://github.com/leconio/ReactNativeInPlatform


 Gitalk评论