React Native FlatList 深度解析:ListEmptyComponent 居中与 onEndReached 防抖封装实战
本文深入解析 React Native 中 FlatList 组件的两个常见陷阱。首先分析 ListEmptyComponent 无法垂直居中的源码原因,通过 onLayout 获取高度并同步 State 解决 iOS 渲染时机差异导致的闪屏问题。其次探讨 onEndReachedThreshold 倍数机制引发的无限循环调用,提出通过 pageSize 取余判断及阈值优化进行二次封装的方案,提供稳定高效的列表处理策略。
FlatList 核心功能回顾
在 React Native 开发中,FlatList 是官方推荐的高性能列表组件,作为 ListView 的升级版,它在性能表现上有了显著提升。因此,在实现列表功能时,强烈建议使用 FlatList,避免使用已废弃的 ListView 或性能较差的 ScrollView。接下来我们将回顾 FlatList 支持的核心功能。
- 具备完全跨平台运行的能力。
- 支持水平方向的布局模式。
- 允许配置行组件显示或隐藏时的回调事件。
- 支持自定义单独的头部组件。
- 支持自定义单独的尾部组件。
- 支持自定义行与行之间的分隔线样式。
- 支持下拉刷新功能。
- 支持上拉加载更多数据。
- 支持通过 ScrollToIndex 跳转到指定行。
FlatList 二次封装与空列表居中解决方案
本文不赘述基础用法,具体使用示例可参考 GitHub 仓库 https://github.com/xiehui999/helloReactNative。文章重点在于分享实际开发中遇到的主要坑点,并基于此对 FlatList 进行二次封装。
首先通过一个简单示例展开讨论。
<FlatList
data={this.state.dataList} extraData={this.state}
refreshing={this.state.isRefreshing}
onRefresh={() => this._onRefresh()}
keyExtractor={(item, index) => item.id}
ItemSeparatorComponent={() => <View style={{
height: 1,
backgroundColor: '#D6D6D6'
}}/>}
renderItem={this._renderItem}
ListEmptyComponent={this.emptyComponent}/>
//定义空布局
emptyComponent = () => {
return <View style={{
height: '100%',
alignItems: 'center',
justifyContent: 'center',
}}>
<Text style={{
fontSize: 16
}}>暂无数据下拉刷新</Text>
</View>
}
在上述代码中,我们重点关注 ListEmptyComponent 属性,它用于定义无数据时的占位布局。通常我们希望提示信息居中显示,但直接运行后发现,文字虽然水平居中,却位于屏幕最上方,此时设置 height: '100%' 并未生效。即使尝试使用 flex: 1 填充剩余空间,依然无法达到垂直居中的效果。
为何设置无效?通过查阅源码(路径:react-native/Libraries/Lists)可以找到答案。在 FlatList 文件中搜索 ListEmptyComponent,发现其并未直接渲染,而是交由 render 方法处理。
render() {
if (this.props.legacyImplementation) {
return (
<MetroListView
{...this.props}
items={this.props.data}
ref={this._captureRef}
/>
);
} else {
return (
<VirtualizedList
{...this.props}
renderItem={this._renderItem}
getItem={this._getItem}
getItemCount={this._getItemCount}
keyExtractor={this._keyExtractor}
ref={this._captureRef}
onViewableItemsChanged={
this.props.onViewableItemsChanged && this._onViewableItemsChanged
}
/>
);
}
}
render 方法内部根据 legacyImplementation 属性决定渲染 MetroListView(旧版 ScrollView 实现)还是 VirtualizedList(新版高性能实现)。我们需查看 VirtualizedList 相关代码。
//省略部分代码
const itemCount = this.props.getItemCount(data);
if (itemCount > 0) {
....省略部分代码
} else if (ListEmptyComponent) {
const element = React.isValidElement(ListEmptyComponent)
? ListEmptyComponent // $FlowFixMe
: <ListEmptyComponent />;
cells.push(
/* $FlowFixMe(>=0.53.0 site=react_native_fb,react_native_oss) This
* comment suppresses an error when upgrading Flow's support for React.
* To see the error delete this comment and run Flow. */
<View
key="$empty"
onLayout={this._onLayoutEmpty}
style={inversionStyle}>
{element}
</View>,
);
}
在源码中可以看到,定义的 ListEmptyComponent 被包裹在一个添加了 inversionStyle 样式的 View 中。
const inversionStyle = this.props.inverted
? this.props.horizontal
? styles.horizontallyInverted
: styles.verticallyInverted
: null;
样式:
verticallyInverted: {
transform: [{scaleY: -1}],
},
horizontallyInverted: {
transform: [{scaleX: -1}],
},
该 inversionStyle 仅包含动画变换(如翻转),并未设置高度属性。因此,在 ListEmptyComponent 内部使用 height: '100%' 或 flex: 1 均无法撑开高度,导致布局异常。
要实现预期效果,需将高度设置为具体数值。由于 FlatList 默认通过 flex: 1 占满屏幕剩余高度,我们可以通过 onLayout 事件获取 FlatList 的实际高度,并将其赋值给 ListEmptyComponent 内部的 View。
代码调整如下:
iOS 端高度获取与 State 同步优化
//创建变量
fHeight = 0;
<FlatList
data={this.state.dataList} extraData={this.state}
refreshing={this.state.isRefreshing}
onRefresh={() => this._onRefresh()}
keyExtractor={(item, index) => item.id}
ItemSeparatorComponent={() => <View style={{
height: 1,
backgroundColor: '#D6D6D6'
}}/>}
renderItem={this._renderItem}
onLayout={e => this.fHeight = e.nativeEvent.layout.height}
ListEmptyComponent={this.emptyComponent}/>
//定义空布局
emptyComponent = () => {
return <View style={{
height: this.fHeight,
alignItems: 'center',
justifyContent: 'center',
}}>
<Text style={{
fontSize: 16
}}>暂无数据</Text>
</View>
}
经过上述调整,Android 端运行效果符合预期。但在 iOS 端表现不稳定,有时居中,有时仍置顶。原因是 iOS 上 onLayout 的调用时机与 Android 存在差异:在 iOS 上,当 emptyComponent 渲染时,onLayout 回调可能尚未触发,导致 fHeight 仍为初始值 0。
为了解决此问题,我们将测量得到的高度 fHeight 存入 State 中,以便在值更新后重新渲染组件。
state={
fHeight:0
}
onLayout={e => this.setState({fHeight: e.nativeEvent.layout.height})}
然而,Android 端效果完美,iOS 端却出现了频繁闪屏。打印日志发现,fHeight 在 0 和测量值之间反复切换。由于我们只需要最终的测量值,因此需修改 onLayout 逻辑,仅在高度发生变化时更新 State。
onLayout={e => {
let height = e.nativeEvent.layout.height;
if (this.state.fHeight < height) {
this.setState({fHeight: height})
}
}}
经过上述优化,iOS 端最终完美实现了列表为空时的垂直居中显示。
onEndReached 循环调用陷阱与阈值优化
除了上述问题,onEndReached 也是另一个常见坑点。实现下拉加载时通常会用到它,这必然涉及 onEndReachedThreshold 属性。在 FlatList 中,onEndReachedThreshold 为 number 类型,表示触发 onEndReached 时距离底部的距离。需注意,FlatList 与 ListView 中该属性的含义不同:ListView 中单位为像素,默认值为 1000;而 FlatList 中为倍数(比值),默认值为 2。
按照常规逻辑,我们可能编写如下实现:
<FlatList
data={this.state.dataList}
extraData={this.state}
refreshing={this.state.isRefreshing}
onRefresh={() => this._onRefresh()}
ItemSeparatorComponent={() => <View style={{
height: 1,
backgroundColor: '#D6D6D6'
}}/>}
renderItem={this._renderItem}
ListEmptyComponent={this.emptyComponent}
onEndReached={() => this._onEndReached()}
onEndReachedThreshold={0.1}/>
在 componentDidMount 中初始化加载第一页数据,下拉时执行 onEndReached 加载更多并更新 dataList。
componentDidMount() {
this._onRefresh()
}
看似完美,但运行后发现 onEndReached 会循环调用(或多次执行),直至数据加载完毕。原因在于 _onRefresh 请求数据需要时间,在数据返回前 render 方法已执行,此时因无数据触发一次 onEndReached,导致逻辑上相当于加载了两次数据。
onEndReached 的执行次数取决于 onEndReachedThreshold 的值。若将其误解为像素值并设置为较大数值(如 100),将导致严重问题。个人建议将该值设置为较小的倍数,如 0.1,以控制触发频率。
FlatList 综合封装实现
基于以上分析,有必要对 FlatList 进行二次封装,以满足特定业务需求。
import React, {
Component,
} from 'react'
import {
FlatList,
View,
StyleSheet,
ActivityIndicator,
Text
} from 'react-native'
import PropTypes from 'prop-types';
export const FlatListState = {
IDLE: 0,
LoadMore: 1,
Refreshing: 2
};
export default class Com extends Component {
static propTypes = {
refreshing: PropTypes.oneOfType([PropTypes.bool, PropTypes.number]),
};
state = {
listHeight: 0,
}
render() {
var {ListEmptyComponent,ItemSeparatorComponent} = this.props;
var refreshing = false;
var emptyContent = null;
var separatorComponent = null
if (ListEmptyComponent) {
emptyContent = React.isValidElement(ListEmptyComponent) ? ListEmptyComponent : <ListEmptyComponent/>
} else {
emptyContent = <Text style={styles.emptyText}>暂无数据下拉刷新</Text>;
}
if (ItemSeparatorComponent) {
separatorComponent = React.isValidElement(ItemSeparatorComponent) ? ItemSeparatorComponent :
<ItemSeparatorComponent/>
} else {
separatorComponent = <View style={{height: 1, backgroundColor: '#D6D6D6'}}/>;
}
if (typeof this.props.refreshing === "number") {
if (this.props.refreshing === FlatListState.Refreshing) {
refreshing = true
}
} else if (typeof this.props.refreshing === "boolean") {
refreshing = this.props.refreshing
} else if (typeof this.props.refreshing !== "undefined") {
refreshing = false
}
return <FlatList
{...this.props}
onLayout={(e) => {
let height = e.nativeEvent.layout.height;
if (this.state.listHeight < height) {
this.setState({listHeight: height})
}
}
}
ListFooterComponent={this.renderFooter}
onRefresh={this.onRefresh}
onEndReached={this.onEndReached}
refreshing={refreshing}
onEndReachedThreshold={this.props.onEndReachedThreshold || 0.1}
ItemSeparatorComponent={()=>separatorComponent}
keyExtractor={(item, index) => index}
ListEmptyComponent={() => <View
style={{
height: this.state.listHeight,
width: '100%',
alignItems: 'center',
justifyContent: 'center'
}}>{emptyContent}</View>}
/>
}
onRefresh = () => {
console.log("FlatList:onRefresh");
if ((typeof this.props.refreshing === "boolean" && !this.props.refreshing) ||
typeof this.props.refreshing === "number" && this.props.refreshing !== FlatListState.LoadMore &&
this.props.refreshing !== FlatListState.Refreshing
) {
this.props.onRefresh && this.props.onRefresh()
}
};
onEndReached = () => {
console.log("FlatList:onEndReached");
if (typeof this.props.refreshing === "boolean" || this.props.data.length == 0) {
return
}
if (!this.props.pageSize) {
console.warn("pageSize must be set");
return
}
if (this.props.data.length % this.props.pageSize !== 0) {
return
}
if (this.props.refreshing === FlatListState.IDLE) {
this.props.onEndReached && this.props.onEndReached()
}
};
renderFooter = () => {
let footer = null;
if (typeof this.props.refreshing !== "boolean" && this.props.refreshing === FlatListState.LoadMore) {
footer = (
<View style={styles.footerStyle}>
<ActivityIndicator size="small" color="#888888"/>
<Text style={styles.footerText}>数据加载中…</Text>
</View>
)
}
return footer;
}
}
const styles = StyleSheet.create({
footerStyle: {
flex: 1,
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
padding: 10,
height: 44,
},
footerText: {
fontSize: 14,
color: '#555555',
marginLeft: 7
},
emptyText: {
fontSize: 17,
color: '#666666'
}
})
在 propTypes 中,我们使用 oneOfType 对 refreshing 类型进行限定。若定义了 ListEmptyComponent,则使用自定义 View;同理,ItemSeparatorComponent 也支持自定义。
在下拉加载时,定义了 ListFooterComponent 以提示用户正在加载。refreshing 属性若为 boolean 类型,表示无下拉加载功能;若为 number 类型,则必须传入 pageSize。通过判断数据源长度与 pageSize 的余数是否为 0,来确定是否还有更多数据(仅当最后一次请求的数据量等于 pageSize 时才认为有更多数据,否则不回调 onEndReached)。上述封装逻辑简单清晰,此处不再赘述。以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持本站。
sigma.js 快速上手:在原生 JS 与 React 中绘制第一个网络图
2026-11-03 23:23:32
UmiJS 约定式路由配置与权限拦截实战
2026-11-03 22:12:08
JS前端文件读取FileReader操作方法总结
2026-10-31 15:12:21
JS面试必备:如何实现一个精确的倒计时
2026-10-31 12:14:23
从 Pages Router 迁移到 Next.js App Router 的注意事项
2026-10-30 19:14:52
