Files
knowledge-kit/Chapter1 - iOS/1.69.md
2024-04-27 13:01:58 +08:00

775 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.01JS 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 KBday.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 的每个节点都是一个 ElementReact 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 属性为 RCTTextstyle 属性值由设置透明属性 `opacity:0.9` 和设置居中布局的属性组成,子节点 children 属性值为 `Hello world`
从 Hello World 应用中的 <App> <Text> 节点的构成,我们可以看出,一个 Element 常见的属性包括 type 、props、concurrentRoot、style、children 等属性。
- typetype 代表该 Element 的类型。如果 type 的值是 RCTText、RCTView 之类的字符串,那么该 Element 对应着一个宿主视图。如果 type 的值是函数或类,那么该 Element 是由合成组件生成的,并且没有对应的宿主视图。
- propsElement 初始化传入的属性,其中又包括当前根节点 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 是 RCTTextApp 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 标签**。效果如下
![渲染为标签](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/20200205-ReactPureComponent)
最佳实践就是:自定义组件大写开头,小写开头会被认为是 html 标签。createElement(ComponentVariable)createElement('renderOverview')。只是渲染,不需要内部改变 state 的话,纯函数非常适合做 UI 渲染,状态的事情由 redux 解决,最后通过 props 处理。