RN for OpenHarmony AnimeHub项目实战:评论页面开发
本文介绍了动漫评论页的实现,重点包括多评论展开收起状态管理和卡片布局设计。评论页展示用户头像、用户名、评分、标签和评论内容,支持独立展开收起。关键点包括:使用Set存储展开状态ID以提高性能,创建新Set保持不可变性,评论卡片布局采用左头像右评分结构,标签区域条件渲染,评论内容通过numberOfLines控制显示行数,并提供展开/收起按钮切换状态。这种设计模式适用于多条目独立交互的场景,如评论列

案例开源地址:https://atomgit.com/nutpi/Rn_openharmony_AnimeHub
评论页展示用户对动漫的评价和评论内容,是了解动漫口碑的重要渠道。这篇来讲评论页的实现,重点是多条评论的展开收起状态管理和评论卡片的布局设计。
功能设计
评论页需要展示以下内容:
- 用户信息 - 头像、用户名、评论日期
- 评分 - 用户给动漫的评分
- 标签 - 评论的分类标签,如"推荐"、"混合"等
- 评论内容 - 支持展开收起,因为评论通常很长
- 列表展示 - 多条评论的列表
这个页面的特点是每条评论都可以独立展开收起,需要管理多个展开状态。
状态定义
const { animeId, title } = route.params;
const [reviews, setReviews] = useState<Review[]>([]);
const [loading, setLoading] = useState(true);
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
状态说明:
animeId- 动漫 ID,用于请求数据title- 动漫标题,显示在 Header 副标题reviews- 评论列表数据loading- 加载状态expandedIds- 已展开的评论 ID 集合
这里用
Set<number>来存储已展开的评论 ID,而不是用数组或对象。Set 的优势是查找和删除操作都是 O(1) 时间复杂度,而且天然去重。
为什么不用单个 boolean 状态?因为页面上有多条评论,每条都可以独立展开收起。如果用单个 boolean,所有评论会同时展开或收起,这不是我们想要的效果。
数据加载
useEffect(() => {
loadReviews();
}, []);
const loadReviews = async () => {
try {
const data = await getAnimeReviews(animeId);
setReviews(data.data || []);
} catch (error) {
console.error('Load reviews error:', error);
} finally {
setLoading(false);
}
};
加载逻辑:
- 组件挂载时加载数据
- 用
data.data || []处理空数据 - 错误时打印日志,不影响页面显示
评论数据通常不会太多,API 默认返回的数量足够展示,所以这里没有做分页。如果需要分页,可以参考剧集列表页的实现。
展开收起逻辑
const toggleExpand = (id: number) => {
setExpandedIds(prev => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
};
切换逻辑详解:
- 用函数形式的 setState 获取最新状态
- 创建新的 Set 对象(保持不可变性)
- 如果 ID 已存在就删除(收起),否则添加(展开)
- 返回新的 Set 触发重渲染
为什么要创建新的 Set 而不是直接修改?因为 React 通过引用比较来判断状态是否变化。如果直接修改原 Set,引用没变,React 不会重渲染。创建新 Set 可以确保状态变化被检测到。
这个模式在管理多个独立状态时很常用。比如多选列表、多个折叠面板等场景都可以用这种方式。
评论卡片渲染
const renderItem = ({ item }: { item: Review }) => {
const isExpanded = expandedIds.has(item.mal_id);
return (
<View style={styles.reviewCard}>
渲染入口:
- 从 expandedIds 中查找当前评论是否展开
Set.has()方法返回 boolean,非常高效
每次渲染时都会检查
expandedIds.has(item.mal_id),这个操作是 O(1) 的,不会影响性能。
评论头部
<View style={styles.reviewHeader}>
<Image
source={{ uri: item.user.images?.jpg?.image_url }}
style={styles.avatar}
/>
<View style={styles.userInfo}>
<Text style={styles.username}>{item.user.username}</Text>
<Text style={styles.date}>
{new Date(item.date).toLocaleDateString()}
</Text>
</View>
<ScoreDisplay score={item.score} size="sm" />
</View>
头部布局:
- 左侧是用户头像,圆形
- 中间是用户名和日期,垂直排列
- 右侧是评分组件
这种布局在社交类应用中很常见,用户信息在左,操作或状态在右。用户名和日期用不同的字号和颜色区分主次。
ScoreDisplay是封装好的评分组件,这里用小号(sm)显示,不会太抢眼。
reviewHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: Spacing.md,
},
avatar: {
width: 40,
height: 40,
borderRadius: BorderRadius.full,
backgroundColor: Colors.backgroundLight,
},
头部样式:
- 横向布局,垂直居中
- 头像 40x40,圆形
- 设置背景色作为加载占位
userInfo: {
flex: 1,
marginLeft: Spacing.md,
},
username: {
fontSize: FontSize.md,
fontWeight: '600',
color: Colors.text,
},
date: {
fontSize: FontSize.xs,
color: Colors.textMuted,
marginTop: 2,
},
用户信息样式:
flex: 1占据中间空间- 用户名加粗,主要颜色
- 日期用最小字号,最淡颜色
标签区域
{item.tags && item.tags.length > 0 && (
<View style={styles.tags}>
{item.tags.map((tag, index) => (
<View key={index} style={styles.tag}>
<Text style={styles.tagText}>{tag}</Text>
</View>
))}
</View>
)}
标签显示:
- 条件渲染,没有标签时不显示
- 用 map 遍历渲染每个标签
- 用 index 作为 key
评论标签通常是"Recommended"(推荐)、“Mixed Feelings”(混合感受)、“Not Recommended”(不推荐)等。这些标签可以帮助用户快速了解评论的倾向。
tags: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: Spacing.xs,
marginBottom: Spacing.md,
},
tag: {
backgroundColor: Colors.backgroundLight,
paddingHorizontal: Spacing.sm,
paddingVertical: 2,
borderRadius: BorderRadius.sm,
},
tagText: {
fontSize: FontSize.xs,
color: Colors.textSecondary,
},
标签样式:
- 横向排列,自动换行
- 小圆角,灰色背景
- 紧凑的内边距
标签用灰色背景而不是彩色,是为了不分散用户对评论内容的注意力。标签只是辅助信息,不应该太显眼。
评论内容
<Text
style={styles.reviewText}
numberOfLines={isExpanded ? undefined : 5}
>
{item.review}
</Text>
<Text
style={styles.expandButton}
onPress={() => toggleExpand(item.mal_id)}
>
{isExpanded ? '收起' : '展开全文'}
</Text>
</View>
);
};
评论内容和展开按钮:
numberOfLines根据展开状态动态设置- 展开时 undefined 表示不限制行数
- 收起时限制为 5 行
- 按钮文字根据状态变化
评论内容通常很长,可能有几百甚至上千字。默认只显示 5 行可以让用户快速浏览多条评论,感兴趣的再点击展开。
这里用
Text组件的onPress属性而不是TouchableOpacity,是因为只需要简单的点击效果。Text 的 onPress 也支持点击,而且代码更简洁。
reviewText: {
fontSize: FontSize.md,
color: Colors.textSecondary,
lineHeight: 22,
},
expandButton: {
fontSize: FontSize.sm,
color: Colors.primary,
marginTop: Spacing.sm,
},
评论内容样式:
- 正常字号,次要颜色
lineHeight: 22增加行高,阅读更舒适- 展开按钮用主题色,表示可点击
页面结构
return (
<View style={styles.container}>
<Header
title="用户评论"
subtitle={title}
showBack
onBack={() => navigation.goBack()}
/>
Header 配置:
- 标题显示"用户评论"
- 副标题显示动漫名称
- 显示返回按钮
条件渲染
{loading ? (
<Loading fullScreen />
) : reviews.length === 0 ? (
<EmptyState icon="comment" title="暂无评论" />
) : (
<FlatList
data={reviews}
renderItem={renderItem}
keyExtractor={(item) => item.mal_id.toString()}
contentContainerStyle={styles.list}
showsVerticalScrollIndicator={false}
/>
)}
</View>
);
三种状态:
- 加载中显示 Loading
- 无评论显示空状态,用评论图标
- 有评论显示列表
空状态用 comment 图标,和评论主题呼应。提示文字"暂无评论"简洁明了。
FlatList 配置
<FlatList
data={reviews}
renderItem={renderItem}
keyExtractor={(item) => item.mal_id.toString()}
contentContainerStyle={styles.list}
showsVerticalScrollIndicator={false}
/>
配置说明:
- 用评论 ID 作为 key
- 隐藏滚动条
- 没有分页加载,因为评论数量通常不多
这个页面没有用
onEndReached做分页,因为 API 返回的评论数量通常在 20 条以内,一次性加载即可。如果评论很多,可以考虑加分页。
评论卡片样式
reviewCard: {
backgroundColor: Colors.backgroundCard,
borderRadius: BorderRadius.lg,
padding: Spacing.lg,
marginBottom: Spacing.md,
},
卡片样式:
- 卡片背景色,圆角
- 内边距让内容不贴边
- 卡片之间有间距
每条评论用独立的卡片展示,视觉上分隔清晰。用户可以很容易区分不同的评论。
列表容器样式
list: {
padding: Spacing.lg,
},
容器样式:
只设置了 padding,让列表内容和屏幕边缘有间距。
Set 数据结构的优势
用 Set 管理展开状态有几个优势:
- 查找高效 -
has()方法是 O(1) 时间复杂度 - 天然去重 - 不会出现重复 ID
- 语义清晰 - Set 本身就表示"集合"的概念
如果用数组,查找需要遍历整个数组,是 O(n) 时间复杂度。虽然评论数量不多时差别不大,但用 Set 是更好的实践。
如果用对象
{ [id]: boolean },也可以实现类似效果,但 Set 的 API 更简洁,不需要处理true/false值。
与其他展开收起的对比
项目中有多个地方用到了展开收起功能:
- 动漫详情页简介 - 单个文本,用 boolean 状态
- 角色详情页简介 - 单个文本,用 boolean 状态
- 评论页 - 多条评论,用 Set 状态
单个文本用 boolean 就够了,多个独立的文本需要用 Set 或类似的数据结构。选择合适的数据结构可以让代码更简洁高效。
评论数据结构
评论的数据结构在 types 中定义:
interface Review {
mal_id: number;
user: {
username: string;
images: {
jpg: {
image_url: string;
};
};
};
score: number;
date: string;
review: string;
tags: string[];
}
字段说明:
mal_id- 评论 IDuser- 用户信息,包含用户名和头像score- 用户给的评分date- 评论日期review- 评论内容tags- 评论标签数组
用户信息是嵌套结构,和其他 API 返回的数据结构类似。这种设计可以复用用户信息的类型定义。
小结
评论页的核心是多条评论的展开收起状态管理。用 Set 数据结构存储已展开的评论 ID,查找和修改都很高效。
评论卡片的布局采用常见的社交应用模式:头像在左,用户信息在中,评分在右。标签和评论内容依次排列,展开按钮在最下方。
评论内容默认只显示 5 行,用户可以点击展开查看全文。这种设计可以让用户快速浏览多条评论,找到感兴趣的再深入阅读。
下一篇会讲推荐页面,展示和当前动漫相似的其他作品。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐

所有评论(0)