React Native 核心基础
1.以组件为基本单位
1.宿主组件(核心组件)
在 React Native 中那些最基础、不可再拆的视图材料,大都是由 React Native 框架提供的宿主视图
除了 React Native 框架提供的宿主组件外,一些社区库也提供了宿主组件,甚至你自己也可以创建宿主组件。它们共同的特点是,这些宿主组件上层是 JavaScript 部分,底层是 Native 部分,这两部分是通过 React Native 框架联系起来的。也就是说,你调用宿主组件时,底层直接渲染的是 Native 视图。
例子:View、Text、Image
安全区域组件 SafeAreaView:它是最外层的容器组件,用于适配 iPhoneX 等的刘海儿屏
注意:他们都是对于安卓和 ios 原生组件的封装!
常见的核心组件:
2.复合组件
纯 JavaScript 函数
2.样式
1.样式属性
组件样式 = 通用样式 + “私有”样式,View 组件样式可以算作通用样式,而 Text 和 Image 组件各有各的“私有”样式
rn 只有 flex 和 absolute 两种布局,主要是用 flex 布局(而且这是 view 默认的布局)
rn 里面写样式都是通过对象属性的方式,设置边框的代码: borderWidth: 1, borderColor: 'red'
2.flex 布局
注意:View 的默认样式是{display: "flex",flexDirection:'column'},所以默认是垂直方向排列的
注意:响应式子元素铺开整个屏幕常用 flex:1
注意:Android 文字默认会有内边距且基于基线对齐,这会导致文字垂直居中时偏下。因此垂直居中时,最好把内边距关掉,并把文字放在中线而不是基线上。
即 includeFontPadding: false, textAlignVertical: 'center',
案例:父容器高度确定,使其子元素 Text 水平垂直方向居中
<View
style={{
alignItems: "center",
justifyContent: "center",
// 高度确定
height: 60,
borderWidth: 1,
}}>
<Text
style={{
fontSize: 18,
// 文字默认内边距,会导致垂直居中偏下
includeFontPadding: false,
// 文字默认基于基线对齐,会导致垂直居中偏下
textAlignVertical: "center",
}}>
我是文字1
</Text>
</View>
注意:文字水平垂直方向居中,除了 Flex 方案,还有行高方案
3.StyleSheet
内联样式就是直接在 JSX 的元素属性中写样式,这样写起来是很方便,但是却把 JSX 的元素结构和样式混在一起了。
此外,内联样式还存在不能复用,性能损耗的问题。
推荐你使用样式表 StyleSheet 来写样式,而不是内联的方式。
// JSX 结构
<View hitSlop={hitSlop} onLayout={handleLayout} style={styles.container}>
<Text style={styles.texts}>我是文字1</Text>
<Text style={styles.texts}>我是文字2</Text>
</View>;
// 样式表:同样是css in js的方式,把样式写成对象结构
const styles = StyleSheet.create({
container: {
alignItems: "center",
justifyContent: "center",
height: 60,
borderWidth: 1,
},
texts: {
fontSize: 18,
includeFontPadding: false,
textAlignVertical: "center",
},
});
注意:业内主流的方案还有带样式的组件 styledComponent 和 样式简写方案 tailwind,它们虽然是源自浏览器的 CSS 管理方案,但也可以在 React Native 中使用。在推特上也有关于样式管理方案的讨论
4.作业
1.请你使用 View、Text、Image 组件实现一个简易版的瀑布流布局,类似于京东、淘宝首页瀑布流列表,不要求能够无限滚动只要能实现左右等宽、不等高的布局即可。
import React from "react";
import { StyleSheet, Text, View, Dimensions, Image } from "react-native";
const img = require("../assets/image/pic.jpg");
export default function Index() {
return (
<View style={[styles.container]}>
<View style={[styles.imageCon]}>
<Image style={[styles.imageBase, styles.img1]} source={img}></Image>
<Image style={[styles.imageBase, styles.img1]} source={img}></Image>
<Image style={[styles.imageBase, styles.img1]} source={img}></Image>
<Image style={[styles.imageBase, styles.img1]} source={img}></Image>
</View>
<View style={[styles.imageCon]}>
<Image style={[styles.imageBase, styles.img2]} source={img}></Image>
<Image style={[styles.imageBase, styles.img2]} source={img}></Image>
<Image style={[styles.imageBase, styles.img2]} source={img}></Image>
</View>
</View>
);
}
const width = Dimensions.get("window").width;
const height = Dimensions.get("window").height;
const styles = StyleSheet.create({
container: {
flexDirection: "row",
},
imageCon: {
width: width / 2,
height: height,
},
imageBase: {
justifyContent: "center",
alignItems: "center",
borderWidth: 1,
borderColor: "red",
// display: 'block',
// marginBottom: 10,
},
img1: {
width: width / 2,
height: height / 4,
},
img2: {
width: width / 2,
height: height / 3,
},
});
2.如果你要给 Text 组件设置全局的默认样式,比如字体,你会怎么设置?
我们不能直接简单的直接使用 react native 自带的 text 组件, 需要对 text 进行封装. 这里需要和 ui 同事沟通好, 定制统一的字体, 字重, 大小的规格. 在自己的 text 组件中自己枚举所有的规格参数. 使用时直接根据 ui 的规格引用自己的规格参数即可.
3.state 状态
让页面“动”起来了,我把这个过程分成了 4 步来实现,状态初选、状态确定、状态声明、状态更新。
1.状态初选
状态初选说的是,先看看页面那些数据是会变化的,这些会变化的数据都可能是状态,我们先把它们找出来。

2.状态确定
状态初选完成后,不能急着写代码,要先确定一下这些初选状态中那些是真正的状态,把其中无用的状态剔除掉,然后再去写代码。这样代码写得少、写得快,代码逻辑也会更简单一些,也更难出 BUG 一些。
(1)一件事情一个状态(重点)
例子:定义请求状态
有些同学写代码的时候,在定义请求状态时,喜欢用布尔值 isLoading 来表示空闲状态或请求中的状态,用 isError 来表示成功状态或失败状态,明明就是网络请求这一件事,却用了两个状态来表示,这就有点多余了,甚至在一些不好测试的边界条件下可能还会留坑。这时其实只需要定义一个状态 ,代码示例如下:
const requestStatus = {
IDLE: "IDLE",
PENDING: "PENDING",
SUCCESS: "SUCCESS",
ERROR: "ERROR",
};
(2)重复状态不是状态
商品组件 ProductRow 中的这个商品数量确实是一个状态,但它却和从网络请求中回来的商品表单状态重复了。从代码层面上,我们确实有办法同时保留两个状态,但这样做就绕弯子了。更好的做法是,把这两个在不同组件之间的重复状态进行合并,去掉底层组件的重复状态,只保留顶层组件中的商品数量作为唯一的状态。
(3)可计算出来的状态不是状态
一个状态必须不能通过其他状态、属性或变量直接计算出来,能通过其他值计算出来的状态,都不是状态。比如,在购物车页面中,结算总价这个动态数据,是可以通过对所有商品的单价和数量的积进行求和得出来的,所以它不是状态。
所以,初选状态一共 5 个,最终确定下来就只剩下网络请求状态和商品列表这两个状态了。
3.状态声明
在定义状态的时候,一定要先考虑好把状态绑定到哪个组件上。我建议你用就近原则来绑定状态,就近原则的意思是哪个组件用上了状态,就优先考虑将状态绑定到该组件上,如果有多个组件使用了同一个状态,则将其绑定到最近的父组件上。这样做能让使用 props 传递状态的次数最少。
注意:useState 和普通函数不同,你不能把钩子函数写在 if 条件判断中、事件循环中、嵌套的函数中,这些都会导致报错。钩子函数类似于 JavaScript 的 import ,你最好在函数组件的顶部使用它们。
如果在 if 中使用了任何的钩子函数,就会报错:
import React, { useState, useEffect } from "react";
// 错误
export default function ProductTable() {
const [requestStatus, setRequestStatus] = useState("IDLE");
// ...
if (requestStatus === "ERROR") return <Text>网络出错了</Text>;
// 在 else 分支中,使用任何 use 开头的钩子函数,都会报错
const [products, setProducts] = useState([]);
useEffect(() => {});
return <Text>购物车页面</Text>;
}
在这个错误示例中,我们先使用了 if(requestStatus === 'ERROR') 判断了网络请求状态。如果请求失败,则提示用户“网络出错了”,否则就返回真正的购物车页面。但 if return 后面的代码,就相当于 else 分支,在分支中使用了钩子函数,比如 useState、useEffect,代码就会报错。
注意:你应该把 use 开头的钩子函数都写在组件的顶部,把 JSX 都写在函数组件的最后面,并使用 eslint-plugin-react-hooks 插件来保障 Hook 规则的会被正确执行。
4.状态更新
1.对于原始数据类型而言,调用 setCount 更新原始数据类型状态的值,页面就会发生更新
2.对象它是一种复合数据类型,它内部的值是可变的(mutable),但它的引用是不可变了(immutable),你更新了对象的内部值后,它的引用并没有发生变化。
可以用直接新建对象、新建数组的方式,代码如下:
setCountObject({ ...countObject, num: countObject.num + 1 });
const newCountArray = [...newCountArray];
newCountArray[0]++;
setCountArray(newCountArray);
你可以看到,对于对象状态的更新我是这么处理的,我先创建了一个新对象{},然后用...的解构的方式将老对象 countObject 的内部值重新赋值给了新对象{},再指定 num 属性进行了复写。对于数组状态的更新也是类似的,你可以自己试试。
5.总结
行军作战是兵马未动粮草先行,讲究的是谋而后动。搭建页面、开发组件也是如此,我们也要代码未动构思先行,先把组件状态设计好了,简单即美,要是没想清楚弄复杂了,后面填坑成本会很高。
6.作业
1.作业请你实现一个井字棋。井字棋的规则和五子棋类似,两人在 3 * 3 格子上进行连珠游戏,任意 3 个标记形成一条直线,则为获胜。在写之前,推荐你先玩一下这个井字棋,了解一下井字棋的最终效果。
2.请你思考一下实现一个井字棋,最少需要声明几个状态?
7.案例

购物车页面,商品加减条:
import React from "react";
import { View, Text, Button, StyleSheet } from "react-native";
export default function ProductRow({
product,
handleIncrement,
handleDecrement,
}) {
return (
<View
style={{
flexDirection: "row",
alignItems: "center",
marginTop: 20,
borderBottomWidth: StyleSheet.hairlineWidth,
}}>
<Text style={{ flex: 1 }}>{product.name}</Text>
<Text style={{ flex: 1 }}>{product.price}</Text>
<View
style={{
alignSelf: "flex-end",
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
}}>
<Button
hitSlop={10}
title='+'
onPress={() => handleIncrement(product)}
/>
<Text>{product.count}</Text>
<Button
hitSlop={10}
title='-'
onPress={() => handleDecrement(product)}
/>
</View>
</View>
);
}
button 的点击事件为 onPress
4.Image 图片组件
图片组件很重要,但要用好却不那么容易。
React Native 的 Image 组件一共支持 4 种加载图片的方法:
1、静态图片资源;
2、网络图片;
3、宿主应用图片;
4、Base64 图片。
深度剖析这 4 种方案分别的适用场景是什么,并给你介绍一下我推荐的最佳实践
1.静态图片资源
如果图片每次都不会变化,那么你就可以把这张图片作为静态图片资源,内置在 App 中。
这样,用户在打开你的 App 时,图片是从本地直接读取的,直接读取图片的速度比走网络请求先下载再加载的速度要快上很多。一张网络图片从下载到展示的耗时通常需要 100ms 以上,而一张内置图片从读取到展示的耗时通常只有几 ms,甚至更低,二者耗时相差了两个数量级。
在一些高性能场景下,你应该选择把这些不经常变动的静态图片资源内置到 App 中。当用户打开 App 时,这些图片就能够立刻展示出来了。
我们需要注意的是,require 函数的入参必须是字面常量,而不能是变量。你可以看下这段代码:
// 方案一:正确
const dianxinIcon = require('./dianxin.jpg')
<Image source={dianxinIcon}/>
// 方案二:错误
const path = './dianxin.jpg'
const dianxinIcon = require(path)
<Image source={dianxinIcon}/>
静态图片资源的加载原理
1.在使用 require 函数引入静态图片资源时,图片的相对路径必须用字面常量表示的原因是,字面常量'./dianxin.jpg'提供的是一个直接的明确的图片相对路径,打包工具很容易根据字面常量'./dianxin.jpg' 找到真正的图片,提取图片信息。而变量 path 提供的是一个间接的可变化的图片路径,你光看 require(path) 这段代码是不知道真正的图片放在哪的,打包工具也一样,更别提自动提取图片信息了。
2.编译后的 Bundle 和静态图片资源,会在构建时内置到 App 中。
3.运行时”,拿到这些图片信息,并加载和展示真正的内置图片
正是因为静态图片资源加载方式,它在“编译时”提前获取了图片宽高等信息,在“构建时”内置了静态图片资源,因此在“运行时”,程序可以提前获取图片宽高和真正的图片资源。相对于我们后面要介绍的网络图片等加载方式,使用静态图片资源加载,即使不设置图片宽高,也有一个默认宽高来进行展示,而且加载速度更快。
2.网络图片
在使用网络图片时,我建议你将宽高属性作为一个必填项来处理。为什么呢?和前面介绍的静态图片资源不同的是,网络图片下载下来之前,React Native 是没法知道图片的宽高的,所以它只能用默认的 0 作为宽高。这个时候,如果你没有填写宽高属性,初始化默认宽高是 0,网络图片就展示不了。
// 建议
<Image source={{uri: 'https://reactjs.org/logo-og.png'}}
style={{width: 400, height: 400}} />
// 不建议
<Image source={{uri: 'https://reactjs.org/logo-og.png'}} />
缓存与预加载
Android 和 iOS 的缓存设置方式和实现原理虽然有所不同,但整体上采用了内存和磁盘的综合缓存机制。第一次访问时,网络图片是先加载到内存中,然后再落盘存在磁盘中的。后续如果我们需要再次访问,图片就会从缓存中直接加载,除非超出了最大缓存的大小限制。
在无限滚动的长列表场景中,图片预加载就非常适合了。
React Native 也提供了非常方便的图片预加载接口:
Image.prefetch:Image.prefetch(url);
也就是说,函数 Image.prefetch 接收一个参数 url,也就是图片的远程地址,函数调用后,React Native 会帮你在后台进行下载和缓存图片。这样,你下拉加载的图片时,网络图片是从本地缓存中加载的,就感受不到网络加载的耗时过程了。
3.宿主应用图片(不推荐)
React Native 使用 Android/iOS 宿主应用的图片进行加载的方式。在 React Native 和 Android/iOS 混合应用中,也就是一部分是原生代码开发,一部分是 React Native 代码开发的情况下,你可能会用到这种加载方式
使用 Android drawable 或 iOS asset 文件目录中的图片资源时,我们可以直接通过统一资源名称 URN(Uniform Resource Name)进行加载。不过,使用 Android asset 文件目录中图片资源时,我们需要在指定它的统一资源定位符 URL(Uniform Resource Locator)。
在 React Native 中,我们为什么要用 URI ,比如 { uri: 'app_icon' } ,来代表图片,而不是用更常用的 URL,比如 { url: 'app_icon' } , 代表图片呢?
URI 代表的含义更广泛,它既包括 URN 这种用名称代表图片的方式,也包括用 URL 这种地址代表图片的方式
在我们国内,绝大多数的 React Native 应用都是混合应用,都是把 React Native 当做一个支持动态更新的跨端框架来使用的。那这种情况下,我们在 React Native 中直接用宿主应用图片资源不是更好吗?你看,React Native 静态图片资源也是内置,Android/iOS 自身图片也要内置,搞一套图片管理机制不更简单一些嘛?而且部分图片还可以跨 React Native 和 Android/iOS 两个技术栈复用,减少一些 App 体积,这听起来很不错啊。
但在实际工作中,我不推荐你在 React Native 中使用宿主应用图片资源。首先,这种加载图片的方法没有任何的安全检查,一不小心就容易引起线上报错。第二,大多数 React Native 是动态更新的,最新代码是跨多个版本运行的,而 Native 应用是发版更新的,应用的最新代码只在最新版本运行,这就导致 React Native 需要确切知道 Native 图片到底内置在哪些版本中,才能安全地使用,这对图片管理要求太高了,实现起来太麻烦了。最后,开发 React Native 的团队,和开发 Android/iOS 的团队很可能不是一个团队,甚至可能跨部门。复用的收益抵不上复用带来的安全风险、维护成本和沟通成本,因此我并不推荐你使用。
// Android drawable 文件目录
// iOS asset 文件目录
<Image source={{ uri: 'app_icon' }} />
// Android asset 文件目录
<Image source={{ uri: 'asset:/app_icon.png' }} />
4.Base64 图片
Base64 指的是一种基于 64 个可见字符表示二进制数据的方式,Base64 图片指的是使用 Base64 编码加载图片的方法,它适用于那些图片体积小的场景。
例子:
<Image
source={{
uri: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADMAAAAzCAYAAAA6oTAqAAAAEXRFWHRTb2Z0d2FyZQBwbmdjcnVzaEB1SfMAAABQSURBVGje7dSxCQBACARB+2/ab8BEeQNhFi6WSYzYLYudDQYGBgYGBgYGBgYGBgYGBgZmcvDqYGBgmhivGQYGBgYGBgYGBgYGBgYGBgbmQw+P/eMrC5UTVAAAAABJRU5ErkJggg==",
}}
/>
你可以看到 Base64 图片并不是图片地址,而是以一大长串的以 data:image/png; base64 开头的文本。
通常我们看的图片资源 .jpg、.png 都是二进制格式的,二进制格式的图片是以独立文件存在的。而 Base64 图片并不是单独图片文件,而是以文本形式存在 .js 文件中的。字符串是可以嵌套到 .js 文件中的,因此 Base64 图片也可以嵌入到 .js 文件中
在线上,Base64 图片是嵌套在 Bundle 文件中的,在加载 React Native 页面的同时,Base64 字符串也能很快地解析成真正的图片,并展示出来。由于 Base64 图片是嵌套在 Bundle 文件中的,所以 Base64 图片的优点是无需额外的网络请求展示快,缺点是它会增大 Bundle 的体积。即便是相同的图片,Base64 字符串的体积也要比二进制字节码的体积要大 1/3,这又进一步增加 Bundle 的大小
在动态更新的 React Native 应用中,Base64 图片展示快是以 React Native 页面整体加载慢为代价的。原因就是它会增加 Bundle 的体积,增加 Bundle 的下载耗时,从而导致 React Native 页面展示变慢。
鉴于这样的情况,我的建议是 Base64 图片只适合用在体积小的图片或关键的图片上。
5.最佳实践
你可以把需要上传到网络的图片放在代码仓库的 assets/network 目录,把需要 Base64 化的图片放在 assets/base64 目录。
你在本地开发的时候,可以通过使用 require 静态图片资源的形式,引入 assets/network 或 assets/base64 目录中的图片来进行本地调试。在代码编译打包的时候,通过工具将 assets/network 目录中的图片上传到 CDN 上,将 assets/base64 目录中的图片都 Base64 化,并将 require 形式的静态图片资源代码转换为网络图片或 Base64 图片的代码。使用自动化工具来管理图片,代替人工手动管理,可以提高你的开发效率。
最后是宿主应用图片,这种加载图片的方式我不建议你使用,具体的原因我们前面已经分析过了。
5.Pressable 点按组件
作为直接和用户打交道的工程师,我们也得“懂”用户,也得去优化我们负责的 App、页面的体验,还得在技术上搞懂点按组件使用方法和背后的原理,把这种最常用的人机交互体验给做到及格,做到优秀。
1.要简单易用还是功能丰富?
实际上,React Native 的点按组件经历了三个版本的迭代,才找到了两全其美的答案。
等你了解了这个三个版本的迭代思路后,你就能很好明白优秀通用组件应该如何设计,才能同时在用户体验 UX 和开发者体验 DX 上找到平衡。
1、第一代 Touchable 组件,第一代点按组件想要解决的核心问题是,提过多种反馈风格。
2、第二代 Button 组件,第二代 Button 组件的实质是对 Touchable 组件的封装。Button 组件的设计思想就是,别让开发者纠结选啥组件了,框架已经选好了,点按反馈的样式就和原生平台的自身风格保持统一就好了。
3、第三代 Pressable 组件,第三代 Pressable 点按组件,不再是 Touchable 组件的封装,而是一个全新重构的点按组件,它的反馈效果可由开发者自行配置。
Pressable 组件的 API 设计得很是巧妙,扩展起来非常方便。Pressable 的样式 style 属性同时支持固定样式,和函数返回的“动态样式”:
type PressableStyle = ViewStyle | (({ pressed: boolean }) => ViewStyle);
其一,固定样式,也就是 type PressableStyle = ViewStyle 的意思是,Pressable 组件的支持样式类型和 View 组件的支持样式类型是一样的
其二,动态样式,也就是 type PressableStyle = (({ pressed: boolean }) => ViewStyle) 的意思是,在用户没有点击时 pressed 值为 false,在用户点击时 pressed 值为 true,你可以根据两种点按状态,为按钮定制不同的样式。
具体怎么实现呢?
1.如果实现静态样式
符合 type PressableStyle = ViewStyle
// 固定的基础样式
const baseStyle = { width: 50, height: 50, backgroundColor: 'red'}
<Pressable
onPress={handlePress}
style={baseStyle} >
<Text>按钮</Text>
</Pressable>
2.如果要实现动态样式
符合 type PressableStyle = (({ pressed: boolean }) => ViewStyle)
ViewStyle 在这里是多个样式对象组成的数组,这是允许的!
// 固定的基础样式
const baseStyle = { width: 50, height: 50, backgroundColor: 'red'}
<Pressable
onPress={handlePress}
style={({ pressed }) => [ /* 动态样式 */
baseStyle,
{ opacity: pressed ? 0.5 : 1}
]} >
<Text>按钮</Text>
</Pressable>
首次渲染时,React Native 会先调用一次 Pressable 的 style 属性的回调函数,这时点按状态 pressed 是 false,透明度为 1。在你触碰到“按钮”时,就会触发点击事件 onPress,与此同时,React Native 会再调用一次 style 属性的回调函数,此时点按状态 pressed 是 true,透明度为 0.5。在你松开“按钮”后,透明度会重新变为 1。
除了改变透明度,你还可以选择改变背景色,改变按钮的宽高,甚至还可以把“按钮”的文字改了。
进行选择:
第一代点按组件 Touchable,功能丰富但学习成本太高;
第二代点按组件 Button,简单易用但带了默认样式和反馈效果,通用性太差;
第三代点按组件 Pressable,同时满足了简单易用和复杂效果可扩展的特性。
注意:==在实现自定义的业务按钮组件时,我更加推荐你使用第三代点按组件 Pressable==。而且,Pressable 组件的动态 style 的设计思路,也是非常值得我们学习的。
2.如何知道是点击,还是长按?
Pressable 组件响应的整体流程,是从触摸屏识别物理手势开始,到系统和框架 Native 部分把物理手势转换为 JavaScript 手势事件,再到框架 JavaScript 部分确定响应手势的组件,最后到 Pressable 组件确定是点击还是长按。
开始响应事件和结束响应事件是两个最基础的手势事件,在 Android、iOS 或者 Web 中都有类似的事件。在 React Native 中它们是:
onResponderGrant:开始响应事件,用户手指接触屏幕,且该手势被当前组件锁定后触发;
onResponderRelease:结束响应事件,用户手指离开屏幕时触发。
基于开始响应事件 onResponderGrant 和结束响应事件 onResponderRelease,Pressable 组件可以很容易地封装出开始点按事件 onPressIn 和结束点按事件 onPressOut。
<Pressable onPressIn={handlePressIn} onPressOut={handlePressOut}>
<Text>按钮</Text>
</Pressable>
基于开始点按事件 onPressIn 和结束点按事件 onPressOut,我们是否可以封装出“自定义”的点击事件 onPress 和长按事件 onLongPress 呢?——> 实际上官方已经提供了
在你同时监听了 onPress 和 onLongPress 两个事件时,如果点按耗时小于 500ms,在你松手时触发的是点击事件 onPress;如果点按耗时大于 500ms,大致会在第 500ms 先触发长按事件 onLongPress,那这时即使你再松手也不会触发 onPress 事件了。也就是说,点击事件 onPress 和长按事件 onLongPress 是互斥的,触发了一个就不会再触发另一个了。
4 个响应事件,onPressIn、onPressOut 、onPress 和 onLongPress 的触发方式:
3.为什么支持中途取消?
点按组件为什么还要支持用户中途取消点击?
要讲清楚这个问题,我们需要深入到事件区域模型,也就是点按操作手势的可用范围的概念下进行讲解。
点按操作手势的可用范围包括盒模型区域、可触发区域 HitRect 和可保留区域 PressRect ,接下来我们一个个讲解。
1.盒模型区域
其实,React Native 中的盒模型概念来自于 Web 领域的 W3C 规范,我把规范中的盒模型示意图放在了下面:
点按事件的默认触发区域是盒模型中的哪几部分?答案就是,盒模型中的默认不透明的部分。这些用户看得见的部分,包括 content、padding 和 border 部分。
人的手指并不是什么精密仪器,不能保证任何情况下都能正确地点按到指定区域。那这种情况该怎么处理呢?我们可以直接修改宽高、边框、内边距的值,通过扩大盒模型的范围,提高点中的成功率。但是,修改盒模型成本较高,它可能会导致原有 UI 布局发生变化。
更好的方案是,不修改影响布局的盒模型,直接修改可触发区域的范围,提高点中的成功率。
2.可触发区域 HitRect
默认情况下,可触发区域 HitRect 就是盒模型中的不透明的可见区域。你可以通过修改 hitSlop 的值,直接扩大可触发区域。
type Rect = {
top?: number;
bottom?: number;
left?: number;
right?: number;
};
type HitSlop = Rect | number;
HitSlop 接收两种类型的参数,一种是 number 类型,以原有盒模型中的 border 为边界,将可触发区域向外扩大一段距离。另一种是 Rect 类型,你可以更加精准地定义,要扩大的上下左右的距离。在老点不中、老勾不中的场景中,你可以在不改变布局的前提下,设置 Pressable 组件的可触发区域 HitSlop,让可点击区域多个 10 像素、20 像素,让用户的更容易点中。
3.可保留区域 PressRect
用户的行为本身就很复杂,用户的意愿也可能会在很短的时间内发生改变的
比如,用户已经点到购买按钮了,突然犹豫,又不想买了,于是将手指从按钮区域移开了。这时你得让用户能够反悔,能够取消即将触发的点击操作。
点按事件可保留区域的偏移量(Press Retention Offset)默认是 0,也就是说默认情况下可见区域就是可保留区域。你可以通过设置 pressRetentionOffset 属性,来扩大可保留区域 PressRect。
pressRetentionOffset 和 HitSlop 一样,接收两种类型的参数,一种是 number 类型,另一种是 Rect 类型。
注意:点击事件要不要触发,其实是根据你手指松开的位置来判断的,如果你松手的位置在可保留区域内那就要触发,如果不是那就不触发。
我将盒模型区域的可见区域、可触发区域 HitRect 和可保留区域 PressRect 的关系画了一张图,你也可以打开文稿看看,加深一下理解:

6.TextInput 输入组件
TextInput 组件是自带状态的宿主组件。TextInput 输入框中的文字状态、光标状态、焦点状态在 React Native 的 JavaScript 框架层的框架层有一份,在 Native 的还有一份,有时候业务代码中还有一份。
这一讲,我将以如何实现一个体验好的输入框为线索,和你介绍使用 TextInput 组件应该知道的三件事。
1.输入框的文字
关于如何处理输入框的文字,网上有两种说法。有些人倾向于使用非受控组件来处理,他们认为“不应该使用 useState 去控制 TextInput 的文字状态”,因为 ref 方案更加简单;有些人倾向于使用受控组件来处理,这些人认为“直接使用 ref 去操作宿主组件这太黑科技了”。这两种说法是相互矛盾的,究竟哪种是正确的呢?
1.非受控组件
ref 的值不会因为组件刷新而重新声明,它是专门用来存储组件级别的信息的。
我们使用 ref 保存非受控输入框的值,示例代码如下:
function UncontrolledTextInput2() {
const textRef = React.useRef("");
return <TextInput onChangeText={(text) => (textRef.current = text)} />;
}
你看,首先我们使用 useRef 创建了一个用于保存用户输入的文字的对象 textRef。每当用户输入文字的时候,会触发 TextInput 的 onChangeText 事件,在该事件的回调中,我们将最新的 text 赋值给了 textRef.current 进行保存。这时,每次获取文字就都是最新的文字了。
非受控组件的原理是最简单的,用户输入的“文本原件”是存在宿主组件上的,JavaScript 中的只是用 textRef 复制了一份 “文本的副本”而已。
但正是因为非受控组件使用的是副本,一些复杂的操作是做不了的,比如将用户输入的字母由大写强制改为小写,等等
2.受控组件
因此我们要操作文本原件,必须得用受控(Controlled)组件。受控的意思说的是使用 JavaScript 中的 state 去控制宿主组件中的值。一个受控的 ControlledTextInput 组件示例如下:
function ControlledTextInput() {
const [text, setText] = React.useState("");
return <TextInput value={text} onChangeText={setText} />;
}
所以对于受控组件来说,输入框的文字始终是由 state 驱动的。
现在如果要我给个处理输入框的文本建议,那我的建议就是使用受控组件,并且使用异步的文字改变事件,这也符合大部分人的代码习惯。
2.输入框的焦点
有些场景下,是需要代码介入控制焦点的。比如你购物搜索商品,从首页跳到搜索页时,搜索页的焦点就是用代码控制的。或者你在填写收货地址时,为了让你少点几次输入框,当你按下键盘的下一项按钮时,焦点就会从当前输入框自动转移到下一个输入框。我们先来看怎么实现自动“对焦”,以搜索页的搜索输入框自动对焦为例,示例代码如下:
<TextInput autoFocus />
TextInput 的 autoFocus 属性,就是用于控制自动对焦用的,其默认值是 false。也就是说,所有的 TextInput 元素默认都不会自动的对焦,而我们将 TextInput 的 autoFocus 属性设置为 true 时,框架会在 TextInput 元素挂载后,自动帮我们进行对焦。搜索页面只有一个搜索框的场景下 ,autoFocus 是好用的。但当一个页面有多个输入框时,autoFocus 就没法实现焦点的转移了。
例子 1:聚焦焦点到一个 input 上
那怎么下命令呢?我们先从最简单的控制 TextInput 焦点讲起,示例代码如下:
function AutoNextFocusTextInputs() {
const ref1 = React.useRef < TextInput > null;
useEffect(() => {
ref1.current?.focus();
}, []);
return <TextInput ref={ref1} />;
}
在这段代码中,先声明了一个 ref1 用于保存 TextInput 宿主组件。在该宿主组件上封装了 Native/C++ 层暴露给 JavaScript 的命令,比如对焦 focus()、失焦 blur()、控制选中文字的光标 setSelection。
AutoNextFocusTextInputs 组件在挂载完成后,程序会调用 ref1.current.focus(),将焦点对到 TextInput 元素上,这就是使用 focus()实现对焦的原理。使用 focus()命令对焦和使用 autoFocus 属性对焦,在原生应用层面的实现原理是一样的,只不过在 JavaScript 层面,前者是命令式的,后者是声明式的。对自带状态的宿主组件而言,命令式的方法能够进行更复杂的操作。
例子 2:要实现每点一次键盘右下角的“下一项”按钮,将焦点对到下一个 TextInput 元素上,怎么实现呢?
function AutoNextFocusTextInputs() {
const ref1 = React.useRef < TextInput > null;
const ref2 = React.useRef < TextInput > null;
const ref3 = React.useRef < TextInput > null;
return (
<>
<TextInput ref={ref1} onSubmitEditing={ref2.current?.focus} /> //
姓名输入框
<TextInput ref={ref2} onSubmitEditing={ref3.current?.focus} /> // 电话输入框
<TextInput ref={ref3} /> // 地址输入框
</>
);
}
在真实的项目中,这三个输入框往往不是封装成同一个组件中的,姓名输入框、电话输入框、地址输入框每个都是一个独立的组件,然后再有一个大的复合组件将它们组合在一起的。那么这时,如何获取到 TextInput 元素 ref 呢?
需要用到 React.forwardRef 转发 ref!
简单理解就是在父组件里面获取子组件的某个标签的 dom 元素!
Ref 转发是一个可选特性,其允许某些组件接收 ref
,并将其向下传递(换句话说,“转发”它)给子组件。
在下面的示例中,FancyButton
使用 React.forwardRef
来获取传递给它的 ref
,然后转发到它渲染的 DOM button
:
const FancyButton = React.forwardRef((props, ref) => (
<button ref={ref} className='FancyButton'>
{" "}
{props.children}
</button>
));
// 你可以直接获取 DOM button 的 ref:
const ref = React.createRef();
<FancyButton ref={ref}>Click me!</FancyButton>;
这样,使用 FancyButton
的组件可以获取底层 DOM 节点 button
的 ref ,并在必要时访问,就像其直接使用 DOM button
一样。
3.联动键盘的体验
输入框和键盘是联动的,键盘的很多属性都可以用 TextInput 组件来设置。因此,除了输入框的值、输入框的焦点,我们还需要关心如何控制键盘
简单理解,就是输入框的值会影响键盘的状态,这是我们日常使用不太关注的,因为电脑不涉及这些问题!
例子:
1.键盘右下角按钮颜色可变
iOS 微信搜索框的键盘右下角按钮有一个“置灰置蓝”的功能。默认情况下,键盘右下角的按钮显示的是置灰的“搜索”二字,当你在搜索框输入文字后,置灰的“搜索”按钮会变成蓝色背景的“搜索”二字。置灰的作用是提示用户,没有输入文字不能进行搜索,按钮变蓝提示的是有内容了,可以搜索了。控制键盘右下角按钮置灰置蓝的,是 TextInput 的 enablesReturnKeyAutomatically 属性,这个属性是 iOS 独有的属性,默认是 false,也就是任何使用键盘右下角的按钮,都可以点击。你也可以通过将其设置为 true,使其在输入框中没有文字时置灰。

2.键盘右下角按钮的文案是可以变化的
你可以根据不同的业务场景进行设置。有两个属性可以设置这些文案,包括 iOS/Android 通用的 returnKeyType 和 Android 独有的 returnKeyLabel。全部的属性你可以查一下文档,我这里只说一下通用属性:

3.登录页面的自动填写账号密码功能
无论是 iOS 还是 Android,它们都有系统层面的记住账号密码的功能,帮助用户快速完成账号密码的填写。完成快速填写功能的 TextInput 属性,在 iOS 上叫做 textContentType,在 Android 上叫做 autoComplete。
你可以将账号输入框的快速填写属性设置为 username,将密码输入框的快速填写属性设置为 password,帮助用户节约一些时间,提高一下整体的成功率。除此之外,一些姓名、电话、地址信息也可以快速填写。

4.还有一些键盘的体验细节,比如 keyboardType 可以控制键盘类型
可以让用户更方便地输入电话号码 phone-pad、邮箱地址 email-address 等等

改进焦点转换的案例
可以实时地根据输入状态改变键盘的效果
function AutoNextFocusTextInputs() {
const ref1 = React.useRef < TextInput > null;
const ref2 = React.useRef < TextInput > null;
const ref3 = React.useRef < TextInput > null;
return (
<>
<TextInput
ref={ref1}
placeholder='姓名'
textContentType='name'
returnKeyType='next'
onSubmitEditing={ref2.current?.focus}
/>
<TextInput
ref={ref2}
placeholder='电话'
keyboardType='phone-pad'
returnKeyType='done'
onSubmitEditing={ref3.current?.focus}
/>
<TextInput ref={ref3} placeholder='地址' returnKeyType='done' />
</>
);
}
4.总结
这一讲,我们还是围绕着交互体验这个角度来讲组件,从交互体验这个角度看 TextInput 组件,我们需要注意三件事:
1.学会处理输入框的文字。有两种处理方式受控组件和非受控组件,受控组件更强大一些,也更符合大多数 React/React Native 开发者的习惯;学会处理输入框的焦点。
2.处理焦点有两种方式:一种是声明式的 autoFocus 属性,另一种是命令式的 ref.current.focus()方法,前者适用场景有限,后者适用场景更多;
3.学会处理与输入框联动的键盘,包括键盘右下角的按钮、键盘提示文案、键盘类型等等。
日常工作中,用到 TextInput 输入框的场景非常多,有聊天框、搜索框、信息表单等等,相信学完这一讲后,你能更好地处理 TextInput 体验细节。
5.作业
1.iOS 模拟器上,点击 TextInput 元素并没有键盘弹窗,必须使用真机进行测试。iOS 如何在真机上进行打包请参考《iOS 个人证书真机调试及报错》。https://www.jianshu.com/p/f31116a76ea9
其实 iOS 模拟器也是可以弹出键盘的,只要把 Hardware -> Keyboard -> Connect HardWare KeyBoard 的勾去掉就可以了。
2.请你实现一个如图所示的用于填写验证码的输入框组件
方法一:验证码组件我的思路是这样的:
放置一个隐藏的 TextInput
画短信验证码输入的 UI (其实就是几个格子)
点击验证码 UI 的时候调用 textInptRef.focus(),将焦点聚集在隐藏的 textInput 上面!
接受输入,划到对应的验证码格子里
完整代码: https://gist.github.com/AEPKILL/3557c4b5b621a3aec36e7e3cd8571e56
方法二:直接使用显示的 TextInput + letterSpacing 控制字间距。
3.请你思考一下 TextInput 的异步 onChange 和同步 onChangeSync 的区别是什么?Fabric 的同步特性将给 React Native 带来什么变化?
TextInput 有两个更新事件,分别是 onChangeText 的异步更新,和新架构提供的 unstable_onChangeSync 同步更新。 在 React Native 中,TextInput 是一个常用的组件,用于接收用户输入的文本。在讨论异步 onChange 和同步 onChangeSync 的区别之前,让我们先了解一下这两种类型的 onChange 方法:
- 异步 onChange:**通常指的是在用户输入文本时触发的事件处理函数,这种函数不会立即更新组件的状态或执行其他可能影响性能的操作。**相反,它会在一定的延迟之后执行,以减少频繁的状态更新或重复的计算。
- 同步 onChangeSync:与异步 onChange 不同,同步 onChangeSync 是在用户每次输入文本时立即触发的事件处理函数。这意味着每次用户输入字符时,都会立即执行事件处理函数。
区别:
- 响应性能:异步 onChange 更适合在用户输入频繁的情况下,以及需要进行复杂计算或涉及性能开销的操作。因为它不会立即更新组件状态或执行操作,从而减少了不必要的重复计算和渲染。
- 实时性:同步 onChangeSync 提供了更实时的反馈,因为它会在每次用户输入时立即触发。这在需要实时验证输入或提供实时反馈的情况下很有用。
关于 Fabric 的同步特性给 React Native 带来的变化:
Fabric 是 React Native 中的新架构,旨在提高性能、稳定性和可维护性。其中一个重要的变化是引入了同步渲染的能力。这意味着 React Native 应用程序可以在每一帧中同步地处理用户输入、计算布局和执行渲染,而不再依赖于原来的异步渲染模型。
这种同步渲染的特性给 React Native 应用程序带来了以下变化:
- 更快的响应速度:由于可以立即处理用户输入并同步地更新界面,因此应用程序的响应速度会显著提高。
- 更可靠的用户体验:同步渲染可以减少界面元素之间的不一致性和闪烁,从而提供更稳定、更可靠的用户体验。
- 更简单的调试和优化:同步渲染使得调试和优化变得更加直观和简单,开发者可以更容易地追踪到渲染过程中的性能瓶颈并进行优化。
总的来说,Fabric 的同步特性为 React Native 带来了更好的性能、稳定性和开发体验,使得开发者能够更轻松地构建高质量的移动应用程序。
7.List 列表组件
React Native 官方提供的列表组件确实是 FlatList,但是我推荐你优先使用开源社区提供的列表组件 RecyclerListView。因为,开源社区提供的 RecyclerListView 性能更好。
那么,为什么开源社区的 RecyclerListView 比官方的 FlatList 性能更好?FlatList、RecyclerListView 的优化原理是什么?FlatList 和 RecyclerListView 的底层实现都是滚动组件 ScrollView,所以我们先从 ScrollView 聊起。
1.ScrollView
一般而言,我们会使用安全区域组件 SafeAreaView 组件作为 ScrollView 的父组件,并给 SafeAreaView 组件设置布局属性 flex:1,让内容自动撑高 SafeAreaView。使用 SafeAreaView 作为最外层组件的好处是,它可以帮我们适配 iPhone 的刘海屏,节约我们的适配成本,示例代码如下:
<SafeAreaView style={{flex: 1}}>
<ScrollView>
<Text>1</Text>
<ScrollView/>
</SafeAreaView>
使用 ScrollView 来实现无限列表:
// 10 个 item 就能填满整个屏幕,渲染很快
// 1000 个 item 相当于 100+ 个屏幕的高度,渲染很慢
const NUM_ITEMS = 1000;
const makeContent = (nItems: number, styles: any) => {
return Array(nItems)
.fill(1)
.map((_, i) => (
<Pressable key={i} style={styles}>
<Text>{"Item " + i}</Text>
</Pressable>
));
};
const App = () => {
return (
<SafeAreaView style={{ flex: 1 }}>
<ScrollView>{makeContent(NUM_ITEMS, styles.itemWrapper)}</ScrollView>
</SafeAreaView>
);
};
使用 ScrollView 组件时,ScrollView 的所有内容都会在首次刷新时进行渲染。内容很少的情况下当然无所谓,内容多起来了,速度也就慢下来了。
那有什么优化方案吗?你肯定想到了一些优化方案,比如按需渲染(虚拟列表)。
2.FlatList
FlatList 列表组件就是 “自动”按需渲染的。
FlatList 是 React Native 官方提供的第二代列表组件。FlatList 组件底层使用的是虚拟列表 VirtualizedList,VirtualizedList 底层组件使用的是 ScrollView 组件。因此 VirtualizedList 和 ScrollView 组件中的大部分属性,FlatList 组件也可以使用。
简单地讲,FlatList 性能比 ScrollView 好的原因是,FlatList 列表组件利用按需渲染机制减少了首次渲染的视图,利用空视图的占位机制回收了原有视图的内存。
实现 FlatList 自动按需渲染的思路具体可以分为三步:
1.通过滚动事件的回调参数,计算需要按需渲染的区域;
在 onScroll 事件中,我们可以获取到当前滚动的偏移量 offset 等信息。以当前滚动的偏移量为基础,默认向上数 10 个屏幕的高度,向下数 10 个屏幕的高度,这一共 21 个屏幕的内容就是需要按需渲染的区域,其他区域都是无需渲染的区域。
2.通过需要按需渲染的区域,计算需要按需渲染的列表项索引;
注意:如果设计师给的列表项的高度是确定的,那么我们在写代码的时候,就可以通过获取列表项布局属性 getItemLayout 告诉 FlastList,因为在列表项高度确定,且知道按需渲染区域的情况下,“求按需渲染列表项的索引”就是一个简单的四则运算的问题
如果设计师给的 UI 稿中是不定高的列表项,也就是高度是由渲染内容决定的。你就没有办法在写代码的时候把列表项的高度告诉 FlastList 了,那么 FlastList 就要先把列表项渲染出来才能获取高度。对于高度未知的情况,FlastList 会启用列表项的布局回调函数 onLayout,在 onLayout 中会有大量的动态测量高度的计算,包括每个列表项的准确高度和整体的平均高度。
但是,实际生产中,如果你不填 getItemLayout 属性,不把列表项的高度提前告诉 FlastList,让 FlastList 通过 onLayout 的布局回调动态计算,用户是可以感觉到滑动变卡的。因此,如果你使用 FlastList,又提前知道列表项的高度,我建议你把 getItemLayout 属性填上。
3.只渲染需要按需渲染列表项,不需要渲染的列表项用空视图代替。
这个过程是顺滑的,列表项是一个个渲染的
3.RecyclerListView
RecyclerListView 是可复用的列表组件
RecyclerListView 是开源社区提供的列表组件,它的底层实现和 FlatList 一样也是 ScrollView,它也要求开发者必须将内容整体分割成一个个列表项。
在 Android 上,动态列表 RecyclerView 在列表项视图滚出屏幕时,不会将其销毁,相反会把滚动到屏幕外的元素,复用到滚动到屏幕内的新的列表项上。这种复用方法可以显著提高性能,改善应用响应能力,并降低功耗。
如果你只开发过 Web,你可以这样理解复用:原来你要销毁一个浏览器中 DOM,再重新创建一个新的 DOM,现在你只改变了原有 DOM 的属性,并把原有的 DOM 挪到新的位置上。
简而言之,RecyclerListView 在滚动时复用了列表项,而不是创建新的列表项,因此性能好。
从使用方式看底层原理
RecyclerListView 有三个必填参数:
列表数据:dataProvider(dp);
dp 是列表数据类 DataProvider new 出来的对象,它是一个存放 listData 的数据容器。它有一个必填参数,就是对比函数。在列表项复用时,对比函数会频繁地调用,因此我们只推荐对更新数据进行 r1 !== r2 的浅对比,不推荐深对比。
jsxconst listData = Array(300) .fill(1) .map((_, i) => i); const dp = new DataProvider((r1, r2) => { return r1 !== r2; }); this.state = { dataProvider: dp.cloneWithRows(listData), }; this.setState({ dataProvider: dp.cloneWithRows(newListData), });
列表项的布局方法:layoutProvider;
注意,使用列表组件 RecyclerListView 有两个前提:
(1)首先是列表项的宽高必须是确定的,或者是大致确定的;
对于就是不确定的情况,RecyclerListView 是无解的;对于大致确定的情况,我们可以开启 forceNonDeterministicRendering 小幅修正布局位置。
(2)第二是列表项的类型必须是可枚举的。
两个列表项的底层 UI 视图必须一样或者大致相似,才能只改列表数据复用列表视图。如果每个列表项的 JSX 结构完全不一样,就不存在复用的可能性。
这两个前提,都体现在了列表项的布局方法 layoutProvider 中了。
其中有两个函数入参。第一个入参函数是通过索引 index 获取类型 type,对应的是类型可枚举。第二个入参函数是通过类型 type 和布局尺寸 dimension 获取每个类型的宽高 width 和 height,对应的是确定宽高。
jsxconst _layoutProvider = new LayoutProvider( (index) => { //获取本数据:getDataForIndex方法,然后自己给数据设置一个字段type就可以直接得到类型 if (index % 3 === 0) { //3的倍数,可枚举 return ViewTypes.FULL; } else { return ViewTypes.HALF_RIGHT; } }, (type, dimension) => { //宽高必须是确定 switch (type) { case ViewTypes.HALF_RIGHT: dimension.width = width / 2; dimension.height = 160; break; case ViewTypes.FULL: dimension.width = width; dimension.height = 140; break; } } );
列表项的渲染函数:rowRenderer。
列表项的渲染函数 rowRenderer 的作用就是根据类型和数据,返回对应的自定义列表项组件
这里其实可以进行操作,比如根据不同的类型渲染不同的组件!
//Given type and data return the view component
_rowRenderer(type, data) {
//You can return any view here, CellContainer has no special significance
switch (type) {
case ViewTypes.HALF_RIGHT:
return (
<CellContainer style={styles.containerGridRight}>
<Text>Data: {data}</Text>
</CellContainer>
);
case ViewTypes.FULL:
return (
<CellContainer style={styles.container}>
<Text>Data: {data}</Text>
</CellContainer>
);
default:
return null;
}
}
4.选择和对比
从底层原理看:
ScrollView 内容的布局方式是从上到下依次排列的,你给多少内容,ScrollView 就会渲染多少内容;
FlatList 内容的布局方式还是从上到下依次排列的,它通过更新第一个和最后一个列表项的索引控制渲染区域,默认渲染当前屏幕和上下 10 屏幕高度的内容,其他地方用空白视图进行占位;
RecyclerListView 性能最好,你应该优先使用它,但使用它的前提是列表项类型可枚举且高度确定或大致确定。
内存上,FlatList 要管理 21 个屏幕高度的内容,而 RecyclerListView 只要管理大概 1 个多点屏幕高度的内容,RecyclerListView 使用的内存肯定少。计算量上,FlatList 要实时地销毁新建 Native 的 UI 视图,RecyclerListView 只是改变 UI 视图的内容和位置,RecyclerListView 在 UI 主线程计算量肯定少。
ScrollView、FlatList 和 RecyclerListView 使用场景:
ScrollView 适合内容少的页面,只有几个屏幕高页面是适合的;
FlatList 性能还过得去,但我不推荐你优先使用它,只有在你的列表项内容高度不能事先确定,或者不可枚举的情况下使用它;
RecyclerListView 性能最好,你应该优先使用它,但使用它的前提是可枚举且高度确定或大致确定。
5.总结
RecyclerListView 文档:https://github.com/Flipkart/recyclerlistview
RecyclerListView 其实也是可以实现高度不确定项的无限列表,和多列的无限列表的
6.使用 RLV 实现一个无限列表
/***
Use this component inside your React Native Application.
A scrollable list with different item type
*/
import React, { Component } from "react";
import { View, Text, Dimensions } from "react-native";
import {
RecyclerListView,
DataProvider,
LayoutProvider,
} from "recyclerlistview";
const ViewTypes = {
FULL: 0,
HALF_LEFT: 1,
HALF_RIGHT: 2,
};
let containerCount = 0;
class CellContainer extends React.Component {
constructor(args) {
super(args);
this._containerId = containerCount++;
}
render() {
return (
<View {...this.props}>
{this.props.children}
<Text>Cell Id: {this._containerId}</Text>
</View>
);
}
}
/***
* To test out just copy this component and render in you root component
*/
export default class RecycleTestComponent extends React.Component {
constructor(args) {
super(args);
let { width } = Dimensions.get("window");
//Create the data provider and provide method which takes in two rows of data and return if those two are different or not.
let dataProvider = new DataProvider((r1, r2) => {
console.log("r1", r1, r2);
return r1 !== r2;
});
this._layoutProvider = new LayoutProvider(
(index) => {
/*
控制列表的显示效果和排布规则:
如果索引能被 3 整除,则返回 ViewTypes.FULL,这意味着每隔 3 个项会显示一个占满整行的项。
如果索引能被 20 整除,则返回 ViewTypes.HALF_LEFT,这意味着每隔 20 个项会显示一个占据左半边的项。
其他情况返回 ViewTypes.HALF_RIGHT,这意味着除了能被 3 整除和能被 20 整除的项之外,其他项会占据右半边。
*/
if (index % 3 === 0) {
return ViewTypes.FULL;
} else if (index % 20 === 0) {
return ViewTypes.HALF_LEFT;
} else {
return ViewTypes.HALF_RIGHT;
}
},
(type, dim) => {
switch (type) {
case ViewTypes.HALF_LEFT:
dim.width = width / 2 - 0.0001;
dim.height = 160;
break;
case ViewTypes.HALF_RIGHT:
dim.width = width / 2;
dim.height = 160;
break;
case ViewTypes.FULL:
dim.width = width;
dim.height = 140;
break;
default:
dim.width = 0;
dim.height = 0;
}
}
);
this._rowRenderer = this._rowRenderer.bind(this);
//Since component should always render once data has changed, make data provider part of the state
this.state = {
dataProvider: dataProvider.cloneWithRows(this._generateArray(300)),
};
}
_generateArray(n) {
let arr = new Array(n);
for (let i = 0; i < n; i++) {
arr[i] = i.toString();
}
return arr;
}
//Given type and data return the view component
_rowRenderer(type, data) {
//You can return any view here, CellContainer has no special significance
switch (type) {
case ViewTypes.HALF_LEFT:
return (
<CellContainer style={styles.containerGridLeft}>
<Text>Data: {data}</Text>
</CellContainer>
);
case ViewTypes.HALF_RIGHT:
return (
<CellContainer style={styles.containerGridRight}>
<Text>Data: {data}</Text>
</CellContainer>
);
case ViewTypes.FULL:
return (
<CellContainer style={styles.container}>
<Text>Data: {data}</Text>
</CellContainer>
);
default:
return null;
}
}
render() {
return (
<RecyclerListView
renderAheadOffset={0}
layoutProvider={this._layoutProvider}
dataProvider={this.state.dataProvider}
rowRenderer={this._rowRenderer}
/>
);
}
}
const styles = {
container: {
justifyContent: "space-around",
alignItems: "center",
flex: 1,
backgroundColor: "#00a1f1",
},
containerGridLeft: {
justifyContent: "space-around",
alignItems: "center",
flex: 1,
backgroundColor: "#ffbb00",
},
containerGridRight: {
justifyContent: "space-around",
alignItems: "center",
flex: 1,
backgroundColor: "#7cbb00",
},
};
实现的效果如下:

函数式组件写法:
import React from "react";
import { View, Text, Dimensions, StyleSheet } from "react-native";
import {
RecyclerListView,
DataProvider,
LayoutProvider,
} from "recyclerlistview";
const ViewTypes = {
FULL: 0,
HALF_LEFT: 1,
HALF_RIGHT: 2,
};
let containerCount = 0;
function CellContainer(props) {
let _containerId = containerCount++;
return (
<View {...props}>
{props.children}
<Text>Cell Id: {_containerId}</Text>
</View>
);
}
export default function Index() {
let { width } = Dimensions.get("window");
//Create the data provider and provide method which takes in two rows of data and return if those two are different or not.
let dataProvider = new DataProvider((r1, r2) => {
console.log("r1", r1, r2);
return r1 !== r2;
});
const _layoutProvider = new LayoutProvider(
(index) => {
/*
如果索引能被 3 整除,则返回 ViewTypes.FULL,这意味着每隔 3 个项会显示一个占满整行的项。
如果索引能被 20 整除,则返回 ViewTypes.HALF_LEFT,这意味着每隔 20 个项会显示一个占据左半边的项。
其他情况返回 ViewTypes.HALF_RIGHT,这意味着除了能被 3 整除和能被 20 整除的项之外,其他项会占据右半边。
*/
if (index % 3 === 0) {
return ViewTypes.FULL;
} else if (index % 20 === 0) {
return ViewTypes.HALF_LEFT;
} else {
return ViewTypes.HALF_RIGHT;
}
},
(type, dim) => {
switch (type) {
case ViewTypes.HALF_LEFT:
dim.width = width / 2 - 0.0001;
dim.height = 160;
break;
case ViewTypes.HALF_RIGHT:
dim.width = width / 2;
dim.height = 160;
break;
case ViewTypes.FULL:
dim.width = width;
dim.height = 140;
break;
default:
dim.width = 0;
dim.height = 0;
}
}
);
//Since component should always render once data has changed, make data provider part of the state
const state = {
dataProvider: dataProvider.cloneWithRows(_generateArray(300)),
};
const _generateArray = (n) => {
let arr = new Array(n);
for (let i = 0; i < n; i++) {
arr[i] = i.toString();
}
return arr;
};
//Given type and data return the view component
const _rowRenderer = (type, data) => {
//You can return any view here, CellContainer has no special significance
switch (type) {
case ViewTypes.HALF_LEFT:
return (
<CellContainer style={styles.containerGridLeft}>
<Text>Data: {data}</Text>
</CellContainer>
);
case ViewTypes.HALF_RIGHT:
return (
<CellContainer style={styles.containerGridRight}>
<Text>Data: {data}</Text>
</CellContainer>
);
case ViewTypes.FULL:
return (
<CellContainer style={styles.container}>
<Text>Data: {data}</Text>
</CellContainer>
);
default:
return null;
}
};
return (
<RecyclerListView
renderAheadOffset={0}
layoutProvider={_layoutProvider}
dataProvider={state.dataProvider}
rowRenderer={_rowRenderer}
/>
);
}
const styles = StyleSheet.create({
container: {
justifyContent: "space-around",
alignItems: "center",
flex: 1,
backgroundColor: "#00a1f1",
},
containerGridLeft: {
justifyContent: "space-around",
alignItems: "center",
flex: 1,
backgroundColor: "#ffbb00",
},
containerGridRight: {
justifyContent: "space-around",
alignItems: "center",
flex: 1,
backgroundColor: "#7cbb00",
},
});
8.Fast Refresh 快速刷新
我会先从 React Native 快速刷新的使用讲起,然后再深入核心原理,帮你理解如何更好地使用快速刷新,提高你的 UI 开发效率。
React Native 快速刷新(Fast Refresh)是默认开启的,你不用做任何额外的配置,就能立刻体验到。
快速刷新提效的本质是及时反馈。也就是说,你写下代码后就能看到 UI,没有其他任何多余步骤。代码完成了,UI 就更新了,这就是及时反馈。
我日常开发时习惯把模拟器放在代码编辑器右边,并且会把模拟器勾选 window => stay on top 选项,在把模拟器置顶在编辑器上方。
1.基础原理:模块热替换
我们写 React Native 之前,都会运行一个 react-native start 命令,启动一个 Metro 服务,而 Metro 服务就实现了模块热替换的功能。Metro 服务会把更新的代码打包发送给 React Native 应用,让应用能够及时更新。
但是这里会有一个问题,仅仅只是用新模块替换旧模块,会导致原生视图重新渲染,并且丢失原有状态。基础的模块热替换功能只能实现组件级别的强制刷新,而组件状态的丢失,会导致开发效率的降低。
2.进阶能力:复用组件及其状态
那么,React Native 的快速刷新功能,是如何实现组件状态不丢失,原生视图不重建的呢?
快速刷新功能复用组件和状态的原理分为两个步骤:在编译时,修改组件的注册方式;在运行时,用“代理”的方式管理新旧组件的切换。
简而言之,React Native 的快速刷新功能,就是通过“代理”组件的方式,实现了组件状态不丢失,原生视图不重建。
这里我放了快速刷新 babel 编译后的复用模型,可以帮助你理解复用的实现原理:
尽可能地拥抱函数组件,放弃类组件。这样你在 UI 调试的时候,就能更多的享受函数组件带来的状态保留好处。特别是一些入口很深的组件,需要多次操作后才能调试,一旦导航、蒙层、滚动之类的组件状态丢失了,整个操作就要重新再来一遍,才能重新进行调试。拥抱函数组件,你的调试效率才会更高。
3.整体策略:逐步降级
快速刷新的整体策略就是逐步降级。如果颗粒度最小的更新不能使用,就换成颗粒度大一些的更新。
快速刷新的逐步降级策略是,从更新颗粒度最小代码块开始的,然后是组件、模块,最后是大颗粒度的 React Native 应用。越小颗粒度的更新,为我们保留了越多原来的状态和环境,我们的开发调试效率也更高。

4.总结
能够实现快速刷新原因是,快速刷新能够通过模块热替换的方式,用我们修改后的新模块替换原来的旧模块。如果,该模块导出的是组件,那么“代理”组件就会将引用从旧组件切到新组件上,实现组件级别的刷新。如果,函数组件且 hooks 顺序没有发生改变,快速刷新时原有的组件状态也会保留。快速刷新时,越小颗粒度的更新,速度越快,调试效率更高。
要用好快速刷新功能,还有三个小技巧:
同屏预览。将模拟器置顶在编辑器上方,减少你视野来回切换频率;
拥抱函数组件。函数组件能保留原有组件状态,减少你操作交互的次数;
单独拎出来调试。单独拎出来先开发独立组件再集成,可能会比在层层嵌套的代码结构中开发效率更高。
请你思考一下为什么快速刷新功能的作者 Dan 认为,保留类组件的热重载非常不可靠的,但函数组件却是可行的?
9.Debug 调试
一方面是,我们会遇到很多 Bug,也会花很多时间去解决 Bug;另一方面是,我们直接裸写的代码可能存在较多的潜藏 Bug,我们得花精力把这些潜藏的 Bug 给找出来。那面对这些 Bug,有没有什么通用的解决思路呢?这正是今天我要和你介绍的,我把它概括为“1+2+3”,也就是一个模型,两个原则,三条思路。
1.一个模型:发现问题、找到原因、修复 Bug
一个模型指的是,发现问题、找到原因、修复 Bug 的三步模型。其实这就是我们日常解决 Bug 的常规步骤。
虽然我们遇到的 Bug 形形色色、各不相同,但当你把解决问题划分为三步之后,我们就可以针对不同的步骤给出不同的解题思路了。
每个 Bug 都有每个 Bug 修复的思路,但大部分的 Bug 在发现问题和找到原因这两步,是可以找到一些通用方法的。而我接下来要讲的**“两个原则”,说的就是发现问题这一步的两个原则**,“三条思路”说的就是找到原因这一步的三个思路,这些原则和思路都是通用的。
同时,在发现问题和找到原因这两步中,我们也离不开团队成员的相互协作,以及各种调试工具支持。
狭义和广义调试是有所不同的。狭义的调试,指的是代码运行时打日志、打断点;但广义的调试,指的是发现问题和解决问题的过程(Debugging is the process of finding and resolving bug)。任何能够帮助我们发现和解决问题的工具,都可以归类为广义的调试工具,甚至上线流程也是可以为调试服务的。
调试的全貌示意图:

2.两个原则:不带上线原则和本地复现原则
我先和你介绍发现问题这一步的两个原则:不带上线原则和本地复现原则。
1.不带上线原则:要尽可能早地在本地开发时发现问题,提前发现问题是不带 Bug 上线的必要条件;
没有任何的线上 Bug 是不可能的,但我们可以减少带上线的风险,比如团队成员之间可以通过合作建立一套完善的上线流程,依靠流程和机制来减少风险。
有两套流程:
(1)在本地开发时,需要针对开发的新增的模块写一个新的单元测试。在提交代码的时候,有 git hook 的自动脚本来执行我们的 Jest 单元测试,并校验 TypeScript、ESLint 是否通过,只有校验通过之后才能提交。在提交到远程仓库后,还有机器人再校验一次,并且只有在机器人校验和项目成员的 Code Review 通过后才能把代码合到主分支。
(2)而理想上线流程的另一套答案,其实也是大部分团队都在实践的答案。
当我们把新功能推到的代码仓库的主分支中,我们还需要把主分支中的代码进行上线。在上线过程中,我们需要靠 UI 验收、靠 QA 测试、靠 PM 体验,靠团队的力量来尽早发现 Bug。必要的时候,还可以在上线平台上下功夫,比如只有 QA 拥有上线权限,又比如做 A/B 测试、灰度测试等。
2.本地复现原则:如果 Bug 已经被带上线了,我们要尽快发现它,还要尽可能多地收集线上信息,让它能更容易地在自己的手机或本地复现。
线上 Bug 本地复现之后,剩下的修复思路就和本地 Bug 的修复思路是一样的了。
我们有两种工具可以利用,一种是监控系统,另一种是用户反馈系统。
在技术层面接入一套监控系统,比如腾讯出品的常用于原生应用监控的 Bugly,或者开源领域的 Sentry,又或者是自研的监控平台,这些都是可以的。在产品层面上,我们需要有一套用户反馈机制,它们的核心作用是发现那些本地难以复现、又缺乏线上报错数据的 Bug。
3.三条思路:一推理、二分法、三问人
1.一推理
所谓的“一推理”,它指的是,我们遇到问题首先要做的是冷静地思考、分析和推理,要搞清楚问题是什么,知道问题是什么了,能直接解决的就自己直接解决,不要一开始就去网上搜答案。网上答案很多,但搜索正确答案成本很高,而且别人的答案不一定能解决你的问题。
当你遇到红屏时,应该先认真读一遍红屏中的报错信息,第一遍没读懂没关系再多读几遍。
但是,不要一上来毫无头绪就开始打日志、打断点,这样做效率很低。一定要先检查代码、先判断原因,再去打日志、打断点去验证你的判断,这样你的调试能力、逻辑能力才会慢慢变强,调试速度才能慢慢提高
有时候代码太复杂了,代码内部执行的步骤太多了,要寻找是具体是哪段逻辑有问题就太难了。这时候,你可以先对代码的入口或出口的数据进行分析。
调试工具功能图:
我们简单分析下这几个工具。首先是弹窗 alert,它的好处是依赖任何环境,但一个弹窗能展示的内容太少了,只有在线上环境我才会用到它。
接着是终端 Terminal,你在本地通过 Terminal 启动打包工具 Metro 的服务时,你的调试代码就和 Terminal 建立了连接,你通过 console.log 打印的日志,都会在 Terminal 显示。使用它时,你不必单独下载其他任何的调试工具。据我所知,很多人排查问题只靠 Terminal 打日志,但实际上还有其他更好用的工具。
比如 Facebook 出品的移动应用调试工具 Flipper 就不错,但你需要单独进行下载。它的功能很强大,打日志、打断点、查看元素树、抓包请求、查看存储它都支持,而且支持扩展插件。
比较流行的调试工具还有 React Native Debugger、Reactotron,如果涉及原生代码,你还可用 Android Studio、Xcode 进行调试。
这些工具你不必每个都要学会怎么使用,选择几个你顺手的即可。工具只是辅助,关键是分析本身,调试工具只要够用就行。我平时用得比较多的是 Terminal 和 Flipper。
2.二分法
在你遇到不知道是什么原因引起的 Bug 时,你可以试试这招。所谓的“二分法”,说的是在我们不能确定问题原因的时候,把所有潜在的问题都用类似“数组二分查找”的方式把代码遍历一遍,不断缩小问题的范围,最终找到问题原因。
例子:
环境:我们先把环境和代码分开,先排查环境原因,如果别人的电脑、手机都没有问题,我的有问题,那就可以判断是我的电脑、手机的环境有问题,否则就是代码问题。
版本:如果是代码问题,我们再排查上一个上线版本有没有问题,上一次 commit 的代码有没有问题,如果上一次也有问题就是历史遗留问题,否则就是新引入的问题。
组件:如果是新引入的问题,再从根组件开始排查,一个 React Native 应用(或页面)只有一个 Root 组件,一个 Root 组件有若干个子组件,子组件又有自己的子子组件,这就组成了一个组件树,你只要顺着 Root 组件一步一步地进行二分判断,看哪一边的子树是有问题的,哪一边的子树是没有问题的,最终就能确定问题代码的范围了。
“多分法”:“二分法”的思路是从整体到局部,它还有一个变种就是“多分法”。比如首屏性能问题,用户从点击、到请求、再到渲染的过程是一个整体,你可以把这个整体中各个阶段中的关键节点都埋上性能统计埋点,找到那些优化收益率高的、做起来容易的地方去优化。只有从整体的视角出发,分析出每个局部的优化空间有多少,你才能判断各个技术方案的投入产出比(ROI),做出全局最优的决定。
3.三问人
所谓的“三问人”,说的是我们借鉴别人的经验来解决自己的问题,别人可以是同事、朋友、微信群,也可以是搜索引擎。
Google 搜到的资料更全一些有博客、论坛、GitHub、学习型网站,百度搜到的大多是国内开发者的博客。另一类就是专业的技术网站,比如 GitHub 和 Stack Overflow,这类专业技术提供的搜索引擎的搜索效率,有时候比 Google 还要更高一些。有时候我在 Google 搜索出的内容不是我想要的,我就会跑到 GitHub 的 React Native 仓库的 Issues 中和 Stack Overflow 上直接搜索,它们推荐的内容就会更加精准一些。
有些英语差的同学可以会觉得,使用 Google、GitHub 这类以英文为主的网站,语言是个门槛。我推荐你使用 DeepL 翻译引擎,在 DeepL 的宣传资料中,它的中英互译的准确性比 Google 等翻译引擎要强上 5 倍,我的实际使用感受也确实是准确很多。
4.总结
自带工具:
react-native doctor:可以帮忙检查本地环境是否搭建是否有问题。
Perf Monitor:调试情况下摇一摇手机,就会有一个弹窗,其中 Perf Monitor 功能可以帮你查看本地的 JavaScript FPS 和 Native FPS。
Inspect:摇一摇中的 Inspect 功能,可以帮我们查看组件树的结构。
搜索工具:
翻译:DeepL、谷歌翻译 百度翻译。DeepL 还有客户端,配合快捷键使用更方便。
搜索:谷歌搜索、百度搜索专业网站:React Native GitHub Issues、Stack Overflow
第三方工具:
(推荐)Facebook 推出的移动应用调试工具 Flipper;
(不推荐)微软推出的 VSCode 插件 React Native Tools;
(不推荐)Infinitered 推出的 Reactotron;
(不推荐)React Native Debugger。
10.改造源码实现瀑布流
现在,国内购物 App 的首页大都采用了双列瀑布流的布局
当时我提出了一种思路:改 RecyclerListView 的源码。我说,RecyclerListView 的布局原理是绝对定位,每个 item 的 x、y 轴坐标是根据传入的 height、width 值算出来的,现在它的布局算法是单列的,我们只要把单列布局算法改成双列布局算法,这件事情应该能成。
我今天就和你讲讲,我是如何通过修改 RecyclerListView 组件的源码,实现瀑布流效果的。
1.准备开发调试环境
现在,你需要做的是准备开发调试环境。准备开发调试环境永远是第一步,而且现在我们要调试的是放在 node_modeuls 目录下的第三方组件 RecyclerListView,所以现在我们要准备第三方依赖包的开发调试环境。
该模块是 node_modules/recyclerlistview 目录下的 index.js 文件 export 导出的模块。不过第三方库,也可以通过自己目录下的 package.json 中的 main 字段进行配置。
在 recyclerlistview 的 package.json 文件中,它通过 main 参数指定了模块路径 dist/reactnative/index.js。
你会发现,dist 目录下放的是编译后的 .js 文件。也就是说,如果我们直接跑项目,只能调试编译后的 .js 文件,不能调试放在 src 目录中的 .ts 源码。那怎样才能调试 .ts 的源码文件呢?有一招很简单,修改 recyclerlistview 的导出模块的配置:你只需把 recyclerlistview/package.json 的 main 参数改为 src/index.ts 即可,React Native 会在编译时通过 babel 将 .ts、.tsx 文件编译为 .js 文件再执行。
2.找到关键源码
理解 RecyclerListView 这类复合组件的源码,我有一个技巧,就是从复合组件 JSX 部分开始切入。
这时我们能直接观察到的是 UI 视图,以 UI 视图为锚点,去理解 JSX 文件就很容易了。在你了解 JSX 之后,再根据状态 state 和属性 props 去推断组件的内部逻辑 f ,会容易很多。
一个页面,无非也就是由这几个部分组成:UI/JSX = f(state, props)
下面是我从 RecyclerListView 类组件摘出来的 3 个和 JSX 相关的方法:
// node_modules/recyclerlistview/src/core/RecyclerListView.tsx
public renderCompat() { // 先调用 render 后调用它
return (
<ScrollComponent>
{this._generateRenderStack()}
</ScrollComponent>
);
}
private _generateRenderStack(){
for(const key in this.state.renderStack){
renderedItems.push(this._renderRowUsingMeta(this.state.renderStack[key]))
}
return renderedItems
}
private _renderRowUsingMeta() {return <ViewRenderer/>}
它们分别是:
renderCompat 方法:实际就是类组件的 render 方法,它最外层是一个滚动组件 ScrollComponent;
generateRenderStack 方法:循环了状态 state.renderStack,生成了若干个 renderedItems;
renderRowUsingMeta 方法:返回的是具体的 renderedItems ,也就是 ViewRender 容器元素。
上面这三个函数息息相关,紧密相连,共同构成了主要的 jsx 页面逻辑!
现在我们已知 JSX 是 ScrollView + View,已知 state 是用 for...in 循环的对象 renderStack,还已知 RecyclerListView 的三个必传 props:列表数据 dataProvider(dp)、列表项的布局方法 layoutProvider、列表项的渲染函数 rowRenderer。
那么 state.renderStack 和三个 props 是怎么控制 JSX 的?
那我们就要再仔细读一下_renderRowUsingMeta 中的代码了:
private _renderRowUsingMeta(itemMeta: RenderStackItem): JSX.Element | null {
const dataIndex = itemMeta.dataIndex;
const data = this.props.dataProvider.getDataForIndex(dataIndex);
const type = this.props.layoutProvider.getLayoutTypeForIndex(dataIndex);
return (
<ViewRenderer
data={data}
layoutType={type}
index={dataIndex}
layoutProvider={this.props.layoutProvider}
childRenderer={this.props.rowRenderer}
/>
);
}
解读:状态 state.renderStack[key] 就是 itemMeta,每个 itemMeta 的 dataIndex 是不一样的,通过 dataIndex 从列表数据 dataProvider 和布局方法 layoutProvider 中,选取了对应项的数据 data 和布局类型 type ,并将这些值和列表项的渲染函数 rowRenderer 都赋值给了 ViewRenderer 的 childRenderer 属性。
ViewRenderer 是一个容器(子项的容器),内部装的是你传给它的渲染函数 rowRenderer 方法,并且按照你指定的数据 data、类型 type 进行渲染。
因为 ViewRenderer 是你指定的列表项 rowRenderer 的父容器,父容器的位置决定了你列表项的位置。而在_renderRowUsingMeta 的源码中还有这句:
const itemRect = ( this._virtualRenderer.getLayoutManager() as LayoutManager ).getLayouts()[dataIndex];
这些由 LayoutManager 类的 getLayouts 方法生成的 x/y/width/height 属性,就是决定你列表项布局方式的关键源码。
打开 LayoutManager 类的源码:
// node_modules/recyclerlistview/src/core/layoutmanager/LayoutManager.ts
public getLayouts(): Layout[] {
return this._layouts;
}
public relayoutFromIndex(): void {
let startX = 0;
let startY = 0;
let maxBound = 0;
for () {
oldLayout = this._layouts[i];
if () {
itemDim.height = oldLayout.height;
itemDim.width = oldLayout.width;
maxBound = 0;
}
while () {
startX = 0;
startY += maxBound;
}
maxBound = Math.max(maxBound, itemDim.height);
this._layouts.push({ x: startX, y: startY, height: itemDim.height, width: itemDim.width, type: layoutType });
}
}
relayoutFromIndex 方法通过一堆计算,计算出了实现单列布局的 x/y/height/width 值,然后把它们作为对象 push 到了 this._layouts。而 ViewRenderer 根据 this._layouts 把你的列表项,渲染到了指定的位置上。
因此,我们要想实现双列瀑布流布局,就得理解和修改 relayoutFromIndex 方法。
3.修改源码
在“找到关键源码”这一步,我们读源码其实只要有宏观上的理解就行了,但要“修改别人源码”就需要更微观上的理解了。
在提高理解的准确性上,我是这么做的。首先我会使用断点工具,一行一行地执行代码,并对上下文中的变量进行一些“终极拷问”:“变量从哪来”、“变量用到哪里去”、“变量的意义是什么”,再把自己的理解马上备注起来,不然容易忘。
在微观理解上,我们也要找到切入点。比如,在理解 relayoutFromIndex 方法时,我找的切入点就是设置列表项的 x/y。设置 x/y 的核心代码如下:(单列布局算法)
public relayoutFromIndex(itemCount: number): void {
// 新 item x y 坐标
let startX = 0;
let startY = 0;
// 记录当前一行最高元素的高度
let maxBound = 0;
for (let i = 0; i < itemCount; i++) {
// 如果当前多个 item 宽度之和超过屏幕宽度就换行
while (!this._checkBounds(startX, startY, this._layouts[i])) {
// 将实际 x 坐标设置为 0
startX = 0;
// 将实际 y 坐标设置增加上一行最高 item 的高度
startY += maxBound;
maxBound = 0;
}
// 设置新的宽高
this._layouts.push({ x: startX, y: startY, height: itemDim.height, width: itemDim.width, type: layoutType });
// 记录当前一行最高 item 的高度
maxBound = Math.max(maxBound, this._layouts[i].height);
// 默认情况下:下一个 item 的初始化的 x 坐标
startX += itemDim.width;
}
}
private _checkBounds(
itemX: number,
itemY: number,
itemDim: Dimension,
): boolean {
return itemX + itemDim.width <= this._window.width;
}
理解单列布局算法:从代码层面看,它对你传入的列表项进行 for 循环遍历,并通过 _checkBounds 方法来判断。如果当前遍历的列表项宽度和当前一列已有列表项的宽度之和,不超过屏幕宽度,也就是 itemX + itemDim.width <= this._window.width,那么就跳过 while 循环,直接使用同一行前几个列表项的宽度之和 startX += itemDim.width ,作为当前列表项的 x(startX) 坐标。也就是情况一:宽度足够,放到同一行。
如果_checkBounds 判断,同一行剩余宽度不够了,那么就进入 while 循环,将当前列表项的 x(startX) 坐标设置为 0,y 坐标设置增加上一行最高 item 的高度 maxBound。也就是情况二:宽度不够,放到下一行。
那么我们就可以通过修改布局算法,来实现双列瀑布流布局!
双列瀑布流布局只有两种情况,第一种情况是如果左边已有列表项的高度之和 startLeftY 大于右边已有列表项的高度之和 startRightY,那么下一个列表项就要放右边。第二种情况则刚好相反,我们需要把下一个列表项放在左边。简单来说就是,左高放右、右高放左。
public relayoutFromIndex(startIndex: number, itemCount: number): void {
// 假设: 每个 item 的宽度为 1/2*window.width 两种情况
const halfWindowWidth = this._window.width / 2;
let startLeftY = 0; // 左边所有 item 的高度之和
let startRightY = 0; // 右边所有 item 的高度之和
let startX = 0; // 新增 item 的 X
let startY = 0; // 新增 item 的 Y
for (let i = startIndex; i < itemCount; i++) {
itemDim.height = oldLayout.height;
itemDim.width = halfWindowWidth;
// 保证一行中所有的 item 宽度之和不超过屏幕宽度,超过就换行
if (startLeftY > startRightY) {
startX = halfWindowWidth;
startY = startRightY;
startRightY += itemDim.height;
} else {
startX = 0;
startY = startLeftY;
startLeftY += itemDim.height;
}
// 如果是 item 是新增的,在添加新的 layout
this._layouts.push({x: startX, y: startY, height: itemDim.height, width: itemDim.width, type: layoutType,});
}
首先,双列瀑布流有一个假设,假设每个列表项的宽度为屏幕的一半。其次,我们还需要记录左边的高度之和 startLeftY 和右边的高度之和 startRightY。在 for 遍历列表项时,如果左边高 startLeftY > startRightY,那么当前列表项放右边 startX = halfWindowWidth,否则当前列表项放左边 startX = 0,同时记录最新的左边 / 右边高度之和。最后把当前列表项 push 到 this._layouts 中。
4.保存修改
怎么把修改好的 node_modules 代码保存呢?有三种思路:
第一种直接复制源码。但复制源码后续想要升级 RecyclerListview 的版本会非常困难,每次升级可能面临的是一次重新改造。
第二种在运行时进行修改。这种方法对源码的侵入性小,但每次升级前我们还是需要手动检查一下的,不然相关代码逻辑有变化,我们的修改就会受到影响。我在这次实战中,采用的就是在运行时进行修改的方案。我观察了一下 RecyclerListview 的代码,它的代码风格是面向对象的编程风格,几乎把所有的内部类都暴露出来了。但由于它 LayoutManager 类的所有属性是私有属性,我没办法通过继承的方式读取到 LayoutManager 的私有属性。因此我复制了 LayoutManager 和 layoutProvider 类,并将其重写为 WaterfallLayoutManager 和 WaterfallLayoutProvider。当你的列表是单列布局时,就应该使用 layoutProvider 类 ,当你的列表是双列瀑布流布局时,就可以使用我创建的 WaterfallLayoutProvider 类。——> 主要是入侵小,保留原来的基础上进行新增!
第三种在编译时修改。这里利用的是 patch-package 即时修复第三方 npm 包的能力,它的原理是先对你的修改进行保存,然后在你每次安装 npm 包的时候把你原先的修改给注入进去,也就是 patch package。它是侵入式的修改方式。
一般来说,无论是快速修改第三方组件源码,还是修改 React Native 的 JavaScript 层的源码,我都不建议使用第一种直接复制源码的方式。我会优先考虑在运行时的修改方法,通常该方案改动最小、侵入性也最小。如果运行时方案改不了,我才会考虑有侵入性的编译时的 patch-package 方案。
5.总结
在理解别人的组件代码时,利用 UI/JSX = f(state, props) 这个最基本 React/React Native 原理,先找到实现 UI 的 JSX 部分,再找到 state、props,然后再理解逻辑 f 的部分。
在修改别人的逻辑代码时,先通过调试工具来理解各个变量上下文含义,理清楚别人的逻辑后,再根据自己目的进行修改。
最终我们要修改的代码:源码里面的 LayoutManager 文件的 relayoutFromIndex 方法修改了即可!
// startIndex:从第几个 item 开始有了更新,从这个 item 开始算,目的是为了减少计算量。默认:0
// itemCount: 一共多个 item。
// 以下注释只考虑垂直滚动,水平滚动同理。
public relayoutFromIndex(startIndex: number, itemCount: number): void {
// TODO: 性能优化
// 每次都从头算,这样最简单。
startIndex = 0;
// 假设: 每个 item 的宽度为 1/2*window.width 两种情况
const halfWindowWidth = this._window.width / 2;
let startLeftY = 0; // 左边所有 item 的高度之和
let startRightY = 0; // 右边所有 item 的高度之和
let startX = 0; // 新增 item 的 X
let startY = 0; // 新增 item 的 Y
// 重新计算 scrollview 的高度
this._totalHeight = 0;
this._totalWidth = 0;
const oldItemCount = this._layouts.length;
// 初始化新 item 的宽高
const itemDim = { height: 0, width: 0 };
let itemRect = null;
let oldLayout = null;
for (let i = startIndex; i < itemCount; i++) {
// 旧 item 的layout(x/y/宽/高)
oldLayout = this._layouts[i];
// 调用 LayoutProvider 第一个入参函数
// LayoutProvider( () => return 'type', fn2)
const layoutType = this._layoutProvider.getLayoutTypeForIndex(i);
// 在高度不确定的动态布局情况下,业务会开启 forceNonDeterministicRendering,此时 height、width 会计算两次,
// 第一次取 LayoutProvider 第二个入参函数返回值(走 else),
// 第二次 ViewRenderer _onViewContainerSizeChange 会调用 layoutManager 重写 height、width,这种情况下需要取重写的值。
if (
oldLayout &&
oldLayout.isOverridden &&
oldLayout.type === layoutType
) {
itemDim.height = oldLayout.height;
} else {
// 调用 LayoutProvider 第二个入参函数,设置 itemDim
// LayoutProvider(fn1,(type, itemDim, index) => { itemDim.height = 300;})
this._layoutProvider.setComputedLayout(layoutType, itemDim, i);
}
itemDim.width = halfWindowWidth;
// 保证一行中所有的 item 宽度之和不超过屏幕宽度,超过就换行
if (startLeftY > startRightY) {
startX = halfWindowWidth;
startY = startRightY;
startRightY += itemDim.height;
} else {
startX = 0;
startY = startLeftY;
startLeftY += itemDim.height;
}
// 如果是 item 是新增的,在添加新的 layout
if (i > oldItemCount - 1) {
this._layouts.push({
x: startX,
y: startY,
height: itemDim.height,
width: itemDim.width,
type: layoutType,
});
// 如果是 item 是已经渲染过一次的,已经记住原有 layout,重新赋值
} else {
itemRect = this._layouts[i];
itemRect.x = startX;
itemRect.y = startY;
itemRect.type = layoutType;
itemRect.width = itemDim.width;
itemRect.height = itemDim.height;
}
}
// 如果 list 的长度减少了,也就是商品数量减少了,
if (oldItemCount > itemCount) {
this._layouts.splice(itemCount, oldItemCount - itemCount);
}
// 设置 scrollview 的最终高度
this._totalHeight = Math.max(startLeftY, startRightY);
this._totalWidth = this._window.width;
}
最终的效果:

11.页面实战:如何搭建一个电商首页?
刚刚开始学习的时候,不要一头扎进技术的细节中去学习,应该拿起 React Native 的知识地图先看看,知道自己学习的方向并给自己树立一个学习目标。
这一讲,我不会讲具体的代码实现,主要讲的是我在“搭建一个简易的电商首页”时的技术设计思路,希望我的思路能够对你的实现基础篇的大作业有所帮助。
1.简易电商首页

你可以看到,App 首页的主要功能包括三个部分:顶栏、金刚位、瀑布流。
顶栏是固定在首页中的,它的主要功能是用于切换首页和关注页,其中关注页不是你负责,因此顶栏你只需要注意两点,第一能够支持点击切换,第二顶栏要始终保持在顶部,页面滚动的时候需要保持不动。
金刚位是其他功能页的核心入口,它横跨两个屏幕,每屏幕两行,每行 5 个图标。金刚位的特点是,它自身支持左右滑动切换,并且在页面滚动时金刚位也要跟随着一起滚动。
瀑布流是 JPG 的核心展示区,它由若干个高度不确定的卡片组成,每一批卡片 20 个,卡片数据是从后端请求过来的,并且需要支持无限滚动。
2.项目结构和目录划分
我以前和你介绍过,搭建页面讲究的是代码未动构思先行。动手开发,我一般会从三个技术维度进行思考,项目维度、页面维度和单个组件维度,主要围绕技术选型、可行性、可扩展性、可维护性这些方向进行。
遇到大的需求,我还会专门先写技术文档,内容包括核心技术选型、组件拆分方式、组件之间的关系、状态的数据结构和流程图,等等。写文档的过程也就是把模糊的构思变成清晰的文字的过程,在这个过程中,我会找出一些以前没有思考到的要点,把风险提前暴露出来,同时写文档也能帮我把设计思路变得更有条理一些。
项目目录应该如何设计,才能支撑后续项目变大的可扩展性和可维护性?从单个 NFT 首页本身来讲,我的设计思路是这样的,你可以参考一下:
.
├── api
│ └── homeAPI.tsx
├── components
│ ├── Grid
│ └── RecyclerListView
├── utils
├── features
│ ├── Icons
│ ├── List
│ ├── TopBar
│ └── WaterFallCard
└── index.tsx
features:业务组件和其后端接口数据的处理逻辑部分,它们是最容易变动的,而且关联性很强,因此我把它们看作一个功能,有时候代码行数不多我也会偷懒不拆,直接把这组件和组件的后端数据处理逻辑放到同一个文件中。按功能 feature 拆分而不是按组件本身进行拆分的思路,我是从 Redux 的最佳实践中学来的。“Structure Files as Feature Folders with Single-File Logic”,相同 feature 的文件,都放在同一个文件夹下。
这时你可能会问,这种项目的结构设计,可扩展性怎样?开发页面用这个项目结构是可以,但咱们不是要开发 NFT 的 App 嘛。其实,这就是个套娃的过程了,当然里面也有一些技巧,如果你后续要开发一个完整的 App,我的扩展设计思路如下:
.
├── api
├── components
├── packages
├── utils
├── features
├── screen
│ ├── Home
│ │ ├── api
│ │ ├── components
│ │ └── ...
│ └── Follow
└── index.tsx
在上面的项目结构中,首页 Home 的页面结构也是 api、components、features、utils、index.tsx 的结构,只不过用 Home 目录包裹起来了,并将其放到了 screen 目录中。一个文件具体放哪儿一层,按照通用程度来划分:
页面级别的共享:我会放在 ./screen/Home/api、./screen/Home/components 等目录下;
应用级别的共享:一个应用中有多个页面,多个页面之间的共享我会放在 ./api、./components 等目录下;
项目级别的共享:有时候项目和项目之间的代码也是会共用的,这部分代码我会放在 packages 目录下,并通过 npm 的方式进行分发。这个思路,我参考的是业内的 monorepo 实践,我们团队内部也在用。
3.页面拆分
项目维度弄清楚后,接下来我重点思考的问题是如何“拆稿”,也就是把 UI 设计稿拆成组件,特别是要把组件状态确认好。
我拿到 UI 设计稿后,发现了两个我熟悉的通用组件,这些通用组件是我以前写代码时沉淀下来的一些应用级别的共享代码。虽然这些 UI 组件在每个 App 上都长得不一样,很难做成多项目通用、业内通用的组件,但自己做项目时直接拿过来改改,还是非常好用的。你看,以前通用组件、通用工具的积累,现在派上用场了吧。
这两个通用组件是网格布局组件 Grid,和瀑布流版的 RecyclerListView,我把它们放到了 components 目录下。
要开发页面,就要先把它拆成组件。在 02 讲中,我提到过拆组件原则是单一职责原则,一个组件只做一件事,我还在 04 讲说过,组件的状态根据就近原则进行放置,我们应该先考虑放在该组件上,再去考虑父组件。根据单一职责原则和就近原则,NFT 首页的设计稿我是这么拆的:

接着是 List 的实现。无限列表 List,底层直接使用瀑布流版的 RecyclerListView 实现就可以了。而且,无限列表的加载状态,我们也在 04 讲中提到过,所有的 isLoading、isError、isSuccess 都可以合并成一个状态。
再接着是 Icons。金刚位 Icons,我用我自己开发的网格组件 Grid 和滚动组件 ScrollView 就能实现。ScrollView 组件我们也在 08 讲中介绍过,我打算用它的横向滚动、分页能力和滚动结束事件,来实现金刚位的支持左右滑动切换、双屏切换的功能。
最后是 WaterFallCard 。前面我们提到过 NFT 首页是由金刚位和瀑布流组成的混合列表,我们不能用 RecyclerListView 嵌套 RecyclerListView 来实现混合列表。这里我敲一个重点,RecyclerListView 是继承自 ScrollView 的,同一个方向也就是垂直方向或水平方向,我们尽量只使用一个 ScrollView/RecyclerListView 组件来进行响应。
4.单个组件
当我把 NFT 页面拆成 2 + 4 个组件后,我的实现思路就清晰很多了。两个通用组件,不需要什么改动,工作量很小,4 个业务组件只有列表 List 组件状态管理比较麻烦,这时候我就把重点放到了 List 组件上。如果你想了解 4 个业务组件的具体实现,也可以看下我放在 GitHub 上的代码。
如果做过无限列表,你就知道,处理里面的逻辑还挺麻烦的,需要处理首次请求成功、首次请求失败、更多数据加载成功 / 失败、后端没有数据等等情况,如果考虑性能优化的话,还要做预加载、要管理数据缓存的逻辑,要写很多代码。
这时候,我想起了以前在技术群里有朋友推荐过的 React Query,说是处理请求状态非常简单!
TODO 但是和 axios 区别还是很大的!
import { QueryClient, QueryClientProvider, useQuery } from "react-query";
const queryClient = new QueryClient();
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<Example />
</QueryClientProvider>
);
}
function Example() {
const { isLoading, error, data } = useQuery("repoData", () =>
fetch("https://api.github.com/repos/tannerlinsley/react-query").then(
(res) => res.json()
)
);
if (isLoading) return "Loading...";
if (error) return "An error has occurred: " + error.message;
return (
<div>
<h1>{data.name}</h1>
<p>{data.description}</p>
<strong>👀 {data.subscribers_count}</strong>{" "}
<strong>✨ {data.stargazers_count}</strong>{" "}
<strong>🍴 {data.forks_count}</strong>
</div>
);
}
5.实操
下面这个函数就非常的优雅:
export default class Grid extends PureComponent {
static propTypes = {
/**
* 传入的菜单数据,包括 icon、文字、点击回调函数
*/
data: PropTypes.arrayOf(
PropTypes.shape({
icon: PropTypes.string,
text: PropTypes.string,
onPress: PropTypes.func,
})
),
/**
* 列数。(行数 = Math.ceil(data.length/column))
*/
column: PropTypes.number,
/**
* 外部容器的样式
*/
style: ViewPropTypes.style,
/**
* 每个格子的样式
*/
itemStyle: ViewPropTypes.style,
/**
* 格子 icon 的样式
*/
iconStyle: PropTypes.oneOfType([PropTypes.number, PropTypes.object]),
/**
* 格子 text 的样式
*/
textStyle: PropTypes.oneOfType([PropTypes.number, PropTypes.object]),
/**
* 自定义渲染每个格子的内容
*/
renderItem: PropTypes.func,
};
static defaultProps = {
data: [],
column: 4,
};
handleItemPress({ icon, text, onPress }, index, data) {
return () => onPress({ icon, text }, index, data);
}
renderItem = ({ icon, text, onPress }, index, data) => {
const { itemStyle, iconStyle, textStyle, column } = this.props;
const width = Dimensions.get("window").width / column;
const height = icon ? 150 / 2 : 80 / 2;
return (
<TouchableHighlight
key={index}
activeOpacity={1}
underlayColor={"#f5f5f5"}
onPress={this.handleItemPress({ icon, text, onPress }, index, data)}>
<View style={[styles.item, { width, height }, itemStyle]}>
{icon && (
<Image style={[styles.icon, iconStyle]} source={{ uri: icon }} />
)}
<Text style={[styles.text, textStyle]}>{text}</Text>
</View>
</TouchableHighlight>
);
};
render() {
const { data, style, renderItem } = this.props;
const items = data.map(renderItem || this.renderItem);
return <View style={[styles.wrapper, style]}>{items}</View>;
}
}
这里金刚位都是各种 icon,其实这个是 app 导航的命脉,所以要由数据库控制
采用如下的数据结构:

这样把事件回调函数统一管理起来!
这样的数据请求管理:
export const queryRecyclerIcons = async (): Promise<RecyclerIcons> => {
const data = await queryIcons();
const cateIcons: CateIconType[] = data?.map((icon) => ({
// 转为 Grid 组件的格式
...icon,
onPress: () => {},
icon: icon.image,
text: icon.title,
}));
// // 转换为 RecyclerListView 需要的格式
return {
icons: cateIcons,
width: windowWidth,
height: wrapperHeight,
type: "ICONS",
};
};
把数据请求方法写在本组件里面,axios 的请求写在 api 里面就行了!
至于控制渲染金刚位还是瀑布流,主要是这里没有固定一个项目的具体宽度(和之前的两栏固定 50%width 不同),而是采用记录所占区域的方式?