关于react-native:Metro拆包工作原理与实战

2次阅读

共计 9140 个字符,预计需要花费 23 分钟才能阅读完成。

一、背景

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

能够看到,在下面热更新示意图中,咱们会把 Old Version 的字符串文件进行内置,这部分代码除了降级 React Native 版本之外不会轻易改变。而 New Version 的字符串是本次热更新的指标代码,也就是残缺的 Bundle 文件,但开发者并不需要下载残缺的 Bundle 文件,因为 Old Version 曾经内置到 App 中了,咱们只须要下发 Patch 热更新包即可。客户端接管到 Patch 热更新包后,会和 Old Version 代表的内置包进行合并,最终加载的是通过合并的残缺 Bundle 包。

能够看到,基于文本的拆包与合包原理,Patch 热更新包是一段记录批改地位、批改内容的文本,而不是可独立执行的代码,间接导致的后果是,只能等到下载实现后生成残缺的 Bundle 文件能力整体执行。这就是为什么基于文本拆包形式不可独立执行的起因。

但基于模块的拆包形式,内置包和热更新包就能够分别独立执行。同样,还是以多 Bundle 模式的 Foo 业务热更新为例,上面仿佛基于模块拆包示意图。

能够看到,基于模块拆包计划拆出来的热更新包是能够独立运行的。因而,应用模块拆包计划后,能够在客户端先运行内置包,同时并行下载热更新包,等热更新包下载实现再接着运行热更新包,当然也能够在利用启动后就去下载,从而升高热更新包的加载时长。

3.2 热更新与拆包

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

因为咱们采纳的是模块拆包计划,尽管实践上每个包都是能够独立运行的,但实际上模块和模块之间是有依赖关系的,整体上讲,按需加载包会依赖业务包中的模块,业务包会依赖公共包中的模块。因而,须要先执行公共包、再执行业务包,最初执行按需加载包。

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

正文完
 0