mirror of
https://github.com/NohamR/knowledge-kit.git
synced 2026-05-25 04:17:17 +00:00
update: image source
This commit is contained in:
@@ -1,5 +1,604 @@
|
||||
# React Native 总结
|
||||
|
||||
|
||||
|
||||
## 一、为什么React/React Native 采用组件式的设计?
|
||||
|
||||
除了组件式之外,常见的构建方式还有类似 HTML + CSS + JS 分层的架构、基于 MVC 的架构,那为什么会采用组件的架构设计?
|
||||
|
||||
- 随着前端/客户端等展示层 UI 交互承载的业务逻辑越来越复杂,单纯的 MVC 会面临「**胖 Model**」、「**巨大 Controller**」等情况
|
||||
- 业务千变万化,昨日的设计无法支撑下个版本的产品调性,但写过的代码还是有用的,直接 copy 过去?
|
||||
- 设计部门也在千变万化的版本迭代中沉淀公司自己的设计规范、交互规范、Android、iOS 各个平台的人机交互指南,基于此也沉淀产出了 UI 组件库。那端上同学必然基于组件式去写一套 UI 组件代码。
|
||||
- 组件化的风慢慢的吹到了 RN 诞生之初,2010年开源的 MVC 模式的 AngularJS 也被最新的 Angular(>= V2)这种基于组件的架构模式所取代。组件式影响到新诞生的一批框架
|
||||
|
||||
React/React Native 选择基于组件的架构模式,好处有3:
|
||||
|
||||
- 第一,组件是内聚的,组件内既有逻辑,也有状态,又有视图。一个组件可以独立完成一个事情,这也使得 UI 模块复用变得简单;
|
||||
- 第二,组件之间是可以组合的,多个组件可以组合成一个更大的页面或者一个更大的组件。当一个组件很大很臃肿、难以维护的时候也可以拆分优化成粒度更合理的组件
|
||||
- 第三,组件和组件之间的数据流动永远是确定的,从上到下单向流动
|
||||
|
||||
组件可组合、可复用的特性,和组件之间单向数据流的模式,是现代应用重交互重展示的情况下,更方便,这也是 React/React Native 采用组件式的核心原因。
|
||||
|
||||
|
||||
|
||||
## 二、热更新平台
|
||||
|
||||
热更新能力是大家选择 RN 的一个重要原因之一,有了热更新能力,就相当于在用户手机和公司业务之间铺设了一道直达的高速公路,公司新业务开发后不再受限于 App 发版审核下载更新这个流程了。那么如何设计一个热更新平台呢?
|
||||
|
||||
业界主流的有:
|
||||
|
||||
- Code Push:是微软 App Center 的服务之一。底层是微软自家的 Azure 云服务。由于国内网络环境的原因,访问国外云服务较慢,不推荐使用
|
||||
- Expo:是亚马逊的 AWS 和 Google Cloud 云服务。由于国内网络环境的原因,访问国外云服务较慢,不推荐使用
|
||||
- Pushy:是 React Native 中文网提供的热更新方案,使用的是国内的阿里云服务,且比前2者有更省流量的增量更新方案。也是国内可直接使用的开源热更新方案之一了。
|
||||
- 自研:灵活自由度高,可控
|
||||
|
||||
热更新方案主要包括2部分:打包服务 + 静态资源服务。
|
||||
|
||||
打包服务核心就是将 React Native 项目中的 JS 代码打包成一个 Bundle 文件。静态资源服务就是将 Bundle 文件分发给客户端的服务。客户端拿到 Bundle 文件后,就可以渲染展示了。
|
||||
|
||||
1. 通过 react-native bundle 命令,提前把 JS 代码打包成一个 Bundle 文件,本质上是一个可执行的 JavaScript 文件。
|
||||
|
||||
```shell
|
||||
npx react-native bundle --entry-file index.js --dev false --minify false --bundle-output ./build/index.bundle --assets-dest ./build
|
||||
```
|
||||
|
||||
2. 如果使用的是 Hermes,还需要把 Javascript 文件转成相应的字节码文件。Hermes 提供了方案
|
||||
|
||||
```
|
||||
hermes -emit-binary -out ./build/index.hbc ./build/index.bundle
|
||||
```
|
||||
|
||||
转换后会得到一个 `.hbc` 字节码包,hbc 也就是 hermes bytecode。
|
||||
|
||||
得到 Bundle 文件后就需要上传到 CDN 上了。但 CDN 存在一个问题,就是 CDN 资源地址是固定不变的,所以不够灵活,**存在几分钟的更新延迟问题**,假设线上出现一个重大故障,需要等几分钟才可以完全回滚,这对于公司形象、用户损失都会产生很大影响。
|
||||
|
||||
<img src="https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/ReactNativeBundleCDNIssue.png" style="zoom:40%">
|
||||
|
||||
上图中旧版本的 JS Bundle 包是绿色的,新版本 JS Bundle 是蓝色的。在旧版本覆盖新版本的过程中:删除 CDN 缓存的旧版本资源,当 CDN 没有缓存了,这时候用户新的请求才不会命中缓存,而是到 OSS 拉取最新资源。
|
||||
|
||||
然而 CDN 不是单点计算机,而是分布在不同位置的网络节点,当使用 CDN 刷新能力时,实际上就是删除上千个节点的缓存。搞过 CDN 的人说,要把这成千个节点的缓存删除干净,最长需要5分钟,同时无法保证这5分钟的时效。
|
||||
|
||||
也就是说这5分钟内请求 JS Bundle 存在3种情况:
|
||||
|
||||
- 命中老版缓存
|
||||
- 未命中缓存,从 OSS 拉取新的资源
|
||||
- 命中新版缓存
|
||||
|
||||
也就是说在享受 CDN 的同时,也要接受这5分钟渐进式更新的延迟,也就是说要有5分钟内用户可能全部访问有问题的业务 JS Bundle 的预见性。
|
||||
|
||||
|
||||
|
||||
有没有改进措施?
|
||||
|
||||
在端上设备和 CDN 之间再架设一层版本服务。
|
||||
|
||||
<img src="https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/ReactNativeBundleServer.png" style="zoom:40%">
|
||||
|
||||
具体步骤:
|
||||
|
||||
1. 本地打包好的 JS Bundle 根据文件内容生成 MD5,然后命名对应的 JS Bundle 文件,格式为 `{MD5}.bundle` ,保证唯一性,然后上传 Bundle 到 OSS。
|
||||
2. 发布上线,比如一个可视化平台点击上线按钮,要做的事情就是告诉版本服务,当前业务 JS Bundle 版本为 0.01,JS Bundle 名为 `s2d8...j07.bundle`
|
||||
3. 端上发起请求,请求版本服务,版本服务根据信息返回对应的 Bundle 名。`{uri: s2d8...j07.bundle}`
|
||||
4. 端上再次发起真正的 CDN 资源请求。资源请求会先询问某个 CDN 边缘节点,如果边缘节点没有缓存,则去源站拉取资源;如果边缘节点有缓存,则直接返回
|
||||
|
||||
|
||||
|
||||
## 三、RN 启动速度优化
|
||||
|
||||
### 3.1 Hermes
|
||||
|
||||
Hermes 是 FaceBook 2019 年中旬开源的一款 JS 引擎,从 **release[1]** 记录可以看出,这个是专为 React Native 打造的 JS 引擎,可以说从设计之初就是为 Hybrid UI 系统打造。
|
||||
|
||||
Hermes **支持直接加载字节码**,也就是说,`Babel`、`Minify`、`Parse` 和 `Compile` 这些流程全部都在开发者电脑上完成,直接下发字节码让 Hermes 运行就行,这样做可以**省去 JSEngine 解析编译 JavaScript 的流程**,JS 代码的加载速度将会大大加快,启动速度也会有非常大的提升。
|
||||
|
||||
|
||||
|
||||
### 3.2 减小 JS Bundle 体积
|
||||
|
||||
前面的优化其实都是 Native 层的优化,从这里开始就进入 Web 前端最熟悉的领域了。
|
||||
|
||||
其实谈到 JS Bundle 的优化,来来回回就是那么几条路:
|
||||
|
||||
- **缩**:缩小 Bundle 的总体积,减少 JS 加载和解析的时间
|
||||
- **延**:动态导入(dynamic import),懒加载,按需加载,延迟执行
|
||||
- **拆**:拆分公共模块和业务模块,避免公共模块重复引入
|
||||
|
||||
如果有 webpack 打包优化经验的小伙伴,看到上面的优化方式,是不是脑海中已经浮现出 webpack 的一些配置项了?不过 React Native 的打包工具不是 webpack 而是 Facebook 自研的 Metro,虽然配置细节不一样,但道理是相通的,下面我就这几个点讲讲 React Native 如何优化 JS Bundle。
|
||||
|
||||
|
||||
|
||||
Metro 打包 JS 时,会把 ESM 模块转为 CommonJS 模块,这就导致现在比较火的依赖于 ESM 的 Tree Shaking 完全不起作用,而且根据官方回复,Metro 未来也不会支持 Tree Shaking
|
||||
|
||||
<img src="https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/RNWillNotSupportTreeShaking.jpeg" style="zoom:40%">
|
||||
|
||||
说人话就是 Tree-Shaking 现在不搞了,现在在搞更有前途的方式来保证 bundle 体积的减少:
|
||||
|
||||
#### 1. 使用 react-native-bundle-visualizer 查看包体积
|
||||
|
||||
使用 react-native-bundle-visualizer 查看包体积。优化 bundle 文件前,一定要知道 bundle 里有些什么,最好的方式就是用可视化的方式把所有的依赖包列出来。web 开发中,可以借助 Webpack 的 `webpack-bundle-analyzer` 插件查看 bundle 的依赖大小分布,React Native 也有类似的工具,可以借助 RN`react-native-bundle-visualizer` 查看依赖关系:
|
||||
|
||||
<img src="https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/RNDemoMoudleDependcencyVisualizer.jpeg" style="zoom:40%">
|
||||
|
||||
#### 2. 对于同样的功能,优先选择体积更小的第三方库
|
||||
|
||||
这是一个非常经典的例子。同样是时间格式化的第三方库, moment.js 体积 200 KB,day.js 体积只有 2KB,而且 API 与 moment.js 保持一致。如果项目里用了 moment.js,替换为 day.js 后可以立马减少 JSBundle 的体积。
|
||||
|
||||
**利用 babel 插件,避免全量引用**
|
||||
|
||||
lodash 基本上属于 Web 前端的工程标配了,但是对于大多数人来说,对于 lodash 封装的近 300 个函数,只会用常用的几个,例如 `get`、 `chunk`,为了这几个函数全量引用还是有些浪费的。
|
||||
|
||||
社区上面对这种场景,当然也有优化方案,比如说 `lodash-es`,以 ESM 的形式导出函数,再借助 Webpack 等工具的 Tree Sharking 优化,就可以只保留引用的文件。但是就如前面所说,React Native 的打包工具 Metro 不支持 Tree Shaking,所以对于 `lodash-es` 文件,其实还会全量引入,而且 `lodash-es` 的全量文件比 `lodash` 要大得多。
|
||||
|
||||
做了个简单的测试,对于一个刚刚初始化的 React Native 应用,全量引入 lodash 后,包体积增大了 71.23KB,全量引入 `lodash-es` 后,包体积会扩大 173.85KB。
|
||||
|
||||
`lodash-es ` 太大了,能不能在 lodash 上做文章?
|
||||
|
||||
```js
|
||||
// 全量
|
||||
import { join } from 'lodash'
|
||||
// 局部
|
||||
import join from 'lodash/join'
|
||||
```
|
||||
|
||||
这样打包就只会打 `lodash/join` 这一个文件,但这严格依赖于团队小伙伴的共识,不能严格保证。且使用 lodash 的七八个方法,就需要 import 七八次,这很低效。有个 `babel-plugin-lodash` 插件,可以在 JS 编译时操作 AST 做如下转换:
|
||||
|
||||
```js
|
||||
import { join, chunk } from 'lodash'
|
||||
// 转换为
|
||||
import join from 'lodash/join'
|
||||
import chunk from 'lodash/chunk'
|
||||
```
|
||||
|
||||
终极大杀器:**`babel-plugin-import`** 基本可以解决所有按需引用的问题
|
||||
|
||||
如何使用?
|
||||
|
||||
有个 ahooks 开源库,封装了很多常用的 React hooks,但问题是这个库是针对 Web 平台封装的,比如说 `useTitle` 这个 hook,是用来设置网页标题的,但是 React Native 平台是没有相关的 BOM API 的,所以这个 hooks 完全没有必要引入,RN 也永远用不到这个 API。
|
||||
|
||||
这时候我们就可以用 `babel-plugin-import` 实现按需引用了,假设我们只要用到 `useInterval` 这个 Hooks,我们现在业务代码中引入:
|
||||
|
||||
```javascript
|
||||
import { useInterval } from 'ahooks'
|
||||
```
|
||||
|
||||
然后运行 `yarn add babel-plugin-import -D` 安装插件,在 `babel.config.js` 文件里启用插件:
|
||||
|
||||
```
|
||||
// babel.config.js
|
||||
module.exports = {
|
||||
plugins: [
|
||||
[
|
||||
'import',
|
||||
{
|
||||
libraryName: 'ahooks',
|
||||
camel2DashComponentName: false, // 是否需要驼峰转短线
|
||||
camel2UnderlineComponentName: false, // 是否需要驼峰转下划线
|
||||
},
|
||||
],
|
||||
],
|
||||
presets: ['module:metro-react-native-babel-preset'],
|
||||
};
|
||||
```
|
||||
|
||||
启用后就可以实现 ahooks 的按需引入
|
||||
|
||||
```js
|
||||
import { useInterval } from 'ahooks'
|
||||
// 等价于
|
||||
import useInterval from 'ahooks/lib/useInterval'
|
||||
```
|
||||
|
||||
| 全量 ahooks | ahooks/lib/useInterval 单文件引用 | ahooks + babel-plugin-import |
|
||||
| :---------- | :-------------------------------- | :--------------------------- |
|
||||
| 111.41 KiB | 443 Bytes | 443 Bytes |
|
||||
|
||||
#### 3. 制定编码规范,减少重复代码
|
||||
|
||||
##### 移除 console
|
||||
|
||||
`babel-plugin-transform-remove-console` 插件,我们可以配置它在打包发布的时候移除 `console` 语句,减小包体积的同时还会加快 JS 运行速度,我们只要安装后再简单的配置一下就好了:
|
||||
|
||||
```js
|
||||
// babel.config.js
|
||||
module.exports = {
|
||||
presets: ['module:metro-react-native-babel-preset'],
|
||||
env: {
|
||||
production: {
|
||||
plugins: ['transform-remove-console'],
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
##### 制定良好的编码规范
|
||||
|
||||
- 代码的抽象和复用:代码中重复的逻辑根据可复用程度,尽量抽象为一个方法,不要用一次复制一次
|
||||
- 删除无效的逻辑:这个也很常见,随着业务的迭代,很多代码都不会用了,如果某个功能下线了,就直接删掉,哪天要用到再从 git 记录里找
|
||||
- 删除冗余的样式:例如引入 ESLint plugin for React Native,开启 `"react-native/no-unused-styles"` 选项,借助 ESLint 提示无效的样式文件
|
||||
|
||||
### 3.3 Inline Requires
|
||||
|
||||
懒执行。一般情况下 RN 容器初始化之后就会加载全量的 JS Bundle 文件,而 Inline Requires 延迟运行,只有需要使用的时候才会执行 JS 代码,而不是启动的时候就执行,RN 0.64 版本,默认开启。需要在 `metro.config.js` 中进行修改
|
||||
|
||||
```js
|
||||
const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const config = {
|
||||
transformer: {
|
||||
getTransformOptions: async () => ({
|
||||
transform: {
|
||||
experimentalImportSupport: false,
|
||||
inlineRequires: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = mergeConfig(getDefaultConfig(__dirname), config);
|
||||
```
|
||||
|
||||
比如说我们写了个工具函数 `join` 放在 `utils.js` 文件里:
|
||||
|
||||
```js
|
||||
// utils.js
|
||||
export function join(list, j) {
|
||||
return list.join(j);
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// App.js
|
||||
import { join } from 'my-module';
|
||||
const App = (props) => {
|
||||
const result = join(['a', 'b', 'c'], '~');
|
||||
return <Text>{result}</Text>;
|
||||
};
|
||||
```
|
||||
|
||||
被 Metro 编译后
|
||||
|
||||
```js
|
||||
const App = (props) => {
|
||||
const result = require('./utils').join(['a', 'b', 'c'], '~');
|
||||
return <Text>{result}</Text>;
|
||||
};
|
||||
```
|
||||
|
||||
<img src="https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/RNInlineRequireDemo1.png" style="zoom:40%">
|
||||
|
||||
`r()` 代表 `require()` 函数,可以看到在顶部的 import 实际上被替换成在实际使用的位置 import 了。
|
||||
|
||||
然而 Metro 的 import 是不支持 export default 的。需要注意,可以具体查看 [RN 官方文档](https://reactnative.dev/docs/ram-bundles-inline-requires)
|
||||
|
||||
|
||||
|
||||
### 3.4 RN 拆包
|
||||
|
||||
假设业务 A 和 B 的业务代码是通过 JS Bundle 动态下发的:
|
||||
|
||||
- Business A JS Bundle:共300KB,其中 200KB 基础包(React、RN)、100KB 业务代码
|
||||
- Business B JS Bundle:共400KB,其中 200KB 基础包(React、RN)、200KB 业务代码
|
||||
|
||||
访问完业务 A 之后再去访问业务 B,下载好300KB 代码后需要继续下载400KB 代码。存在冗余,完全没有必要多次下载和加载,这时候一个想法自然而然就出来了:
|
||||
|
||||
> 能否把一些共有库打包到一个 `common.bundle` 文件里,我们每次只要动态下发业务包 `businessA.bundle` 和 `businessB.bundle`,然后在客户端实现先加载 `common.bundle` 文件,再加载 `business.bundle` 文件就可以了
|
||||
|
||||
这样做的好处有几个:
|
||||
|
||||
- `common.bundle` 可以直接放在本地,省去多业务线的多次下载,**节省流量和带宽**
|
||||
- 可以在 RN 容器预初始化的时候就加载 `common.bundle` ,**二次加载的业务包体积更小,初始化速度更快**
|
||||
|
||||
顺着上面的思路,上面问题就会转换为两个小问题:
|
||||
|
||||
- 如何实现 JSBundle 的拆包?
|
||||
- iOS/Android 的 RN 容器如何实现多 bundle 加载?
|
||||
|
||||
|
||||
|
||||
RN 本地调试还是构建,底层都是用到了 Metro 打包工具的能力。然而 Facebook 的 Metro 本身没有拆包能力,只能将 JS 代码打包成一个 Bundle 文件,且 Metro 不支持三方插件。在查看 Metro 源代码的时候发现了一个令人眼前一亮的方法 `customSerializer` ,可以实现不侵入修改 Metro 源码,通过配置的方式给 Metro 写第三方插件的能力
|
||||
|
||||
#### 3.4.1 拆包原理
|
||||
|
||||
为什么选择基于模块拆包而不是基于文本?因为基于模块拆包,加载速度会更快。
|
||||
|
||||
为什么基于模块的拆包方式能够独立运行,而基于文本的拆包方式不能独立运行?
|
||||
|
||||
来做一些说明,架设采用的是多 Bundle 基于文本的拆包方式,多个 Bundle 之间的公共代码是 React、React Native 库,用 `console.log('react')`、`console.log('react native')` 代替。多个 Bundle 不同的部分是业务代码,用 `console.log('foo')` 代表业务代码。
|
||||
|
||||
基于文本拆包一般采用 Google 开源的 [diff-match-patch](https://github.com/google/diff-match-patch?tab=readme-ov-file) 算法。repo 主页也提供了 Demo 入口,可以在线体验效果
|
||||
|
||||
<img src="https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/ReactNativeBundlePatchDemo.png" style="zoom:40%">
|
||||
|
||||
实际上,在基于文本计算热更新包的场景下,我们会内置 Old version,这部分代码除了升级 RN 版本外不会改动,而 New Version 的字符串是本次热更新的目标代码,但为了传输效率不需要下载完成的 Bundle 文件,因为 Old Version 已经内置了,基于 diff-match-patch 计算出需要热更新的部分即可。
|
||||
|
||||
客户端拉取到需要 Patch 热更新包后,会和 Old Version 代表的内置包进行合并,合并的结果就是 New Version 所代表的完整的 Bundle
|
||||
|
||||
很显然,Patch 热更新是一段记录修改位置、修改内容的文本,而不是一段可单独运行的代码。会导致内置包没法提前执行,只能等下在完成再合并,生成完整的 Bundle 文件后,作为整体才执行。这就是为什么基于文本的拆包方式不能独立运行的原因。
|
||||
|
||||
|
||||
|
||||
引入正题,基于模块的拆包方式,内置包和热更新包就可以分别独立运行了。
|
||||
|
||||
<img src="https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/ReactNativeBundlePatch.png" style="zoom:40%">
|
||||
|
||||
可以看到基于模块的拆包方案,拆出来热更新包是一个业务代码,单独可运行。所以可以在客户端先运行内置包,然后下载热更新包,等热更新包下载完成,再运行热更新包。
|
||||
|
||||
|
||||
|
||||
#### 3.4.2 Bundle 文件结构及内容说明
|
||||
|
||||
React Native打包形成的Bundle文件的内容从上到下依次是:
|
||||
|
||||
- Polyfills:定义基本的JS环境(如:`__d()`函数、`__r()`函数、`__DEV__` 变量等)
|
||||
- Module定义:使用`__d()`函数定义所有用到的模块,该函数为每个模块赋予了一个模块ID,模块之间的依赖关系都是通过这个ID进行关联的。
|
||||
- Require调用:使用`__r()`函数引用根模块。
|
||||
|
||||
业务不同的2个 Bundle 但是会在 Polyfills 部分和 Module 定义的部分有大量重复,因为都包含 React、React Native 2个模块代码,重复部分大约500K 左右
|
||||
|
||||
|
||||
|
||||
`-d` 函数实际上就是 `define()` 函数,3个参数分别为:factory 方法、moudleID、dependencyMap
|
||||
|
||||
```js
|
||||
function define(factory, moduleId, dependencyMap) {
|
||||
if (moduleId in modules) {
|
||||
// that are already loaded
|
||||
return;
|
||||
}
|
||||
modules[moduleId] = { dependencyMap};
|
||||
// other code ....
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
|
||||
`_r` 函数实际上就是 `require()` 函数,这个方法首先判断所有要加载的模块是否已经存在并完成了初始化。如果是,则直接返回模块的 exports,如果不是则调用 `guardedLoadModule` 方法来完成模块的初始化
|
||||
|
||||
```js
|
||||
function require(moduleId) {
|
||||
const module = modules[moduleId];
|
||||
return module && module.isInitialized
|
||||
? module.exports
|
||||
: guardedLoadModule(moduleIdReallyIsNumber, module);
|
||||
}
|
||||
function guardedLoadModule(moduleId, module) {
|
||||
return loadModuleImplementation(moduleId, module);
|
||||
}
|
||||
function loadModuleImplementation(moduleId, module) {
|
||||
module.isInitialized = true;
|
||||
const exports = (module.exports = {});
|
||||
var _module = module;
|
||||
const factory = _module.factory,
|
||||
dependencyMap = _module.dependencyMap;
|
||||
const moduleObject = { exports };
|
||||
factory(global, require, moduleObject, exports, dependencyMap);
|
||||
return (module.exports = moduleObject.exports);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
#### 3.4.3 公共资源包
|
||||
|
||||
随着 RN 版本迭代,官方已经逐步将bundle文件生成流程规范化,并为此设计了独立的打包模块 – Metro。Metro 通过输入一个需要打包的JS文件及几个配置参数,返回一个包含了所有依赖内容的JS文件。
|
||||
|
||||
Metro将打包的过程分为了3个依次执行的阶段:
|
||||
|
||||
1. **解析(Resolution)**:计算得到所有的依赖模块,形成依赖树,该过程是多线程并行执行。
|
||||
2. **转义(Transformation)**:代码的编译转换,该过程是多线程并行执行。
|
||||
3. **序列化(Serialization)**:所有代码转换完毕后,打印转换后的代码,生成一个或者多个 bundle 文件
|
||||
|
||||
Metro工具提供了配置功能,开发人员可以通过配置RN项目中的**metro.config.js**文件修改bundle文件的生成流程。
|
||||
|
||||
可以看到,我们需要关注 Metro Serialization 阶段,只要借助 `Serialization` 暴露的各个方法就可以实现 bundle 分包了。主要是 `createModuleIdFactory(path)` 方法和 `processModuleFilter(module)` 。
|
||||
|
||||
`createModuleIdFactory(path)`是传入的模块绝对路径`path`,并为该模块返回一个唯一的`Id`。`processModuleFilter(module)`则可以实现对模块进行过滤,使其不被写入到最后的bundle文件中。
|
||||
|
||||
官方的 `createModuleIdFactory` 内部实现是返回一个数字,该数字在 require 方法中被调用,以此来实现模块的导入和初始化
|
||||
|
||||
```js
|
||||
"use strict";
|
||||
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;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
官方默认的实现存在一个问题,就是业务代码改动后,重新打包,由于 moduleID 是从0开始自增分配,可能会存在前后2次构建中 moduleID 发生改变。
|
||||
|
||||
针对官方实现,可以重新自定义 `createModuleIdFactory(path)` 方法,该方法根据当前模块文件的路径哈希值作为分配 moduleID 的依据,并建立哈希值和模块 ID 的对应关系保存到本地文件缓存中,每次编译 Bundle 先读取本地缓存文件来初始化内存缓存,当需要分配 ID 的时候,先从缓存内部查找,找不到则重新分配 ID 并存储变化。
|
||||
|
||||
```js
|
||||
// metro.common.config.js
|
||||
/**
|
||||
* Metro configuration
|
||||
* https://facebook.github.io/metro/docs/configuration
|
||||
*
|
||||
* @type {import('metro-config').MetroConfig}
|
||||
*/
|
||||
const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const config = {
|
||||
transformer: {
|
||||
getTransformOptions: async () => ({
|
||||
transform: {
|
||||
experimentalImportSupport: false,
|
||||
inlineRequires: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
serializer: {
|
||||
createModuleIdFactory: function () {
|
||||
//获取命令行执行的目录,__dirname是nodejs提供的变量
|
||||
const projectRootPath = __dirname;
|
||||
return (path) => {
|
||||
let name = '';
|
||||
// 如果需要去除react-native/Libraries路径去除可以放开下面代码
|
||||
// if (path.indexOf('node_modules' + pathSep + 'react-native' + pathSep + 'Libraries' + pathSep) > 0) {
|
||||
// //这里是react native 自带的库,因其一般不会改变路径,所以可直接截取最后的文件名称
|
||||
// name = path.substr(path.lastIndexOf(pathSep) + 1);
|
||||
// }
|
||||
if (path.indexOf(projectRootPath) == 0) {
|
||||
/*
|
||||
这里是react native 自带库以外的其他库,因是绝对路径,带有设备信息,
|
||||
为了避免重复名称,可以保留node_modules直至结尾
|
||||
如/{User}/{username}/{userdir}/node_modules/xxx.js 需要将设备信息截掉
|
||||
*/
|
||||
name = path.substr(projectRootPath.length + 1);
|
||||
}
|
||||
//js png字符串 文件的后缀名可以去掉
|
||||
// name = name.replace('.js', '');
|
||||
// name = name.replace('.png', '');
|
||||
//最后在将斜杠替换为下划线
|
||||
let regExp = pathSep == '\\' ? new RegExp('\\\\', "gm") : new RegExp(pathSep, "gm");
|
||||
name = name.replace(regExp, '_');
|
||||
//名称加密
|
||||
if (isEncrypt) {
|
||||
name = md5(name);
|
||||
}
|
||||
fs.appendFileSync('./idList.txt', `${name}\n`);
|
||||
return name;
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = mergeConfig(getDefaultConfig(__dirname), config);
|
||||
```
|
||||
|
||||
同时,为了能够在`processModuleFilter(module)`方法中对模块进行过滤,需要在构建Common文件时,标记某个模块是否已包含在Common文件中。
|
||||
|
||||
打 common 包:`npx react-native bundle --platform ios --config metro.common.config.js --dev false --entry-file common.js --bundle-output='./ios/common.ios.bundle'`
|
||||
|
||||
#### 3.4.4 业务资源包
|
||||
|
||||
|
||||
|
||||
```js
|
||||
// metro.business.config.js
|
||||
const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config');
|
||||
|
||||
/**
|
||||
* Metro configuration
|
||||
* https://facebook.github.io/metro/docs/configuration
|
||||
*
|
||||
* @type {import('metro-config').MetroConfig}
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const idList = fs.readFileSync('./idList.txt', 'utf8').toString().split('\n');
|
||||
|
||||
function createModuleId(path) {
|
||||
// 和上面生成 moduleID 方法一样
|
||||
return moduleId;
|
||||
}
|
||||
|
||||
const config = {
|
||||
transformer: {
|
||||
getTransformOptions: async () => ({
|
||||
transform: {
|
||||
experimentalImportSupport: false,
|
||||
inlineRequires: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
serializer: {
|
||||
createModuleIdFactory: function () {
|
||||
return function (path) {
|
||||
return createModuleId(path);
|
||||
};
|
||||
},
|
||||
processModuleFilter: function (modules) {
|
||||
const mouduleId = createModuleId(modules.path);
|
||||
// 通过 mouduleId 过滤在 common.bundle 里的数据
|
||||
if (idList.indexOf(mouduleId) < 0) {
|
||||
console.log('createModuleIdFactory path', mouduleId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = mergeConfig(getDefaultConfig(__dirname), config);
|
||||
```
|
||||
|
||||
此时打业务包 `npx react-native bundle --platform ios --config metro.business.config.js --dev false --entry-file index.js --bundle-output='./ios/businessA.ios.bundle'`
|
||||
|
||||
其中几个业务,就需要几个业务入口,也就需要打几次包。当然可以再次基础上包装一层,比如读取配置文件。
|
||||
|
||||
|
||||
|
||||
#### 3.4.5 客户端加载
|
||||
|
||||
以 iOS 为例,加载基础包
|
||||
|
||||
```objective-c
|
||||
@implementation AppDelegate
|
||||
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
|
||||
{
|
||||
RCTBridge *bridge = [[RCTBridge alloc] initWithDelegate:self launchOptions:launchOptions];
|
||||
RCTRootView *rootView = [[RCTRootView alloc] initWithBridge:bridge
|
||||
moduleName:@"rnCodeSplitDemo"
|
||||
initialProperties:nil];
|
||||
|
||||
if (@available(iOS 13.0, *)) {
|
||||
rootView.backgroundColor = [UIColor systemBackgroundColor];
|
||||
} else {
|
||||
rootView.backgroundColor = [UIColor whiteColor];
|
||||
}
|
||||
|
||||
self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
|
||||
UIViewController *rootViewController = [UIViewController new];
|
||||
rootViewController.view = rootView;
|
||||
self.window.rootViewController = rootViewController;
|
||||
[self.window makeKeyAndVisible];
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
|
||||
{
|
||||
return [[NSBundle mainBundle] URLForResource:@"common.ios"" withExtension:@"bundle"];
|
||||
}
|
||||
```
|
||||
|
||||
加载业务包
|
||||
|
||||
为 `RCTCxxBridge` 添加分类 `RCTCxxBridge+RunBundleJS`
|
||||
|
||||
```objective-c
|
||||
#import <React/RCTBridge+Private.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface RCTCxxBridge (RunBundleJS)
|
||||
- (void)executeSourceCode:(NSData *)sourceCode withSourceURL:(NSURL *)url sync:(BOOL)sync;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
```
|
||||
|
||||
```objective-c
|
||||
|
||||
NSString *businessBundle = [[NSBundle mainBundle] pathForResource:@"business.ios.jsbundle" ofType:nil];
|
||||
NSData *businessData = [NSData dataWithContentsOfFile:businessBundle options:NSDataReadingMappedIfSafe error:nil];
|
||||
[(RCTCxxBridge *)bridge.batchedBridge executeSourceCode:businessData sync:YES];
|
||||
```
|
||||
|
||||
|
||||
|
||||
## 学习方面
|
||||
|
||||
学过 React.js 之后你再去学习 React Native 会很简单,一些核心的东西理解之后会很简单。比如 React 中的单向数据流、虚拟 Dom、diff 算法、数据变动的批量更新机制、diff 之后的 UI 渲染。
|
||||
|
||||
Reference in New Issue
Block a user