likes
comments
collection
share

RN 基于Metro 拆包实战

作者站长头像
站长
· 阅读数 40

原文参考: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 算法,它也提供了在线计算网站,它计算热更新包的示意图如下:

RN 基于Metro 拆包实战

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

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

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

RN 基于Metro 拆包实战

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

3.2 热更新与拆包

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

RN 基于Metro 拆包实战

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

当然每个独立的按需加载包之间也会有依赖关系,不过这些加载的依赖关系,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 示例