在这里插入图片描述

引言

组件封装是提高代码复用性和可维护性的关键。在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组件封装!


相关链接

本章小结

核心知识点

本文完成了ArticleCard组件的封装:

1. 组件属性设计

  • 文章基本信息(标题、摘要、封面)
  • 作者信息(名称、头像)
  • 统计信息(阅读量、收藏状态)
  • 事件处理(点击、收藏)

2. 组件结构

  • 封面图片区域
  • 内容区域(标题、摘要)
  • 底部信息区域(作者、时间、阅读量)

3. 组件变体

  • 标准版:垂直布局,适合网格展示
  • 紧凑版:横向布局,适合列表展示

下一步预告

ArticleCard组件已经完成!在下一篇文章中,我们将学习:

  • CategoryGrid组件封装
  • 分类网格布局
  • 分类图标展示
  • 选中状态处理

节气通应用已发布上线,可在应用市场下载体验


相关链接

)

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐