RN 基于Metro 拆包实战

4,776 阅读14分钟

原文参考:time.geekbang.org/column/arti…

一、背景

触过RN的同学都知道,热更新作为RN最大的特点之一,可以让开发者随时上线新的迭代以及修复线上Bug。在上一篇文章我们聊了一下热更新平台搭建,今天来我们聊聊热更新中的拆包环节。

热更新和拆包都是大家聊得比较多的话题,通常一个聊得比较多的技术话题都会有一套成熟的技术方案,比如热更新平台就有 CodePush 这样的成熟方案,但拆包却没有一套大家都公认成熟的方案。不过,市面上支持拆包的方案有react-native-multibundler、携程的moles-packer 还有58同城的metro-code-split,由于前两种已经停止更新,所以不做特别的介绍。

众所周知,Facebook 开源的 Metro 打包工具,本身并没有拆包功能,它的主要功能是将 JavaScript 代码打包成一个 Bundle 文件,而且 Metro 也不支持第三方插件,所以社区也没有第三方拆包插件。

不过,我们在阅读 Metro 源码的时候,发现了一个可配置的函数 customSerializer,从而找到了不入侵 Metro 源码,通过配置的方式给 Metro 写第三方插件的方法。有了 Metro 的 customSerializer 方法后,现在我们也可以给 Metro 来写插件了,通过插件来提供单独拆包能力。

二、metro-code-split基本使用

metro-code-split是58同城技术团队开发的支持RN拆包的插件,目前支持最新的0.66.2版本,相关的文章介绍可以参考:58RN 页面秒开方案与实践

接下来,我们看一下如何在现有的项目中接入metro-code-split。首先,我们在项目中安装metro-code-split插件。

npm i metro-code-split -D
//或者
yarn add metro-code-split -D

然后,在package.json配置文件中添加如下脚本:

  "scripts": {
    "start": "mcs-scripts start -p 8081",
    "build:dllJson": "mcs-scripts build -t dllJson -od public/dll",
    "build:dll": "mcs-scripts build -t dll -od public/dll",
    "build": "mcs-scripts build -t busine -e index.js"
  }

脚本的具体含义如下:

  • start:启动本地调试服务;
  • build:dllJson:构建公共包的模块文件;
  • build:dll:构建公共包;
  • build:构建业务包和按需加载包。

如果是开发环境,上述的配置脚本需要NODE_ENV=xxx参数,修改后如下所示。

  "scripts": {
    "start": "NODE_ENV=production react-native start --port 8081",
    "build:dllJson": "NODE_ENV=production react-native bundle --platform ios --entry-file node_modules/.cache/metro-code-split/dll-entry.js --bundle-output public/dll/_dll.ios.json --dev false & NODE_ENV=production react-native bundle --platform android --entry-file node_modules/.cache/metro-code-split/dll-entry.js --bundle-output public/dll/_dll.android.json --dev false",
    "build:dll": "NODE_ENV=production react-native bundle --platform ios --entry-file node_modules/.cache/metro-code-split/dll-entry.js --bundle-output public/dll/_dll.ios.bundle --dev false & NODE_ENV=production react-native bundle --platform android --entry-file node_modules/.cache/metro-code-split/dll-entry.js --bundle-output public/dll/_dll.android.bundle --dev false",
    "build": "NODE_ENV=production react-native bundle --platform ios --entry-file index.js --bundle-output dist/buz.ios.bundle --dev false & NODE_ENV=production react-native bundle --platform android --entry-file index.js --bundle-output dist/buz.android.bundle --dev false"
  }

接下来,修改metro.config.js文件的配置如下:

  
const Mcs = require('metro-code-split')

// 拆包的配置
const mcs = new Mcs({
  output: {
    // 配置你的 CDN 的 BaseURL 
    publicPath: 'https://static001.geekbang.org/resource/rn',
  },
  dll: {
    entry: ['react-native', 'react'], // 要内置的 npm 库
    referenceDir: './public/dll', // 拆包的路径
  },
  dynamicImports: {}, // dynamic import 是默认开启的
})

// 业务的 metro 配置
const busineConfig = {
  transformer: {
    getTransformOptions: async () => ({
      transform: {
        experimentalImportSupport: false,
        inlineRequires: true,
      },
    }),
  },
}

// Dynamic Import 在本地和线上环境的实现是不同的
module.exports = process.env.NODE_ENV === 'production' ? mcs.mergeTo(busineConfig) : busineConfig

这里有两个拆包的参数需要注意:一个是 publicPath,它是用于配置线上环境中,按需加载包的根路径的。另一个要注意的参数是 dll,它用于配置需要内置 npm 库。

通常在一个混合开发的 React Native 应用中,“react”和 “react-native” 这两个包基本上不会变动,所以你可以把这两个 npm 库拆到一个公共包中,这个公共包只能跟随 App 发版更新。而其他的业务代码或者第三方库,比如 “reanimated”,这些代码变动相对频繁,就可以都跟着进行业务包进行集成,方便动态更新。

配置完成 metro-code-split 之后,如何使用 metro-code-split 进行拆包呢?metro-code-split 支持三类包的拆分,包括公共包、业务包和按需加载包。

公共包

当你在 dll 配置项中填写了 “react”和 “react-native”之后,每次打包时, “react”和 “react-native”都会被当作公共包来处理。

  dll: {
    entry: ['react-native', 'react'], // 要内置的 npm 库
    referenceDir: './public/dll', // 拆包的路径
  },

接下来,直接运行yarn build:dll命令就可以把公共包拆出来。运行完成后,你再查看public/dll目录,你会发现该目录下面多了两个文件,分别是 _dll.android.bundle 和 _dll.ios.bundle,这两个文件就是集成了“react”和“react-native”所有代码的公共包。

如果想要查看公共包中包含的模块,可以使用下面的命令:

yarn build:dllJson

运行上述命令后,你可以找到 _dll.android.json 和 _dll.ios.json 两个文件,这两个包含了 “react”和“react-native”依赖的所有模块,如下。


[
  "__prelude__", // 框架预制模块
  "require-node_modules/react-native/Libraries/Core/InitializeCore.js", // react-native 初始化模块
  "node_modules/@babel/runtime/helpers/createClass.js", // babel 的类模块
  "node_modules/react-native/index.js", // react-native 入口模块
  "node_modules/metro-runtime/src/polyfills/require.js", // require 运行时模块 
  "node_modules/react/index.js" // react 模块
]

_dll.json 记录了所有的公共模块,_dll.bundle 包含所有公共模块代码,比如管理 React Native 全局变量的框架预制模块 prelude、管理初始化的 InitializeCore 模块、管理 babel、require 的模块,以及 react 和 react-native 框架的入口模块。

业务包和按需加载包

当你拿到内置包后,除了“react”和“react-native”的内置代码以外,其他所有代码都归属于业务包,但有一类文件例外,就是按需加载模块。不过因为业务包和按需加载包的耦合性很强,按需加载包没办法脱离业务包进行独立打包,所以接下来我会把业务包和按需加载包一起介绍。

通常,你引入普通业务模块,使用的是 import * from "xxx" ,那么该模块的代码都会直接打到业务包中。但在引入按需加载业务模块时,使用的是 import("xxx") 引入的,那么该模块代码会直接打到按需加载包中。比如,有下面一段代码:


import React, {lazy, Suspense} from 'react';
import {
  Text,
} from 'react-native';
import {NavigationContainer} from '@react-navigation/native';
import {
  createNativeStackNavigator,
} from '@react-navigation/native-stack';
import {Views, RootStackParamList} from './types';
import Main from './component/Main';

const Stack = createNativeStackNavigator<RootStackParamList>();

const Foo = lazy(() => import('./component/Foo'));
const Bar = lazy(() => import('./component/Bar'));

export default function App() {
  return (
    <Suspense fallback={<Text>Loading...</Text>}>
      <NavigationContainer>
        <Stack.Navigator initialRouteName={Views.Main}>
          <Stack.Screen name={Views.Main} component={Main} />
          <Stack.Screen name={Views.Foo} component={Foo} />
          <Stack.Screen name={Views.Bar} component={Bar} />
        </Stack.Navigator>
      </NavigationContainer>
    </Suspense>
  );
}

可以看到,Main 组件是通过 import * from "xxx" 引入的,它属于普通的业务模块;而 Foo 组件和 Bar 组件是通过 import("xxx") 引入的,它们属于按需加载的业务模块。当我们完成代码的编写后,使用如下命令就可以生成业务包和按需加载包。

yarn build

构建完成后,业务包和按需加载包会放在 dist 目录下,其中 buz.android.bundlebuz.ios.bundle 就是业务包,chunks 目录下以 MD5 值开头的包就是按需加载包。

dist
├── buz.android.bundle
├── buz.ios.bundle
└── chunks
    ├── 22b3a0e5af84f7184abd.bundle
    └── 479c3b2dc4e8fef12a34.bundle

可以看到,通过 yarn build:dllyarn build,我们就完成了公共包、业务包、按需加载包的构建。

附件:Mcs 默认配置参数

三、拆包原理

3.1 Metro 打包流程

metro是一种RN的打包工具,现在我们也可以使用它来进行拆包,metro 打包流程分为以下几个步骤:

  1. Resolution:Metro 需要从入口点构建所需的所有模块的图,要从另一个文件中找到所需的文件,需要使用 Metro 解析器。在实际开发中,这个阶段与Transformation 阶段是并行的。
  2. Transformation:所有模块都要经过 Transformation 阶段,Transformation 负责将模块转换成目标平台可以理解的语法格式(如 React Naitve)。模块的转换是基于拥有的核心数量来决定的。
  3. Serialization:所有模块一经转换就会被序列化,Serialization 会组合这些模块来生成一个或多个包,包就是将模块组合成一个 JavaScript 文件的包,序列化的时候提供了一些列的方法让开发者自定义一些内容,比如模块 id,模块过滤等。

打开Metro库的createModuleIdFactory代码,路径为node_modules/metro/src/lib/createModuleIdFactory.js ,可以看到如下一段代码。

function createModuleIdFactory() {
  const fileToIdMap = new Map();
  let nextId = 0;
  return path => {
    let id = fileToIdMap.get(path);

    if (typeof id !== "number") {
      id = nextId++;
      fileToIdMap.set(path, id);
    }

    return id;
  };
}

module.exports = createModuleIdFactory;

上述代码的逻辑是:如果查到 map 里没有记录这个模块则 id 自增,然后将该模块记录到 map 中,所以从这里可以看出,官方代码生成 moduleId 的规则就是自增,所以这里要替换成我们自己的配置逻辑,我们要做拆包就需要保证这个 id 不能重复,但是这个 id 只是在打包时生成,如果我们单独打业务包,基础包,这个 id 的连续性就会丢失,所以对于 id 的处理,我们还是可以参考上述开源项目,每个包有十万位间隔空间的划分,基础包从 0 开始自增,业务 A 从 1000000 开始自增,又或者通过每个模块自己的路径或者 uuid 等去分配,来避免碰撞,但是字符串会增大包的体积,这里不推荐这种做法。

3.2 基于模块的拆包方案

下面我们来看一下metro-code-split 拆包工具,相对于基于文本的拆包方式,基于模块来拆包加载速度要更快一些。为什么基于模块的拆包要比基于文本的拆包加载速度更快一些呢?这是因为,基于模块的拆包方式能够独立运行。

那为什么基于模块的拆包方式,能够独立运行,而基于文本的拆包方式不能独立运行呢?

我们先来看基于文本的拆包方式。假设我们采用的是多 Bundle 的基于文本的拆包方式。多个 Bundle 之间的公共代码部分是 “react”和“react-native”库,这里我用 console.log(“react”)、console.log(“react-native”) 来代替。多个 Bundle 之间不同的代码部分是业务代码,这里用 console.log(“Foo”)来代替某个具体业务代码。

基于文本的拆包,我们采用的是 Google 开源的 diff-match-patch 算法,它也提供了在线计算网站,它计算热更新包的示意图如下:

image.png

可以看到,在上面热更新示意图中,我们会把 Old Version 的字符串文件进行内置,这部分代码除了升级 React Native 版本之外不会轻易改动。而 New Version 的字符串是本次热更新的目标代码,也就是完整的 Bundle 文件,但开发者并不需要下载完整的 Bundle 文件,因为 Old Version 已经内置到 App 中了,我们只需要下发 Patch 热更新包即可。客户端接收到 Patch 热更新包后,会和 Old Version 代表的内置包进行合并,最终加载的是经过合并的完整 Bundle包。

可以看到,基于文本的拆包与合包原理,Patch 热更新包是一段记录修改位置、修改内容的文本,而不是可独立执行的代码,直接导致的结果是,只能等到下载完成后生成完整的 Bundle 文件才能整体执行。这就是为什么基于文本拆包方式不可独立执行的原因。

但基于模块的拆包方式,内置包和热更新包就可以分别独立执行。同样,还是以多 Bundle 模式的 Foo 业务热更新为例,下面似乎基于模块拆包示意图。

image.png

可以看到,基于模块拆包方案拆出来的热更新包是可以独立运行的。因此,使用模块拆包方案后,可以在客户端先运行内置包,同时并行下载热更新包,等热更新包下载完成再接着运行热更新包,当然也可以在应用启动后就去下载,从而降低热更新包的加载时长。

3.2 热更新与拆包

经过前文的操作后,我们已经生成好的公共包、业务包、按需加载包,接下来就是如何实现热更新并运行的问题。下面是一张拆包方案的热更新示意图。

image.png

因为我们采用的是模块拆包方案,虽然理论上每个包都是可以独立运行的,但实际上模块和模块之间是有依赖关系的,整体上讲,按需加载包会依赖业务包中的模块,业务包会依赖公共包中的模块。因此,需要先执行公共包、再执行业务包,最后执行按需加载包。

当然每个独立的按需加载包之间也会有依赖关系,不过这些加载的依赖关系,metro-code-split 都已经帮你考虑到了,你直接用就行了。对于首页是 Native 页面,而其他页面是 React Native 页面的多 Bundle 混合应用而言,整体加载流程如下:

首先,在启动 App 之后,找一个空闲时间,把 React Native “环境预创建” 好,然后把 “拆出来的公共包” 进行预加载。

然后,在用户点击进入 React Native 页面时,在相关跳转协议中传入 React Native 页面的唯一标识符或者 CDN 地址,下载业务包并进行页面加载:

https://static001.geekbang.org/resource/rn/id999.buz.android.bundle

不过,对于一些复杂业务来说,页面内容会比较多,把一些非首屏的代码放在业务包中会拖慢首屏的加载速度,因此更好的方案是,把这些代码放在按需加载包中进行加载。当用户点击某个按钮或者下拉时,会再触发相关的按需加载逻辑。

此时,metro-code-split 会根据 import(‘xxx’) 中的参数路径,找到对应的 CDN 地址,比如 Foo.js 模块对应的就是如下 CDN 地址:

https://static001.geekbang.org/resource/rn/03ad61906ed0e1ec92c2.bundle

然后,再根据该 CDN 地址请求按需加载包,并通过 new Function(code) 的方式执行下载回来的代码,把 Foo 组件加载到当前 JavaScript 的上下文中,并进行最终的渲染。以上方案适合首页是 Native 页面的混合应用,如果首页也是 React Native 页面怎么办呢?

1,首页是 React Native 页面,而且采用的是多 Bundle 策略

那么,公共包依旧需要内置,并且首页业务包也需要内置。此时,首页业务包采用静默更新策略,也就是当次下载、下次生效的策略。这样每次启动时首页,首页的业务包是从本地加载的,不走网络请求,首页的启动速度就会变快。其他页面的业务包或按需加载包继续采用,当次生效的动态下发形式进行更新。

当次生效的方式,大概多了 300ms~500ms 的 Bundle 下载时间,但带来的好处是业务能够随时更新、Bug 能够随时修复,不用等到用户下次进入页面再生效。

2,首页是 React Native 页面,但采用的是单 Bundle 策略

那么,公共包和业务包需要分别内置,其中公共包走发版更新流程,业务包走 CodePush 静默更新流程。相对于纯 CodePush 方案,通过拆包的方式,能够节约 CodePush 更新的下载量体积。如果你还同时使用了按需加载包,那么还能节约非首屏代码的执行时间。

如果遇到紧急 Bug,CodePush 也支持当次生效。但由于 CodePush 底层机制的原理,它不仅需要下载热更新 Bundle,还需要重新加载整个 JavaScript 环境,耗时比较长,因此不建议你把它用作默认的更新方式。

四、总结

现在,使用开源拆包工具 metro-code-split 能够很方便地帮你把整个 Bundle 包拆分成公共包、业务包和按需加载包。你只需要下载、配置和执行命令,就可以完成拆包操作了。

本地拆包只是热更新流程中的一个环节,因此你需要配合你的热更新流程一起使用。根据业务的不同,应用可大致分为三种形态,包括单 Bundle 的纯 React Native 应用、多 Bundle 的纯 React Native 以及多 Bundle 的混合应用,每种不同的形态的应用采用的热更新方式和拆包策略都有所区别,你需要结合具体的场景进行分析。

虽然使用 metro-code-split 进行拆包很简单,但要实现 metro-code-split 并不容易,在编译时、运行时有大量的工作需要处理,你还得把所有模块的正向依赖、逆向依赖给理清楚,才能合理的进行拆包。

参考:metro-code-split 示例