mirror of
https://github.com/NohamR/knowledge-kit.git
synced 2026-05-25 12:27:15 +00:00
775 lines
39 KiB
Markdown
775 lines
39 KiB
Markdown
# 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];
|
||
```
|
||
|
||
|
||
|
||
## Fabric 新渲染器
|
||
|
||
React Native 开源之初的宏大愿景是:将现代 Web 技术引入移动端(Brighing modern web techniques to mobile)
|
||
想来也是,Web 开发历史悠久,沉淀了很多优秀的 UI 开发实践和基础设置。随着 React Web 的出现,将现代的 Web 中积累的开发理念、语言、框架、规范也带入到移动端,
|
||
但也有难点。如何打通 Web 和移动端
|
||
|
||
换言之,问题就是语言之间相互调用,也就是通信。本质上,JavaScript 如何与 OC/Java 通信。彼此可以互相调用方法、访问属性。
|
||
|
||
### 老架构渲染器
|
||
|
||
- 老渲染器的主要职责之一,就是将 JavaScript 侧声明的组件转换为 iOS/Android 侧的 Api 命令。
|
||
```
|
||
const App = () => <Text>Hello world</Text>
|
||
AppRegistry.registerComponent(appName, () => App)
|
||
```
|
||
当声明一个包含 `<Text>Hello world</Text>` App 组件,并将该 App 组件传递给 `registerComponent` 方法后,通过渲染器,会将声明式的代码转换为原生指令。
|
||
以上 Hello World 应用中会包括一个用于布局的 View 视图和显示文本的视图。在 iOS 端,会生成一个 UIView 用于布局,并会创建 NSAttributedString 用于显示文本。在 Objective-C 中调用相关以上创建视图的 API 后,操作系统就会将 Hello World 文字显示在屏幕上了。
|
||
|
||
- 老渲染器的另一个重要职责是实现 Flex 布局。
|
||
开源的第一版 Flex 布局是直接用原生代码实现的,后来该功能独立了出来,作为一个 C++ 第三方库 Yoga 被 React Native 引入。
|
||
假设想实现文字居中
|
||
```
|
||
<view style={{flex:1, justifyContent: 'center', alignItems: 'center'}}>
|
||
<Text>Hello world</Text>
|
||
</view>
|
||
```
|
||
渲染器会将 style 属性设置,转换为包裹 Hello World 视图容器的 x/y 轴坐标使其实现屏幕居中。
|
||
|
||
- 老版渲染器还有一个职责就是尽可能的提升渲染性能。
|
||
RN 在第一版的时候就设置为双线程异步消息通信架构,后来 RN 团队又为 Yoga 布局引擎,新增了一个线程,专门用于处理布局。
|
||
相较于单线程同步调用的架构,多线程异步消息架构更能大幅减少卡顿。一方面,渲染任务被分解到3个线程中(JS 线程、布局线程、UI 线程),所以 UI 线程的任务量会变少,UI 线程卡顿的几率也会减少。另一方面,采用异步通信,JS 线程任务的执行不会阻塞 UI 线程。
|
||
|
||
|
||
### Fabric 新渲染器
|
||
Fabric 新渲染器是基于老渲染器的重构升级,而重构升级过程中不变的是核心责任,是组件化 / 声明式、Flex 布局和多线程模型。升级的是开发者体验,以及性能提升带来的用户体验。
|
||
Fabric 渲染器完成的主要任务还是将声明的组件转换为最终原生 Api 调用。转换过程涉及到3颗树:
|
||
- Element Tree
|
||
- Fiber Tree
|
||
- Shadow Tree
|
||
|
||
#### Element Tree
|
||
Element Tree 是 Javascript 侧,由 React 通过开发者书写的 JSX 创建而成,由若干个 Element 组成。
|
||
一般而言,根结点 `<App />` 就是一个 Element,同时它也是 Element Tree。一个 Element 其实就是一个普通的 Object,该对象描述组件的实例或宿主视图实例。
|
||
```
|
||
const App = () => {
|
||
return (
|
||
<View style={{opacity: 0.99, flex:1, justifyContent: 'center', alignItems: 'center'}}>
|
||
<Text style={{opacity: 0.88}}>Hello World</Text>
|
||
</View>
|
||
);
|
||
};
|
||
// Element Tree
|
||
<App/>
|
||
```
|
||
整个应用的根节点是 `<App />`,`<App />` 的子节点是 `<View />`,`<View />` 子节点是 `<Text />`,共同构成了一棵 Element Tree。
|
||
Element Tree 的每个节点都是一个 Element,React Element 有2种类型:一种是通过函数或者自定义合成组件生成的、一种是宿主组件生成的。其中,宿主组件指的是框架通过 JS 引擎暴露给 JS 的原生组件(Native 组件)
|
||
|
||
`<App />` 根节点是自定义函数创建的,属于合成组件生成的节点,由 type、props、concurrentRoot 等属性组成的对象,type 属性是一个 function 函数,函数名 name 是 App。打印信息如下
|
||
<img src="https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/ReactNative-ElementTreeDemo1.jpeg" style="zoom:40%">
|
||
|
||
`<Text />` 节点是由框架暴露组件生成的节点,信息如下
|
||
<img src="https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/ReactNative-ElementTreeDemo2.webp" style="zoom:40%">
|
||
|
||
可知,一个 Element 也是一个普通的对象,该对象的 type 属性为 RCTText,style 属性值由设置透明属性 `opacity:0.9` 和设置居中布局的属性组成,子节点 children 属性值为 `Hello world`
|
||
|
||
从 Hello World 应用中的 <App> <Text> 节点的构成,我们可以看出,一个 Element 常见的属性包括 type 、props、concurrentRoot、style、children 等属性。
|
||
- type:type 代表该 Element 的类型。如果 type 的值是 RCTText、RCTView 之类的字符串,那么该 Element 对应着一个宿主视图。如果 type 的值是函数或类,那么该 Element 是由合成组件生成的,并且没有对应的宿主视图。
|
||
- props:Element 初始化传入的属性,其中又包括当前根节点 concurrentRoot、样式 style、子节点 children,或者例如 Text 组件的 ellipsizeMode 文本省略属性等等。
|
||
|
||
在 React 层, Element Tree 会被映射为 Fiber Tree。
|
||
|
||
#### Fiber Tree
|
||
Fiber Tree 由若干个 FIber 节点组成。如果某个 Fiber 节点是通过用于描述宿主视图的 Element 生成的,那么该 Fiber 节点会对应一个同样的宿主视图。
|
||
|
||
Fiber 是 React16 之后引入的新能力,使得 React 每次可渲染的颗粒度变小了。由 React 16 之前的一次 render 所有节点,变成了一次 render 时可分批次对节点进行操作。因此,从渲染角度,我们还可以将 Fiber 节点当作每次 render 的最小渲染单位,让 Fabric 渲染器更快更智能。
|
||
|
||
在 React 内部,Fiber 节点是由 `function createFiberFromElement(element, mode, lanes)` 函数创建的。顾名思义,Fiber 节点是由 Element 节点生成的,换言之 Fiber Tree 可以看成是 Element Tree 的映射。
|
||
|
||
同样 Fiber Tree 分为2种,一种是由合成组件生成的 Element 所映射得到的 Fiber 节点,它没有对应的宿主组件实例;一种是由宿主组件生成的 Element 所映射得到的 Fiber 节点,它拥有对应的宿主组件实例。
|
||
|
||
以 Hello world 威力,打印 App 组件所创建的 Fiber 节点
|
||
<img src="https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/ReactNativeFiberNodeDemo.webp" style="zoom:40%">
|
||
|
||
可以看到 App Fiber 也是一个 JS 对象,也拥有 type、child、props 等属性,这些属性在 Element 上也有。例如 App 组件创建的 Element、Fiber 的 Type 都是一个名为 App 的函数。Fiber 节点上的属性比 Element 多,比如 Fiber 节点拥有兄弟节点 sibling、父节点 return、状态节点 stateNode 等属性。
|
||
|
||
状态节点是一个特殊的属性,关联了渲染器在 C++ 层生成的 Shadow 节点。App Fiber 节点的 stateNode 为 null,代表的就是合成组件所对应的 Fiber 节点是没有关联的 Shadow 节点的,也就没有对应的宿主视图。
|
||
|
||
打印 Text 组件所创建的 Fiber 节点,如下:
|
||
<img src="https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/ReactNativeFiberNodeDemo2.webp" style="zoom:40%">
|
||
Text Fiber 节点和 App Fiber 节点属性都一样,比如:类型 type、子节点 child、兄弟节点 sibling、父节点 return、状态节点 stateNode 等。不同的是 Text Fiber 节点的 type 是 RCTText,App Fiber 的 type 是个函数。Text Fiber 的 stateNode 是有值的,node 属性值是一个 CallbackObject,而 App Fiber 的 node 是 null。该 CallbackObject 类型的值代表的是一个在 C++ 层的 shadow 节点。
|
||
|
||
|
||
#### Shadow Tree
|
||
Shadow Tree 是 C++ 层创建的树,由若干个 Shadow 节点组成,这些 Shadow 节点是在创建对应的拥有 stateNode 值的 Fiber 节点时,同步创建的。
|
||
```
|
||
<Text style={{opacity: 0.88}}>Hello world</Text>
|
||
```
|
||
Xcode 打印如下:
|
||
<img src="https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/ReactNativeShadowNode.webp" style="zoom:40%">
|
||
<Text>元素对应的 Shadow 节点是个 Native 对象,拥有属性 props、子节点 children、布局 layoutMetrics 等属性。
|
||
|
||
其中,Shadow 的 props 的透明度 opacity: 0.88 来自于 JSX 中的 style={{opacity: 0.88}} 的设置;子节点 children 的 text="Hello World" 来自于 JSX 标签括起来的内容 Hello World。而 x/y 轴坐标以及 width/height 视图大小是根据其自身 style 布局属性,以及父节点和其他节点 style 布局属性计算出来的。
|
||
|
||
Shadow Tree 不仅继承了由 JSX 所创建的 Element Tree 相关属性、父子节点关系,还新增了该视图如何在屏幕上进行布局的具体值。
|
||
|
||
最后 Fabric 渲染器的 C++ 层,通过 diff 算法对比更新前后的2颗 Shadow Tree,计算出更新视图的操作指令,完成最终的渲染。整个流程就是 Fabric 渲染器将 JSX 渲染成原生视图的完整流程。
|
||
|
||
<img src="https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/ReactNative-RenderPhase.png" style="zoom:40%">
|
||
可以访问完整的[渲染、提交与挂载流程](https://reactnative.cn/architecture/render-pipeline)
|
||
|
||
## 经验小结
|
||
|
||
最近在做企业内部工具,在提 MR 进行 Code review 的时候,reviewer 提了这样一个问题。将 `{this.renderOverview()}` 改为 `<renderOverview />` 这种写法,界面上更加直观些。我觉得公用组件或者页面这样做是可以的,但是页面上的某个部分 UI,其他页面不需要用到,所以不需要抽取出来。所以这个 code review 我没接受,跟她讲了一番,我维持了现状。
|
||
|
||
背景是这样的,React + Redux 实现界面编写,Redux 负责状态的管理,页面一般是需要负责渲染和交互的,所以在代码编写上通过 Redux 的 `@connect` 将 state 绑定到当前页面的 `props` 上,因此界面的展示全部由 props 完成。页面的交互逻辑由各个组件内部 `dispatch` `action` 到 `reducer` 中进行运算。运算后的结果继续以 props 绑定到当前的页面上。代码如下
|
||
|
||
|
||
```Javascript
|
||
@connect(({ skynet }) => ({
|
||
...skynet,
|
||
}), dispatch => ({
|
||
getXXXLogDetail (***) {
|
||
return dispatch({
|
||
type: ListPre('***'),
|
||
payload: ***
|
||
})
|
||
}
|
||
}))
|
||
|
||
export default class Detail extends Component {
|
||
state = {
|
||
showDispatchPanel: false
|
||
}
|
||
|
||
get id() {
|
||
const { match } = this.props
|
||
return match.params.id
|
||
}
|
||
|
||
componentDidMount() {
|
||
const { getSkyLogDetail } = this.props
|
||
getXXXLogDetail({ groupHash: this.id})
|
||
}
|
||
|
||
s() {
|
||
const { skyLogDetail: { groupDTO = {} } } = this.props
|
||
// ...
|
||
return (
|
||
<div className={style.crashOverview}>
|
||
//***
|
||
</div>
|
||
)
|
||
}
|
||
|
||
render() {
|
||
return (
|
||
<div>
|
||
<Card className={style.container} bordered={false}>
|
||
{this.renderOverview()}
|
||
</Card>
|
||
{/* ... */}
|
||
</div >
|
||
)
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
和 Reviewer 聊过后,她好像自己对这些东西不是很熟悉,比如 `<RenderOverView />` 这种形式需要改造为纯函数,且如果纯函数不能通过这种小写的方式去写,不然 React 在内部渲染会认为是一个**html 标签**。效果如下
|
||

|
||
|
||
最佳实践就是:自定义组件大写开头,小写开头会被认为是 html 标签。createElement(ComponentVariable),createElement('renderOverview')。只是渲染,不需要内部改变 state 的话,纯函数非常适合做 UI 渲染,状态的事情由 redux 解决,最后通过 props 处理。
|
||
|