列表里没有数据,你打算显示什么?
很多开发者的第一反应是:什么都不显示呗,反正没数据。但用户看到的是一片空白,然后开始怀疑:加载完了吗?是不是出 bug 了?网络有问题?
一个精心设计的空状态,能把"什么都没有"变成一次沟通机会。它告诉用户三件事:
- 这里确实没有内容(不是 bug)
- 为什么没有内容(可选)
- 你可以做什么来改变这个状态(可选)
今天我们来看看 Empty 组件是怎么实现的。代码不多,但设计上有不少讲究。
源码全貌
先把完整代码贴出来,让你有个整体印象。文件位置:src/components/ui/Empty.tsx
tsx
import React from 'react';
import { View, Text, StyleSheet, ViewStyle } from 'react-native';
import { UITheme } from './theme';
import { Button } from './Button';
interface EmptyProps {
icon?: string;
title?: string;
description?: string;
action?: { label: string; onPress: () => void };
variant?: 'default' | 'compact' | 'large';
style?: ViewStyle;
}
export const Empty: React.FC<EmptyProps> = ({
icon = '📭',
title = '暂无数据',
description,
action,
variant = 'default',
style,
}) => {
const sizeMap: Record<string, { iconSize: number; titleSize: number; padding: number }> = {
compact: { iconSize: 32, titleSize: 14, padding: 16 },
default: { iconSize: 48, titleSize: 16, padding: 32 },
large: { iconSize: 64, titleSize: 18, padding: 48 },
};
const { iconSize, titleSize, padding } = sizeMap[variant];
return (
<View style={[styles.container, { padding }, style]}>
<Text style={[styles.icon, { fontSize: iconSize }]}>{icon}</Text>
<Text style={[styles.title, { fontSize: titleSize }]}>{title}</Text>
{description && <Text style={styles.description}>{description}</Text>}
{action && (
<Button
title={action.label}
onPress={action.onPress}
variant="outline"
size="sm"
style={{ marginTop: UITheme.spacing.lg }}
/>
)}
</View>
);
};
const styles = StyleSheet.create({
container: { alignItems: 'center', justifyContent: 'center' },
icon: { marginBottom: UITheme.spacing.md },
title: { fontWeight: '500', color: UITheme.colors.gray[600], textAlign: 'center' },
description: {
fontSize: UITheme.fontSize.sm,
color: UITheme.colors.gray[400],
textAlign: 'center',
marginTop: UITheme.spacing.xs,
},
});
50 行代码,一个完整的空状态组件。下面我们逐段拆解。
第一部分:依赖引入
tsx
import React from 'react';
import { View, Text, StyleSheet, ViewStyle } from 'react-native';
import { UITheme } from './theme';
import { Button } from './Button';
引入说明
View和Text:基础布局组件,Empty 的结构很简单,就是垂直排列的几个元素UITheme:主题配置,用于获取统一的颜色、间距、字号Button:复用项目里的按钮组件,用于"操作引导"功能
Empty 组件依赖了 Button,这是组件复用的好例子。空状态里的按钮不需要自己实现,直接用现成的。
第二部分:类型定义
tsx
interface EmptyProps {
icon?: string;
title?: string;
description?: string;
action?: { label: string; onPress: () => void };
variant?: 'default' | 'compact' | 'large';
style?: ViewStyle;
}
属性解读
属性 说明 为什么这样设计 icon图标,用 emoji 字符串 简单直接,不需要引入图标库 title主标题 告诉用户"这里是什么" description补充说明 解释"为什么是空的"或"怎么办" action操作按钮配置 引导用户采取行动 variant尺寸变体 适应不同大小的容器 style自定义样式 灵活性保障
所有属性都是可选的。这意味着你可以直接写 <Empty /> 就能得到一个可用的空状态。
第三部分:默认值设置
tsx
export const Empty: React.FC<EmptyProps> = ({
icon = '📭',
title = '暂无数据',
description,
action,
variant = 'default',
style,
}) => {
默认值策略
icon = '📭':邮箱图标,表示"空空如也"title = '暂无数据':最通用的空状态文案variant = 'default':中等尺寸,适合大多数场景
description和action没有默认值,因为它们是"增强功能",不是每个空状态都需要。
这组默认值的设计理念是:零配置可用。开发者不传任何参数,也能得到一个合理的空状态展示。
第四部分:尺寸映射表
tsx
const sizeMap: Record<string, { iconSize: number; titleSize: number; padding: number }> = {
compact: { iconSize: 32, titleSize: 14, padding: 16 },
default: { iconSize: 48, titleSize: 16, padding: 32 },
large: { iconSize: 64, titleSize: 18, padding: 48 },
};
const { iconSize, titleSize, padding } = sizeMap[variant];
尺寸配置详解
这个映射表定义了三种尺寸变体的具体数值:
compact紧凑模式
- 图标 32px,标题 14px,内边距 16px
- 适合空间有限的场景,比如列表项内部、侧边栏
default默认模式
- 图标 48px,标题 16px,内边距 32px
- 通用尺寸,适合大多数页面区域
large大号模式
- 图标 64px,标题 18px,内边距 48px
- 适合整页空状态,比如首次使用时的引导页
为什么用映射表而不是 switch 语句?因为映射表更简洁,而且方便后续扩展。如果要加一个 mini 尺寸,只需要在对象里加一行。
解构赋值 const { iconSize, titleSize, padding } = sizeMap[variant] 让后续代码更清晰,不用写 sizeMap[variant].iconSize 这种冗长的访问方式。
第五部分:渲染结构
tsx
return (
<View style={[styles.container, { padding }, style]}>
<Text style={[styles.icon, { fontSize: iconSize }]}>{icon}</Text>
<Text style={[styles.title, { fontSize: titleSize }]}>{title}</Text>
{description && <Text style={styles.description}>{description}</Text>}
{action && (
<Button
title={action.label}
onPress={action.onPress}
variant="outline"
size="sm"
style={{ marginTop: UITheme.spacing.lg }}
/>
)}
</View>
);
渲染逻辑拆解
整个组件的 DOM 结构非常简单,就是一个垂直居中的容器,里面放着:
- 图标层 - 用 Text 组件渲染 emoji,通过 fontSize 控制大小
- 标题层 - 主要信息,告诉用户当前状态
- 描述层 - 可选,用条件渲染
{description && ...}- 操作层 - 可选,复用 Button 组件
样式合并的写法 style={[styles.container, { padding }, style]} 是 React Native 的常见模式。数组里的样式会从左到右依次合并,后面的覆盖前面的。这样既保留了基础样式,又能动态调整 padding,还支持外部自定义。
条件渲染用的是短路求值:{description && <Text>...</Text>}。当 description 为空字符串或 undefined 时,整个表达式返回 falsy 值,React 不会渲染任何内容。
第六部分:样式定义
tsx
const styles = StyleSheet.create({
container: { alignItems: 'center', justifyContent: 'center' },
icon: { marginBottom: UITheme.spacing.md },
title: { fontWeight: '500', color: UITheme.colors.gray[600], textAlign: 'center' },
description: {
fontSize: UITheme.fontSize.sm,
color: UITheme.colors.gray[400],
textAlign: 'center',
marginTop: UITheme.spacing.xs,
},
});
样式设计思路
container容器样式
alignItems: 'center'水平居中justifyContent: 'center'垂直居中- 没有设置固定宽高,让组件自适应内容
icon图标样式
- 只设置了底部间距,字号在渲染时动态传入
- 使用主题变量
UITheme.spacing.md保持间距一致性
title标题样式
fontWeight: '500'中等粗细,不会太抢眼- 颜色用
gray[600],比正文稍浅,符合"次要信息"的定位
description描述样式
- 字号更小
fontSize.sm- 颜色更浅
gray[400]- 形成视觉层次:图标 > 标题 > 描述
整个样式表只有 4 个样式对象,非常精简。空状态组件不需要复杂的视觉效果,简洁清晰才是正道。
Demo 实战解析
看完组件实现,我们来看看实际怎么用。文件位置:src/screens/demos/EmptyDemo.tsx
最简用法
tsx
<Empty />
什么参数都不传,直接用。组件会显示默认的邮箱图标和"暂无数据"文字。这种写法适合快速原型开发,或者你懒得想文案的时候。
自定义内容
tsx
<Empty
icon="🔍"
title="未找到结果"
description="尝试使用其他关键词搜索"
/>
搜索场景的空状态。放大镜图标暗示"搜索",标题说明结果为空,描述给出建议。三层信息递进,用户一眼就能理解发生了什么。
带操作引导
tsx
<Empty
icon="📝"
title="暂无文章"
description="点击下方按钮创建第一篇文章"
action={{ label: '创建文章', onPress: () => {} }}
/>
这是空状态的"完全体"。不仅告诉用户没有内容,还提供了解决方案。action 属性接收一个对象,包含按钮文字和点击回调。
按钮使用了 variant="outline" 和 size="sm",这是组件内部的设计决策。outline 样式不会太抢眼,sm 尺寸和空状态的整体比例协调。
尺寸对比
tsx
<Empty variant="compact" title="紧凑模式" />
<View style={{ height: 24 }} />
<Empty variant="default" title="默认模式" />
<View style={{ height: 24 }} />
<Empty variant="large" title="大号模式" />
三种尺寸并排展示,方便对比选择。中间用
View加间距,避免挤在一起。实际项目中,你需要根据容器大小选择合适的 variant。
场景矩阵
tsx
<View style={styles.grid}>
<View style={styles.gridItem}>
<Empty icon="🛒" title="购物车为空" variant="compact" />
</View>
<View style={styles.gridItem}>
<Empty icon="💬" title="暂无消息" variant="compact" />
</View>
<View style={styles.gridItem}>
<Empty icon="❤️" title="暂无收藏" variant="compact" />
</View>
<View style={styles.gridItem}>
<Empty icon="📦" title="暂无订单" variant="compact" />
</View>
</View>
用网格布局展示多个场景。每个场景用不同的 emoji 图标,让用户一眼就能区分。这种展示方式也给开发者提供了灵感:原来空状态可以这么玩。
网格样式的实现:
tsx
const styles = StyleSheet.create({
grid: { flexDirection: 'row', flexWrap: 'wrap', marginHorizontal: -8 },
gridItem: { width: '50%', padding: 8 },
});
flexWrap: 'wrap'让子元素自动换行,width: '50%'实现两列布局。负 margin 配合子元素 padding 是经典的网格间距处理技巧。
什么时候该用空状态
空状态不是"没数据就显示"这么简单。以下几种情况特别需要精心设计:
首次使用
用户刚注册,什么数据都没有。这时候的空状态是引导用户的好机会。比如笔记应用可以显示"创建你的第一条笔记",配上醒目的按钮。
搜索无结果
用户主动搜索却没找到,心情可能有点沮丧。空状态应该给出建议:换个关键词试试?检查一下拼写?或者提供热门搜索推荐。
筛选后为空
用户设置了筛选条件,结果列表变空了。这时候要让用户知道"不是没有数据,是筛选条件太严格了",并提供清除筛选的快捷方式。
网络错误
严格来说这不算"空状态",但很多团队会复用 Empty 组件来处理。显示一个断网图标,加上"重试"按钮。
权限不足
用户没有权限查看某些内容。空状态可以解释原因,并引导用户申请权限或联系管理员。
设计上的取舍
这个 Empty 组件做了一些有意思的设计决策:
用 emoji 而不是图标库
优点是零依赖、跨平台一致、开发者容易上手。缺点是样式不可控、不能改颜色。对于空状态这种"点缀性"的场景,emoji 够用了。
固定的按钮样式
action 按钮强制使用 outline 和 sm,不允许自定义。这是为了保持视觉一致性。如果你需要不同样式的按钮,可以不用 action 属性,自己在外面包一层。
没有图片支持
有些空状态设计会用插画图片,这个组件不支持。如果需要,可以考虑扩展 icon 属性,让它支持 ReactNode 类型。
没有动画
空状态出现时没有淡入效果。对于这种静态展示组件,动画不是必需的,但加上会更精致。
实际项目中的用法
来看几个真实场景的代码示例。
订单列表为空
tsx
const OrderList = () => {
const [orders, setOrders] = useState([]);
if (orders.length === 0) {
return (
<Empty
icon="📦"
title="还没有订单"
description="去逛逛,发现喜欢的商品吧"
action={{
label: '去购物',
onPress: () => navigation.navigate('Shop')
}}
/>
);
}
return <FlatList data={orders} renderItem={...} />;
};
这是最常见的用法。先判断数据是否为空,为空就渲染 Empty,否则渲染正常列表。action 按钮引导用户去购物,形成闭环。
搜索结果为空
tsx
const SearchResult = ({ keyword, results }) => {
if (results.length === 0) {
return (
<Empty
icon="🔍"
title={`未找到"${keyword}"相关内容`}
description="换个关键词试试?"
variant="large"
/>
);
}
return <ResultList data={results} />;
};
搜索场景用 large 尺寸,因为搜索结果页通常是全屏的。标题里嵌入用户的搜索词,让反馈更具体。
收藏夹为空
tsx
const Favorites = () => {
const { favorites, isLoading } = useFavorites();
if (isLoading) {
return <Spinner />;
}
if (favorites.length === 0) {
return (
<Empty
icon="❤️"
title="收藏夹是空的"
description="看到喜欢的内容,点击爱心收藏"
/>
);
}
return <FavoriteGrid items={favorites} />;
};
注意这里先处理 loading 状态,再处理空状态。顺序很重要,不然用户会先看到空状态闪一下,体验不好。
嵌套在卡片里
tsx
const RecentActivity = () => {
const activities = [];
return (
<Card title="最近动态">
{activities.length > 0 ? (
<ActivityList data={activities} />
) : (
<Empty
variant="compact"
icon="📋"
title="暂无动态"
/>
)}
</Card>
);
};
空状态不一定要占满整个页面。在卡片、侧边栏这种小空间里,用 compact 尺寸刚刚好。
扩展思路
如果你觉得这个 Empty 组件功能不够,这里有几个扩展方向:
支持自定义图标组件
tsx
interface EmptyProps {
icon?: string | React.ReactNode; // 支持传入组件
// ...
}
这样就能用 SVG 图标或者 Lottie 动画了。
添加重试功能
tsx
interface EmptyProps {
onRetry?: () => void; // 重试回调
retryText?: string; // 重试按钮文字
// ...
}
网络错误场景特别需要这个。
支持插槽
tsx
interface EmptyProps {
children?: React.ReactNode; // 自定义底部内容
// ...
}
有时候你需要放两个按钮,或者放一段富文本,children 插槽能满足这种需求。
小结
Empty 组件的代码量很小,但覆盖了空状态的核心需求:
- 图标 + 标题 + 描述的信息层次
- 可选的操作按钮引导用户
- 三种尺寸适应不同场景
- 零配置可用的默认值
写空状态组件的关键不是技术实现,而是理解它的设计目的。空状态是产品和用户沟通的机会,别浪费了。
下次你的列表没数据时,别再显示一片空白了。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
