HarmonyOS应用<节气通>开发第20篇:ArticleCard组件封装

引言
组件封装是提高代码复用性和可维护性的关键。在HarmonyOS应用开发中,组件化思想尤为重要——一个设计良好的组件不仅能提升开发效率,还能确保UI一致性和代码可测试性。
本文将深入探讨如何封装一个ArticleCard文章卡片组件,涵盖:
- 组件设计原则:单一职责、可复用性、可配置性
- 状态管理策略:@Prop、@State的合理使用
- 事件处理机制:父子组件通信模式
- 性能优化技巧:避免不必要渲染、懒加载
通过本文,你将掌握HarmonyOS组件封装的核心技术和最佳实践。
学习目标
完成本文后,你将能够:
- ✅ 理解组件封装的基本原则(单一职责、开闭原则)
- ✅ 设计合理的组件API(Props、Events、Slots)
- ✅ 实现ArticleCard组件的完整功能
- ✅ 掌握组件状态管理(@Prop、@State、@Link)
- ✅ 实现组件复用和变体设计
- ✅ 优化组件性能(懒加载、避免重渲染)
需求分析
组件功能设计
| 功能 | 描述 | 技术要点 |
|---|---|---|
| 文章封面 | 展示文章缩略图,支持懒加载 | Image组件、onAppear回调 |
| 文章标题 | 展示文章标题,支持行数限制 | Text组件、maxLines |
| 文章摘要 | 展示文章简介,支持省略号 | Text组件、textOverflow |
| 作者信息 | 展示作者头像和名称 | Row布局、圆形头像 |
| 阅读量 | 展示文章阅读次数,支持格式化 | 图标+数字、千分位格式化 |
| 收藏按钮 | 收藏/取消收藏,支持状态切换 | 事件冒泡控制、状态同步 |
| 分类标签 | 展示文章分类 | 条件渲染、圆角背景 |
组件设计原则
1. 单一职责原则
- ArticleCard只负责展示文章卡片
- 不处理数据请求和业务逻辑
- 通过Props接收数据,通过Events触发回调
2. 开闭原则
- 对扩展开放:支持多种卡片变体
- 对修改封闭:核心逻辑稳定不变
3. 可配置性原则
- 支持自定义样式(颜色、尺寸、圆角)
- 支持自定义事件处理
- 支持条件渲染(是否显示收藏按钮、分类标签等)
技术原理深度解析
组件通信机制
父子组件通信模式:
父组件 (ArticleListPage)
│
├── Props (数据向下传递)
│ ↓
│ ArticleCard
│ │
└── Events (事件向上传递)
↑
ArticleCard
Props传递原理:
@Prop:单向绑定,父组件更新时子组件自动更新@State:组件内部状态,不影响父组件@Link:双向绑定,父子组件状态同步
事件触发机制:
// 子组件定义事件
@Prop onCollect: (id: string, collected: boolean) => void = () => {};
// 子组件触发事件
this.onCollect(this.id, !this.isCollected);
// 父组件监听事件
onCollect: (id: string, collected: boolean) => {
// 处理收藏逻辑
}
事件冒泡与阻止
事件冒泡原理:
Card (外层)
└── Image (收藏按钮)
当点击收藏按钮时,事件会从Image冒泡到Card,可能触发Card的点击事件。
阻止冒泡方法:
Image(this.isCollected ? $r('app.media.ic_collect_active') : $r('app.media.ic_collect'))
.onClick((event: ClickEvent) => {
event.stopPropagation(); // ✅ 阻止事件冒泡
this.onCollect(this.id, !this.isCollected);
})
状态管理策略
状态分类:
| 状态类型 | 作用域 | 数据来源 | 更新方式 |
|---|---|---|---|
| 外部状态 | 父子共享 | 父组件 | @Prop单向传递 |
| 内部状态 | 组件内部 | 自身初始化 | @State自管理 |
| 计算状态 | 组件内部 | 依赖其他状态 | 函数计算 |
状态更新原则:
- 展示型状态(如标题、摘要)使用
@Prop - 交互型状态(如是否收藏)使用
@Prop或@Link - 派生状态(如格式化阅读量)使用计算函数
核心实现
步骤1: 组件接口定义
// components/ArticleCard.ets
/**
* 文章卡片组件接口定义
*/
export interface ArticleCardProps {
// 文章ID(必需)
id: string;
// 文章标题(必需)
title: string;
// 文章摘要(可选)
summary?: string;
// 封面图片(可选)
cover?: string;
// 作者名称(必需)
author: string;
// 作者头像(可选)
authorAvatar?: string;
// 阅读量(可选,默认0)
views?: number;
// 是否收藏(可选,默认false)
isCollected?: boolean;
// 文章分类(可选)
category?: string;
// 发布时间(可选)
publishTime?: string;
// 点击事件(可选)
onClick?: () => void;
// 收藏事件(可选)
onCollect?: (id: string, collected: boolean) => void;
// 是否显示收藏按钮(可选,默认true)
showCollect?: boolean;
// 是否显示分类标签(可选,默认true)
showCategory?: boolean;
}
/**
* 文章卡片组件
*/
@Component
export struct ArticleCard {
// 使用接口定义Props
private props: ArticleCardProps = {
id: '',
title: '',
author: ''
};
// 内部状态:图片加载状态
@State isImageLoaded: boolean = false;
/**
* 格式化阅读量
*/
private formatViews(count: number): string {
if (count >= 10000) {
return (count / 10000).toFixed(1) + '万';
} else if (count >= 1000) {
return (count / 1000).toFixed(1) + 'k';
}
return count.toString();
}
/**
* 获取默认值
*/
private get defaultProps(): Required<ArticleCardProps> {
return {
id: '',
title: '',
summary: '',
cover: '',
author: '',
authorAvatar: '',
views: 0,
isCollected: false,
category: '',
publishTime: '',
onClick: () => {},
onCollect: () => {},
showCollect: true,
showCategory: true
};
}
/**
* 合并Props(带默认值)
*/
private get mergedProps(): Required<ArticleCardProps> {
return { ...this.defaultProps, ...this.props };
}
/**
* 构建UI
*/
build() {
const { id, title, summary, cover, author, authorAvatar, views,
isCollected, category, publishTime, onClick, onCollect,
showCollect, showCategory } = this.mergedProps;
Card() {
Column({ space: 12 }) {
// 1. 封面图片区域
this.buildCoverArea(cover, category, isCollected, id, onCollect, showCollect, showCategory)
// 2. 内容区域
this.buildContentArea(title, summary)
// 3. 底部信息
this.buildBottomArea(author, authorAvatar, views, publishTime)
}
}
.width('100%')
.onClick(() => {
onClick?.();
})
}
}
设计要点:
- 使用TypeScript接口定义Props,提供类型安全
- 支持默认值配置,增强组件易用性
- 使用解构赋值简化代码
- 支持条件渲染控制(showCollect、showCategory)
步骤1.5: Props设计原则
Props设计最佳实践:
// ✅ 正确:使用接口定义,区分必需和可选属性
interface ArticleCardProps {
id: string; // 必需属性,无默认值
title: string; // 必需属性
summary?: string; // 可选属性
views?: number; // 可选属性
onClick?: () => void; // 可选事件
}
// ❌ 错误:所有属性都可选,缺乏类型约束
interface BadProps {
id?: string;
title?: string;
}
Props命名规范:
- 使用驼峰命名法(camelCase)
- 布尔类型使用is/has前缀(如isCollected、showCollect)
- 事件使用on前缀(如onClick、onCollect)
默认值处理策略:
// 方式1:在接口中设置默认值(仅适用于可选属性)
interface Props {
views?: number = 0; // ❌ 不支持
}
// 方式2:在组件内部设置默认值
private defaultProps = {
views: 0,
isCollected: false
};
// 方式3:使用解构赋值设置默认值
const { views = 0, isCollected = false } = this.props;
步骤2: 封面图片区域(带懒加载)
/**
* 构建封面图片区域
*/
@Builder
buildCoverArea(
cover: string,
category: string,
isCollected: boolean,
id: string,
onCollect: (id: string, collected: boolean) => void,
showCollect: boolean,
showCategory: boolean
): void {
Stack({ alignContent: Alignment.TopStart }) {
// 占位图(加载中显示)
Image($r('app.media.ic_placeholder'))
.width('100%')
.height(160)
.objectFit(ImageFit.Cover)
.borderRadius({ topLeft: 12, topRight: 12 })
.opacity(this.isImageLoaded ? 0 : 1)
.transition({ type: TransitionType.Opacity, duration: 300 })
// 封面图片(懒加载)
if (cover) {
Image(cover)
.width('100%')
.height(160)
.objectFit(ImageFit.Cover)
.borderRadius({ topLeft: 12, topRight: 12 })
.opacity(this.isImageLoaded ? 1 : 0)
.transition({ type: TransitionType.Opacity, duration: 300 })
.onComplete(() => {
this.isImageLoaded = true;
})
.onError(() => {
this.isImageLoaded = true; // 加载失败也显示占位图
})
}
// 分类标签(条件渲染)
if (showCategory && category) {
Text(category)
.fontSize(12)
.fontColor('#FFFFFF')
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.backgroundColor('#4A9B6D')
.borderRadius(4)
.margin({ left: 12, top: 12 })
}
// 收藏按钮(条件渲染)
if (showCollect) {
Image(isCollected ? $r('app.media.ic_collect_active') : $r('app.media.ic_collect'))
.width(28)
.height(28)
.fillColor(isCollected ? '#FF5252' : '#FFFFFF')
.backgroundColor('#00000040')
.borderRadius(14)
.padding(6)
.position({ right: 12, top: 12 })
.onClick((event: ClickEvent) => {
event.stopPropagation();
onCollect(id, !isCollected);
})
}
}
}
设计要点:
- 懒加载实现: 使用opacity过渡动画,加载完成后显示实际图片
- 条件渲染: 通过showCollect、showCategory控制元素显示
- 错误处理: onError回调确保加载失败时也能正常显示
- 事件冒泡控制: stopPropagation防止触发父组件点击事件
- 过渡动画: 使用transition实现平滑的图片切换效果
步骤2.5: 图片懒加载原理
懒加载工作流程:
1. 组件渲染时,只显示占位图(isImageLoaded = false)
2. 图片开始加载
3. 图片加载完成(onComplete)→ isImageLoaded = true → 显示实际图片
4. 图片加载失败(onError)→ isImageLoaded = true → 保持占位图显示
性能优势:
- 减少初始加载时间
- 节省带宽(未进入视口的图片不加载)
- 提升用户体验(占位图提供视觉反馈)
高级优化: 结合IntersectionObserver实现真正的懒加载
@Component
struct LazyImage {
@Prop src: string = '';
@State isVisible: boolean = false;
@State isLoaded: boolean = false;
aboutToAppear() {
// 使用IntersectionObserver监听元素是否进入视口
const observer = IntersectionObserver.create({
thresholds: [0.1],
callback: (entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.isVisible = true;
observer.disconnect(); // 停止监听
}
});
}
});
// 监听当前组件
observer.observe(this);
}
build() {
Stack() {
Image($r('app.media.ic_placeholder'))
.opacity(this.isLoaded ? 0 : 1)
if (this.isVisible) {
Image(this.src)
.opacity(this.isLoaded ? 1 : 0)
.onComplete(() => {
this.isLoaded = true;
})
}
}
}
}
步骤3: 内容区域(支持自定义样式)
/**
* 构建内容区域
*/
@Builder
buildContentArea(title: string, summary: string): void {
Column({ space: 8 }) {
// 标题
Text(title)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.lineHeight(24) // 增加行高提升可读性
// 摘要(条件渲染)
if (summary) {
Text(summary)
.fontSize(14)
.fontColor('#666666')
.lineHeight(22)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
}
.padding({ left: 12, right: 12 })
}
设计要点:
- 条件渲染: 摘要为空时不渲染
- 行高优化: 标题行高24px,摘要行高22px,提升可读性
- 省略号处理: 使用textOverflow实现超出部分省略
文本截断原理:
Text(content)
.maxLines(2) // 最多显示2行
.textOverflow({ overflow: TextOverflow.Ellipsis }) // 超出部分显示省略号
性能考虑:
- 文本截断在渲染时计算,避免大量文本导致性能问题
- 建议后端返回时已截断的摘要,减少前端处理压力
步骤4: 底部信息区域(带时间格式化)
/**
* 构建底部信息区域
*/
@Builder
buildBottomArea(author: string, authorAvatar: string, views: number, publishTime: string): void {
Row({ space: 12 }) {
// 作者信息
Row({ space: 8 }) {
Image(authorAvatar || $r('app.media.ic_default_avatar'))
.width(28)
.height(28)
.borderRadius(14)
.objectFit(ImageFit.Cover) // 确保头像正确显示
Column({ space: 2 }) {
Text(author)
.fontSize(13)
.fontColor('#333333')
Text(this.formatTime(publishTime))
.fontSize(11)
.fontColor('#BBBBBB')
}
}
.flexGrow(1)
// 阅读量
Row({ space: 4 }) {
Image($r('app.media.ic_eye'))
.width(16)
.height(16)
.fillColor('#999999')
Text(this.formatViews(views))
.fontSize(13)
.fontColor('#999999')
}
}
.padding({ left: 12, right: 12, bottom: 12 })
}
/**
* 格式化时间显示
*/
private formatTime(timeStr: string): string {
if (!timeStr) return '';
const now = new Date();
const publishDate = new Date(timeStr);
const diff = now.getTime() - publishDate.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor(diff / (1000 * 60));
if (minutes < 60) {
return `${minutes}分钟前`;
} else if (hours < 24) {
return `${hours}小时前`;
} else if (days < 30) {
return `${days}天前`;
} else {
return timeStr.substring(5, 10); // 返回 MM-DD 格式
}
}
设计要点:
- 时间格式化: 根据时间差显示不同格式(X分钟前、X小时前、X天前)
- 头像处理: 使用objectFit确保头像正确填充
- flexGrow: 作者信息占据剩余空间,阅读量右对齐
时间格式化策略:
时间差 < 60分钟 → X分钟前
60分钟 ≤ 时间差 < 24小时 → X小时前
24小时 ≤ 时间差 < 30天 → X天前
时间差 ≥ 30天 → MM-DD格式
步骤5: 使用ArticleCard组件(完整示例)
// pages/ArticleListPage.ets
import router from '@ohos.router';
@Entry
@Component
struct ArticleListPage {
@State articles: Article[] = [];
@State loading: boolean = true;
aboutToAppear() {
this.loadArticles();
}
async loadArticles() {
try {
this.articles = await ArticleService.getArticles();
} catch (error) {
console.error('Failed to load articles:', error);
} finally {
this.loading = false;
}
}
build() {
Column() {
// 加载状态
if (this.loading) {
LoadingProgress()
.width(40)
.height(40)
.margin({ top: 100 })
} else {
// 文章列表
List({ space: 12 }) {
ForEach(this.articles, (article: Article) => {
ListItem() {
ArticleCard({
props: {
id: article.id,
title: article.title,
summary: article.summary,
cover: article.cover,
author: article.author,
authorAvatar: article.authorAvatar,
views: article.views,
isCollected: article.isCollected,
category: article.category,
publishTime: article.publishTime,
onClick: () => {
router.pushUrl({
url: 'pages/ArticleDetail',
params: { id: article.id }
});
},
onCollect: (id: string, collected: boolean) => {
this.handleCollect(id, collected);
},
showCollect: true,
showCategory: true
}
})
}
}, (article: Article) => article.id)
}
.width('92%')
.padding({ top: 12 })
}
}
.width('100%')
.height('100%')
}
handleCollect(id: string, collected: boolean) {
const article = this.articles.find((a) => a.id === id);
if (article) {
article.isCollected = collected;
// 同步到后端
ArticleService.updateCollectStatus(id, collected);
}
}
}
interface Article {
id: string;
title: string;
summary?: string;
cover?: string;
author: string;
authorAvatar?: string;
views?: number;
isCollected?: boolean;
category?: string;
publishTime?: string;
}
使用要点:
- 数据加载: 在aboutToAppear中异步加载数据
- 状态管理: 使用@State管理文章列表和加载状态
- 事件处理: 点击跳转到详情页,收藏更新状态并同步后端
- 错误处理: try-catch捕获加载异常
实际应用场景
场景1: 列表页使用ArticleCard
// pages/ArticleListPage.ets
@Entry
@Component
struct ArticleListPage {
@State articles: Article[] = [];
@State loading: boolean = true;
@State page: number = 1;
aboutToAppear() {
this.loadArticles();
}
async loadArticles() {
try {
const data = await ArticleService.getArticles(this.page, 10);
this.articles = [...this.articles, ...data];
} catch (error) {
console.error('Failed to load articles:', error);
} finally {
this.loading = false;
}
}
build() {
Column() {
// 下拉刷新
Refresh({ refreshing: this.loading, onRefresh: () => {
this.page = 1;
this.articles = [];
this.loadArticles();
} }) {
List({ space: 12, initialIndex: 0 }) {
ForEach(this.articles, (article) => {
ListItem() {
ArticleCard({
props: {
...article,
onClick: () => {
router.pushUrl({ url: 'pages/ArticleDetail', params: { id: article.id } });
},
onCollect: (id, collected) => {
const item = this.articles.find(a => a.id === id);
if (item) item.isCollected = collected;
}
}
})
}
.onAppear(() => {
// 触底加载更多
if (article === this.articles[this.articles.length - 1] && !this.loading) {
this.page++;
this.loadArticles();
}
})
}, (article) => article.id)
}
}
}
}
}
场景2: 首页精选文章展示
// components/HomeContent.ets
@Component
export struct HomeContent {
@State featuredArticles: Article[] = [];
async aboutToAppear() {
this.featuredArticles = await ArticleService.getFeaturedArticles(3);
}
build() {
Column({ space: 16 }) {
// 精选标题
Row({ space: 8 }) {
Text('精选文章')
.fontSize(18)
.fontWeight(FontWeight.Bold)
Text('查看更多')
.fontSize(14)
.fontColor('#4CAF50')
}
.padding({ left: 16, right: 16 })
// 横向滚动的文章卡片
Scroll({ direction: Axis.Horizontal }) {
Row({ space: 12 }) {
ForEach(this.featuredArticles, (article) => {
ArticleCard({
props: {
...article,
showCollect: false, // 精选区域不显示收藏按钮
onClick: () => {
router.pushUrl({ url: 'pages/ArticleDetail', params: { id: article.id } });
}
}
})
.width(280)
}, (article) => article.id)
}
.padding({ left: 16, right: 16 })
}
}
}
}
场景3: 收藏列表页面
// pages/CollectionPage.ets
@Entry
@Component
struct CollectionPage {
@State collections: Article[] = [];
aboutToAppear() {
this.loadCollections();
}
async loadCollections() {
this.collections = await ArticleService.getCollections();
}
build() {
Column() {
if (this.collections.length === 0) {
EmptyState({
icon: $r('app.media.ic_empty_collect'),
title: '暂无收藏',
desc: '去发现精彩内容吧'
})
} else {
List({ space: 12 }) {
ForEach(this.collections, (article) => {
ListItem() {
ArticleCard({
props: {
...article,
isCollected: true,
onCollect: (id, collected) => {
// 取消收藏
if (!collected) {
this.collections = this.collections.filter(a => a.id !== id);
ArticleService.removeCollection(id);
}
}
}
})
}
}, (article) => article.id)
}
.width('92%')
}
}
}
}
组件变体设计
紧凑版卡片(ArticleCardCompact)
适用于列表展示场景,横向布局节省空间。
/**
* 文章卡片组件(紧凑版)
*/
@Component
export struct ArticleCardCompact {
@Prop article: Article = {} as Article;
@Prop onClick: () => void = () => {};
build() {
Row({ space: 12 }) {
// 封面图片
Image(this.article.cover || $r('app.media.ic_default_cover'))
.width(80)
.height(80)
.objectFit(ImageFit.Cover)
.borderRadius(8)
// 内容
Column({ space: 6 }) {
Text(this.article.title)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 12 }) {
Text(this.article.author)
.fontSize(12)
.fontColor('#999999')
Text(this.formatTime(this.article.publishTime))
.fontSize(12)
.fontColor('#BBBBBB')
}
}
.flexGrow(1)
}
.width('100%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(8)
.onClick(() => {
this.onClick();
})
}
private formatTime(timeStr: string): string {
if (!timeStr) return '';
return timeStr.substring(5, 10);
}
}
大图版卡片(ArticleCardLarge)
适用于详情页推荐,突出封面图片。
/**
* 文章卡片组件(大图版)
*/
@Component
export struct ArticleCardLarge {
@Prop article: Article = {} as Article;
@Prop onClick: () => void = () => {};
build() {
Column({ space: 16 }) {
// 大图封面
Stack({ alignContent: Alignment.BottomStart }) {
Image(this.article.cover || $r('app.media.ic_default_cover'))
.width('100%')
.height(200)
.objectFit(ImageFit.Cover)
.borderRadius(12)
// 渐变遮罩 + 标题
Row() {
Text(this.article.title)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
.padding(16)
.backgroundColor('#00000040')
.borderRadius({ bottomLeft: 12, bottomRight: 12 })
}
// 底部信息
Row({ space: 8 }) {
Image(this.article.authorAvatar || $r('app.media.ic_default_avatar'))
.width(32)
.height(32)
.borderRadius(16)
Column({ space: 4 }) {
Text(this.article.author)
.fontSize(14)
.fontColor('#333333')
Text(`${this.formatViews(this.article.views || 0)}阅读 · ${this.formatTime(this.article.publishTime || '')}`)
.fontSize(12)
.fontColor('#999999')
}
}
}
.width('100%')
.onClick(() => {
this.onClick();
})
}
private formatViews(count: number): string {
if (count >= 10000) return (count / 10000).toFixed(1) + '万';
if (count >= 1000) return (count / 1000).toFixed(1) + 'k';
return count.toString();
}
private formatTime(timeStr: string): string {
if (!timeStr) return '';
return timeStr.substring(5, 10);
}
}
常见问题与解决方案
问题1: 图片加载失败显示空白
现象:
封面图片加载失败,显示空白区域。
原因:
- 图片URL无效或过期
- 网络请求失败
- 没有设置占位图
解决方案:
// ✅ 使用默认占位图 + onError回调
Image(this.cover || $r('app.media.ic_default_cover'))
.onError(() => {
// 加载失败时可以显示错误提示或占位图
console.error('Image load failed:', this.cover);
})
最佳实践:
- 始终提供默认占位图
- 监听onError回调处理异常
- 后端返回图片时进行有效性校验
问题2: 收藏状态不同步
现象:
点击收藏按钮后,UI状态改变,但后端数据未同步。
原因:
- 忘记调用后端API
- API调用失败未处理
- 网络延迟导致状态不一致
解决方案:
onCollect: (id: string, collected: boolean) => {
// 1. 先更新本地状态(快速响应用户)
const article = this.articles.find(a => a.id === id);
if (article) article.isCollected = collected;
// 2. 同步到后端
ArticleService.updateCollectStatus(id, collected)
.catch((error) => {
// 3. 同步失败时回滚状态
console.error('Update collect status failed:', error);
if (article) article.isCollected = !collected;
});
}
问题3: 大量卡片渲染性能差
现象:
列表中有大量ArticleCard时,滚动卡顿。
原因:
- 每个卡片都有独立的图片请求
- 复杂布局导致渲染开销大
- 没有使用虚拟化列表
解决方案:
// ✅ 使用LazyForEach优化
List({ space: 12 }) {
LazyForEach(this.articleDataSource, (article) => {
ListItem() {
ArticleCard({ props: article })
}
}, (article) => article.id)
}
.onScrollIndex((start, end) => {
// 只加载可视区域内的图片
console.log(`Visible items: ${start} - ${end}`);
})
优化策略:
- 使用LazyForEach替代ForEach
- 图片懒加载(IntersectionObserver)
- 减少卡片内部嵌套层级
问题4: Props传递错误
现象:
组件显示异常,属性值未正确传递。
原因:
- Props名称拼写错误
- 属性类型不匹配
- 可选属性未设置默认值
解决方案:
// ✅ 使用接口定义和默认值
interface ArticleCardProps {
title: string;
summary?: string; // 可选属性
views?: number; // 可选属性
}
// 设置默认值
private get defaultProps(): Required<ArticleCardProps> {
return {
title: '',
summary: '',
views: 0
};
}
// 合并Props
private get mergedProps(): Required<ArticleCardProps> {
return { ...this.defaultProps, ...this.props };
}
性能优化细节
优化1: 避免不必要的重新渲染
问题: 父组件状态变化时,所有子组件都重新渲染。
解决方案:
// ❌ 不好:每次build都创建新函数
@Builder
buildCoverArea() {
Image(this.cover)
.onClick(() => {
// 每次都创建新的回调函数
})
}
// ✅ 好:使用稳定的函数引用
@Component
struct ArticleCard {
private handleCollect = (id: string, collected: boolean) => {
this.onCollect(id, collected);
};
@Builder
buildCoverArea() {
Image(this.cover)
.onClick(this.handleCollect) // 稳定引用
}
}
优化2: 图片缓存策略
问题: 相同图片重复请求。
解决方案:
// 实现图片缓存服务
class ImageCacheService {
private cache = new Map<string, ImageSource>();
async getImage(url: string): Promise<ImageSource> {
// 检查缓存
if (this.cache.has(url)) {
return this.cache.get(url)!;
}
// 请求图片
const response = await fetch(url);
const buffer = await response.arrayBuffer();
const imageSource = new ImageSource(buffer);
// 存入缓存(设置过期时间)
this.cache.set(url, imageSource);
setTimeout(() => {
this.cache.delete(url);
}, 30 * 60 * 1000); // 30分钟过期
return imageSource;
}
}
优化3: 减少DOM节点数量
问题: 卡片内部嵌套层级过多。
解决方案:
// ❌ 不好:多层嵌套
Column() {
Row() {
Column() {
// 内容
}
}
}
// ✅ 好:扁平化结构
Column({ space: 8 }) {
// 直接放置内容
Text('标题')
Text('摘要')
Row({ space: 12 }) {
// 底部信息
}
}
优化4: 使用memo化组件
问题: 无状态变化的组件仍然重新渲染。
解决方案:
// 使用@Memo装饰器
@Memo
@Component
struct ArticleCardMemo {
@Prop article: Article;
build() {
// 只有article变化时才重新渲染
Card() {
// ...
}
}
}
本章小结
核心知识点
本文完成了ArticleCard组件的封装,主要包括:
1. 组件设计原则
- 单一职责:ArticleCard只负责展示文章卡片
- 开闭原则:支持扩展新变体,不修改核心逻辑
- 可配置性:通过Props控制显示和行为
2. Props设计
- 使用TypeScript接口定义
- 区分必需和可选属性
- 设置合理的默认值
3. 状态管理
@Prop:单向绑定外部状态@State:组件内部状态- 事件回调:向上传递交互事件
4. 性能优化
- 图片懒加载
- 避免不必要渲染
- 使用LazyForEach
5. 组件变体
- 标准版:垂直布局,适合网格展示
- 紧凑版:横向布局,适合列表展示
- 大图版:突出封面,适合推荐展示
最佳实践总结
✅ Props定义
interface ArticleCardProps {
id: string;
title: string;
summary?: string;
onClick?: () => void;
}
✅ 默认值处理
private defaultProps = { summary: '', onClick: () => {} };
private mergedProps = { ...this.defaultProps, ...this.props };
✅ 事件处理
.onClick((event) => {
event.stopPropagation();
this.onCollect(this.id, !this.isCollected);
})
✅ 图片懒加载
Image(src)
.opacity(isLoaded ? 1 : 0)
.onComplete(() => { isLoaded = true; })
下一步预告
ArticleCard组件已经完成!在下一篇文章中,我们将学习:
- 🏷️ CategoryGrid组件封装
- 分类网格布局
- 分类图标展示
- 选中状态处理
敬请期待 第二十一篇:CategoryGrid组件封装!
相关链接
- 上一篇: 第十九篇:空态页面设计
- 下一篇: 第二十一篇:CategoryGrid组件封装
- 项目源码: Atomgit仓库
- 写作规范: V2版本规范
本章小结
核心知识点
本文完成了ArticleCard组件的封装:
1. 组件属性设计
- 文章基本信息(标题、摘要、封面)
- 作者信息(名称、头像)
- 统计信息(阅读量、收藏状态)
- 事件处理(点击、收藏)
2. 组件结构
- 封面图片区域
- 内容区域(标题、摘要)
- 底部信息区域(作者、时间、阅读量)
3. 组件变体
- 标准版:垂直布局,适合网格展示
- 紧凑版:横向布局,适合列表展示
下一步预告
ArticleCard组件已经完成!在下一篇文章中,我们将学习:
- CategoryGrid组件封装
- 分类网格布局
- 分类图标展示
- 选中状态处理
节气通应用已发布上线,可在应用市场下载体验
相关链接
)
- 项目源码: Atomgit仓库
更多推荐


所有评论(0)