在这里插入图片描述

📖 引言

组件化是现代前端开发的核心思想。一个项目的代码质量,很大程度上取决于组件抽象得好不好。

组件抽得好——复用率高、一致性强、改起来方便,一个人顶三个人用;
组件抽得烂——到处复制粘贴、样式不一致、改一个地方炸十个地方,三个人顶一个人用。

但很多开发者对"自定义组件"的理解,停留在"把一段代码抽出来,传几个参数"的层面。结果就是:组件越写越臃肿,props 越传越多,逻辑越来越乱,最后还不如不抽。

一个高质量的自定义组件,应该是什么样的?

  • 接口清晰:看 props 就知道怎么用,不用读源码
  • 职责单一:一个组件只做一件事,做好一件事
  • 易于扩展:新增需求不用大改,加个 props 就行
  • 性能优良:不会因为用了组件反而更卡
  • 风格统一:和项目整体视觉、交互保持一致

本文我们就以「民族图鉴」项目中的几个典型组件为例,从设计思路到实现细节,从 Props 设计到事件定义,从插槽使用到样式定制,带你完整走一遍"从零打造一个高质量组件"的全过程。


🎯 学习目标

完成本文后,你将能够:

  • ✅ 掌握自定义组件的设计原则与拆分方法论
  • ✅ 学会 Props 的设计技巧:类型、默认值、校验、必填
  • ✅ 掌握自定义事件的定义与传参规范
  • ✅ 理解插槽(Slot/Builder)的使用场景与最佳实践
  • ✅ 学会组件的样式定制与主题适配
  • ✅ 深入组件的生命周期与状态管理策略
  • ✅ 掌握组件性能优化的关键技巧
  • ✅ 避开组件封装的常见坑,写出优雅、可复用的组件

💡 需求分析

什么时候该抽组件?

这是第一个要想清楚的问题——不是所有代码都值得抽成组件。

该抽组件的信号

  1. 重复出现:同样的 UI 结构出现了 3 次以上
  2. 职责独立:有明确的功能边界,可以单独理解和测试
  3. 复杂度高:代码太长(超过 200 行),影响可读性
  4. 需要复用:多个页面/模块都会用到

不该抽组件的信号

  1. 只用一次:只在一个地方出现,抽了反而增加理解成本
  2. 和父组件强耦合:传一大堆 props,逻辑都在父组件
  3. 只是代码长:逻辑紧密耦合,拆分反而割裂
  4. 过早抽象:需求还不稳定,抽了以后天天改

💡 组件拆分三问

  1. 它有独立的职责吗?
  2. 它会在多个地方复用吗?
  3. 它的接口清晰吗?(调用方需要知道多少内部细节?)

三个问题有两个以上的答案是"是",再考虑抽组件。

组件设计的原则

1. 单一职责原则(SRP)
一个组件只做一件事。如果一个组件叫 UserCard,它就只负责展示用户卡片,不要在里面放登录逻辑、数据请求、状态管理。

2. 开闭原则(OCP)
对扩展开放,对修改关闭。新增需求应该通过加 props、加插槽来实现,而不是改原有代码。

3. 迪米特法则(Law of Demeter)
组件只和它的直接"朋友"通信。不要在子组件里直接改父组件的状态,不要在组件里直接调用全局 store(除非是全局状态组件)。

4. 可预测性
同样的输入,永远产生同样的输出。组件的行为是可预测的,不会偷偷干一些意料之外的事情。

「民族图鉴」的组件体系

「民族图鉴」项目的组件按层级可以分为三类:

层级 特点 示例 复用范围
基础组件 原子级,不带业务逻辑 Button、Tag、Avatar、Divider 全项目
业务组件 带业务数据结构,但通用 EthnicCard、MusicPlayerBar、EmptyState 多个页面
页面组件 特定页面的拆分,不复用 DetailHeader、QuizOptionItem 单个页面

组件的演进路径

页面内代码 → 抽成 @Builder → 页面级组件 → 业务组件 → 基础组件

不要上来就搞成全局通用组件,先从页面内复用开始,等真的多个页面用了,再往上提。


🛠️ 核心实现

步骤1:Props 设计——组件的"对外接口"

Props 是组件的"输入",也是组件的"接口说明书"。Props 设计得好,组件用起来就顺手;Props 设计得烂,用的人骂娘。

1.1 Props 的基本定义
@Component
export struct EthnicCard {
  // 👇 定义 Props
  @Prop ethnic: EthnicBrief;
  @Prop cardType: CardType = CardType.Grid;
  @Prop showDivider: boolean = true;

  build() {
    // ...
  }
}

Props 的命名规范

  • 小驼峰(camelCase):showDividerimageUrl
  • 布尔值用 is/has/can/should 开头:isVisiblehasBorder
  • 语义化:不要用 datainfo 这种太泛的名字
1.2 Props 的类型

ArkTS 是强类型的,Props 一定要明确类型。

基本类型

@Prop title: string = '';
@Prop count: number = 0;
@Prop isEnabled: boolean = true;

枚举类型

export enum CardType {
  Grid = 'grid',
  List = 'list',
}

@Prop cardType: CardType = CardType.Grid;

对象类型

interface UserInfo {
  id: string;
  name: string;
  avatar: string;
}

@Prop user: UserInfo; // 注意:对象是引用传递

函数类型(回调)

@Prop onClick?: (id: string) => void;
@Prop onReady?: () => void;
1.3 默认值与必填

有默认值的 Props

// 有默认值,调用方可以不传
@Prop size: number = 24;
@Prop type: ButtonType = ButtonType.Default;

必填 Props

// 没有默认值,调用方必须传
@Prop title: string;
@Prop ethnic: EthnicBrief;

💡 设计原则

  • 尽量给默认值,降低使用成本
  • 核心数据(没有就显示不了的)才设为必填
  • 布尔值默认值要符合"最少意外"原则——比如 disabled 默认 false
1.4 Props 校验

虽然 ArkTS 没有像 Vue 那样内置的 validator,但我们可以在 aboutToAppear 里做校验:

aboutToAppear(): void {
  if (!this.ethnic) {
    console.error('EthnicCard: ethnic is required');
    return;
  }
  if (this.maxLines < 1) {
    console.warn('EthnicCard: maxLines should be >= 1, got ' + this.maxLines);
    this.maxLines = 1;
  }
}
1.5 Props 多少算多?

一个组件的 Props 多少个合适?

Props 数量 评估 建议
0-5 个 健康 没问题
5-10 个 注意 看看能不能合并
10-15 个 警告 考虑拆分组件
15+ 个 危险 大概率职责不单一,该拆了

合并 Props 的技巧

  • 把相关的 props 打包成对象:{ width, height, borderRadius }sizeConfig
  • 用枚举代替多个布尔值:isPrimary + isDanger + isSuccesstype: ButtonType

步骤2:事件设计——组件的"输出"

组件不光有输入(Props),还要有输出(事件/回调)。

2.1 自定义事件的定义
@Component
export struct EthnicCard {
  @Prop ethnic: EthnicBrief;
  @Prop onClick?: (ethnic: EthnicBrief) => void;
  @Prop onFavoriteChange?: (id: string, isFav: boolean) => void;

  build() {
    Column() {
      // ...
      Image(this.isFavorite ? 'heart_filled' : 'heart_outline')
        .onClick((event: ClickEvent) => {
          event.stopPropagation(); // 阻止冒泡,避免触发卡片的点击
          this.handleFavorite();
        })
    }
    .onClick(() => {
      if (this.onClick) {
        this.onClick(this.ethnic);
      }
    })
  }

  private handleFavorite(): void {
    this.isFavorite = !this.isFavorite;
    if (this.onFavoriteChange) {
      this.onFavoriteChange(this.ethnic.id, this.isFavorite);
    }
  }
}

事件命名规范

  • 动词开头:onClickonChangeonSubmit
  • 语义化:onFavoriteChangeonHeartClick 好——描述"发生了什么",而不是"点了什么"
  • 过去式表示已发生:onChangedonSubmitted(可选,看项目规范)
2.2 事件传参

事件回调的参数设计原则

  1. 第一个参数是事件主体:比如点击卡片,第一个参数就是卡片对应的数据
  2. 第二个参数是事件对象(如果需要):原生事件对象
  3. 参数不要太多:超过 2 个就考虑打包成对象
// ✅ 好:参数明确
@Prop onClick?: (ethnic: EthnicBrief) => void;

// ✅ 好:多个参数打包成对象
interface CardActionParams {
  id: string;
  type: ActionType;
  position: number;
}
@Prop onAction?: (params: CardActionParams) => void;

// ❌ 不好:参数太多,顺序记不住
@Prop onChange?: (id: string, name: string, type: string, index: number) => void;
2.3 事件冒泡与阻止

嵌套组件的点击事件,会冒泡到父组件。有时候这是想要的,有时候不是。

Column() {
  Image('heart')
    .onClick((event: ClickEvent) => {
      event.stopPropagation(); // 👇 阻止冒泡,点爱心不会触发卡片点击
      this.handleFavorite();
    })
}
.onClick(() => {
  // 整个卡片的点击事件
})

什么时候该阻止冒泡?

  • 子组件的操作和父组件的操作是独立的(比如卡片里的爱心按钮)
  • 子组件的操作会触发完全不同的逻辑

什么时候不该阻止?

  • 子组件只是父组件的一部分,点击子组件就是点击父组件

步骤3:插槽(Slot/Builder)——组件的"扩展点"

Props 是传数据,那如果要传"一段 UI"怎么办?这时候就需要插槽。

ArkUI 中实现"插槽"有几种方式:

3.1 @BuilderParam(推荐)

@BuilderParam 是 ArkUI 中最接近"插槽"概念的东西。

// 组件定义
@Component
export struct CardContainer {
  @Prop title: string = '';
  @BuilderParam header?: () => void;  // 头部插槽
  @BuilderParam content?: () => void; // 内容插槽
  @BuilderParam footer?: () => void;  // 底部插槽

  build() {
    Column() {
      // 头部
      if (this.header) {
        this.header();
      }

      // 标题
      if (this.title) {
        Text(this.title)
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .margin({ bottom: 12 });
      }

      // 内容
      if (this.content) {
        this.content();
      }

      // 底部
      if (this.footer) {
        this.footer();
      }
    }
    .width('100%')
    .padding(16)
    .backgroundColor($r('app.color.card_background'))
    .borderRadius(12);
  }
}

使用:

// 调用方
CardContainer({
  title: '民族信息',
  header: () => {
    Row() {
      Image('badge')
        .width(20)
        .height(20)
      Text('精选')
        .fontSize(12)
        .fontColor($r('app.color.primary'))
    }
  },
  content: () => {
    Column({ space: 8 }) {
      Text('人口:约 13 亿')
      Text('分布:全国各地')
    }
  },
  footer: () => {
    Button('查看详情')
      .width('100%')
      .onClick(() => { /* ... */ })
  }
})
3.2 默认插槽的简化写法

如果组件只有一个主要插槽,可以用默认的方式:

@Component
export struct CardWrapper {
  // 用 @BuilderParam 作为主内容
  @BuilderParam content: () => void;

  build() {
    Column() {
      this.content();
    }
    .padding(16)
    .backgroundColor(Color.White)
    .borderRadius(12);
  }
}

使用:

CardWrapper({
  content: () => {
    Text('Hello')
  }
})
3.3 插槽的设计原则

什么时候用插槽,什么时候用 Props?

场景 推荐方式
传数据(字符串、数字、对象) Props
传简单的样式变体(大小、类型) Props + 枚举
传一段自定义 UI 插槽(@BuilderParam)
传复杂的交互逻辑 插槽 + 回调

插槽的命名

  • header / footer / content:布局位置
  • prefix / suffix:前后缀
  • left / right:左右
  • item / cell:列表项自定义

插槽不要太多

  • 1-2 个插槽:健康
  • 3-4 个插槽:注意
  • 5+ 个插槽:这个组件可能太"空"了,不如不封装

💡 插槽的本质
插槽是组件给调用方留的"扩展点"。
组件控制整体结构和通用逻辑,调用方通过插槽填入具体内容。
好的插槽设计,是"骨架+血肉"的关系——组件提供骨架,插槽填入血肉。


步骤4:完整案例——民族卡片组件

让我们用一个完整的案例,把上面讲的知识串起来。

我们要做一个 EthnicCard 组件,支持两种模式:

  • 网格模式:圆形头像 + 名字,用于首页的网格列表
  • 列表模式:左侧头像 + 中间信息 + 右侧箭头,用于列表页

还要支持:

  • 点击回调
  • 收藏按钮(可选显示)
  • 自定义底部内容(插槽)
4.1 第一步:定义接口

先想清楚"这个组件接收什么、输出什么",然后定义类型。

// models/EthnicModels.ets
export interface EthnicBrief {
  id: string;
  name: string;
  nameEn: string;
  imageUrl: string;
  population: string;
  region: string;
  primaryColor: string;
}

export enum EthnicCardType {
  Grid = 'grid',
  List = 'list',
}
4.2 第二步:定义 Props 和事件
// components/EthnicCard.ets
import { EthnicBrief, EthnicCardType } from '../models/EthnicModels';

@Component
export struct EthnicCard {
  // 数据
  @Prop ethnic: EthnicBrief;

  // 样式
  @Prop cardType: EthnicCardType = EthnicCardType.Grid;
  @Prop showFavorite: boolean = false;
  @Prop isFavorite: boolean = false;
  @Prop cornerRadius: number = 12;

  // 事件
  @Prop onClick?: (ethnic: EthnicBrief) => void;
  @Prop onFavoriteChange?: (id: string, isFav: boolean) => void;

  // 插槽
  @BuilderParam extraContent?: () => void;

  // 内部状态
  @State isPressed: boolean = false;

  build() {
    if (this.cardType === EthnicCardType.Grid) {
      this.buildGridCard();
    } else {
      this.buildListCard();
    }
  }
}
4.3 第三步:实现网格模式
@Builder
buildGridCard() {
  Column({ space: 8 }) {
    Stack({ alignContent: Alignment.TopEnd }) {
      Image(this.ethnic.imageUrl)
        .width(60)
        .height(60)
        .borderRadius(30)
        .objectFit(ImageFit.Cover)

      if (this.showFavorite) {
        Image(this.isFavorite ? 'heart_filled' : 'heart_outline')
          .width(18)
          .height(18)
          .fillColor(this.isFavorite ? '#FF4D4F' : '#FFFFFF')
          .backgroundColor(this.isFavorite ? '#FFF' : 'rgba(0,0,0,0.3)')
          .borderRadius(9)
          .padding(3)
          .margin({ top: 4, right: 4 })
          .onClick((event: ClickEvent) => {
            event.stopPropagation();
            this.handleFavorite();
          })
      }
    }

    Text(this.ethnic.name)
      .fontSize(14)
      .fontColor($r('app.color.text_primary'))
      .fontWeight(FontWeight.Medium)
      .maxLines(1)
      .textOverflow({ overflow: TextOverflow.Ellipsis })

    Text(this.ethnic.population)
      .fontSize(12)
      .fontColor($r('app.color.text_secondary'))
      .maxLines(1)

    if (this.extraContent) {
      this.extraContent();
    }
  }
  .width('100%')
  .padding(12)
  .borderRadius(this.cornerRadius)
  .backgroundColor($r('app.color.card_background'))
  .scale({ x: this.isPressed ? 0.95 : 1, y: this.isPressed ? 0.95 : 1 })
  .opacity(this.isPressed ? 0.8 : 1)
  .animation({ duration: 150, curve: Curve.EaseInOut })
  .onTouch((event: TouchEvent) => {
    if (event.type === TouchType.Down) {
      this.isPressed = true;
    } else if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
      this.isPressed = false;
      if (event.type === TouchType.Up && this.onClick) {
        this.onClick(this.ethnic);
      }
    }
  })
}
4.4 第四步:实现列表模式
@Builder
buildListCard() {
  Row({ space: 12 }) {
    Image(this.ethnic.imageUrl)
      .width(56)
      .height(56)
      .borderRadius(8)
      .objectFit(ImageFit.Cover)

    Column({ space: 4 }) {
      Text(this.ethnic.name)
        .fontSize(16)
        .fontColor($r('app.color.text_primary'))
        .fontWeight(FontWeight.Medium)
        .maxLines(1)

      Text(`${this.ethnic.region} · ${this.ethnic.population}`)
        .fontSize(13)
        .fontColor($r('app.color.text_secondary'))
        .maxLines(1)

      if (this.extraContent) {
        this.extraContent();
      }
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Start)

    if (this.showFavorite) {
      Image(this.isFavorite ? 'heart_filled' : 'heart_outline')
        .width(24)
        .height(24)
        .fillColor(this.isFavorite ? '#FF4D4F' : '#CCCCCC')
        .onClick((event: ClickEvent) => {
          event.stopPropagation();
          this.handleFavorite();
        })
    }

    Image('arrow_right')
      .width(16)
      .height(16)
      .fillColor($r('app.color.text_tertiary'))
  }
  .width('100%')
  .padding(16)
  .borderRadius(this.cornerRadius)
  .backgroundColor($r('app.color.card_background'))
  .scale({ x: this.isPressed ? 0.98 : 1, y: this.isPressed ? 0.98 : 1 })
  .opacity(this.isPressed ? 0.8 : 1)
  .animation({ duration: 150, curve: Curve.EaseInOut })
  .onTouch((event: TouchEvent) => {
    if (event.type === TouchType.Down) {
      this.isPressed = true;
    } else if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
      this.isPressed = false;
      if (event.type === TouchType.Up && this.onClick) {
        this.onClick(this.ethnic);
      }
    }
  })
}
4.5 第五步:内部方法
private handleFavorite(): void {
  const newState = !this.isFavorite;
  if (this.onFavoriteChange) {
    this.onFavoriteChange(this.ethnic.id, newState);
  }
}
4.6 使用示例
// 网格模式
EthnicCard({
  ethnic: item,
  cardType: EthnicCardType.Grid,
  showFavorite: true,
  isFavorite: this.isFavorite(item.id),
  onClick: (ethnic) => {
    this.goToDetail(ethnic.id);
  },
  onFavoriteChange: (id, isFav) => {
    this.toggleFavorite(id);
  }
})

// 列表模式 + 自定义内容
EthnicCard({
  ethnic: item,
  cardType: EthnicCardType.List,
  onClick: (ethnic) => {
    this.goToDetail(ethnic.id);
  },
  extraContent: () => {
    Row({ space: 8 }) {
      Tag({ text: '服饰', type: TagType.Primary })
      Tag({ text: '美食', type: TagType.Success })
    }
  }
})

步骤5:组件的样式定制

组件怎么支持"换肤"?怎么让调用方自定义样式?

5.1 样式 Props

最简单的方式:通过 Props 传样式。

@Prop backgroundColor?: string;
@Prop textColor?: string;
@Prop borderRadius: number = 12;

但这样有个问题——样式 props 太多了,每个样式都加一个 prop,props 会爆炸。

5.2 样式类型(推荐)

把相关的样式打包成一个对象:

interface CardStyle {
  backgroundColor?: string;
  borderRadius?: number;
  padding?: Padding;
  borderColor?: string;
  borderWidth?: number;
}

@Prop style?: CardStyle;

使用:

Column() {
  // ...
}
.backgroundColor(this.style?.backgroundColor ?? $r('app.color.card_background'))
.borderRadius(this.style?.borderRadius ?? 12)
5.3 主题适配

用系统资源 $r() 而不是写死颜色值,天然支持深色模式:

// ✅ 好:用资源,自动适配深色模式
.backgroundColor($r('app.color.card_background'))
.fontColor($r('app.color.text_primary'))

// ❌ 不好:写死,深色模式下可能看不见
.backgroundColor('#FFFFFF')
.fontColor('#333333')
5.4 CSS 变量

对于需要动态切换的主题色,可以用 CSS 变量:

// 定义在全局
AppStorage.setOrCreate('--primary-color', '#409EFF');

// 组件中使用
const primaryColor = AppStorage.get('--primary-color') || '#409EFF';
5.5 样式继承的边界

一个原则:组件只控制"自己的骨架样式",内容样式交给调用方。

比如卡片组件:

  • ✅ 组件控制:背景色、圆角、内边距、阴影
  • ❌ 组件不控制:里面文字的字号、颜色(除非是标题等固定部分)

这样组件才有足够的灵活性,适应不同的使用场景。


步骤6:组件的生命周期

了解组件的生命周期,才能在正确的时机做正确的事。

生命周期 调用时机 典型用途
aboutToAppear 组件即将出现时 初始化数据、注册监听、开始加载
aboutToDisappear 组件即将消失时 取消请求、移除监听、释放资源
onPageShow 页面显示时(仅页面) 刷新数据、恢复播放
onPageHide 页面隐藏时(仅页面) 暂停播放、保存状态
onActive 组件激活时 恢复动画、重新开始轮播
onInactive 组件失活时 暂停动画、暂停轮播

aboutToAppear vs onPageShow

  • aboutToAppear:组件创建时调用一次
  • onPageShow:每次页面显示都调用(比如从后台切回来)

常见用法

aboutToAppear(): void {
  // 初始化数据
  this.loadData();
  // 注册监听
  EventHub.on('themeChange', this.handleThemeChange);
}

aboutToDisappear(): void {
  // 移除监听(重要!防止内存泄漏)
  EventHub.off('themeChange', this.handleThemeChange);
  // 取消请求
  this.requestController?.abort();
  // 清理定时器
  clearInterval(this.timer);
}

onPageShow(): void {
  // 页面回来时刷新
  this.refreshIfNeeded();
}

⚠️ 关于内存泄漏
在 aboutToAppear 里注册的监听,一定要在 aboutToDisappear 里移除。
在组件里启的定时器,一定要在销毁时清掉。
不然组件销毁了,回调还在引用着组件,就会内存泄漏。


步骤7:组件性能优化

组件用多了,性能会不会变差?怎么保证组件的性能?

7.1 避免不必要的重渲染

问题:父组件状态变了,所有子组件都重新 build?

ArkUI 的优化
ArkUI 是细粒度更新的,不是整个组件树重新渲染。只有真正依赖了变化状态的部分才会更新。

你可以做的优化

  1. 合理拆分组件:把不相关的状态拆到子组件里,父组件状态变化不会影响子组件

  2. 用 @Observed / @ObjectLink:嵌套对象的精细化观测

  3. 避免在 build 里创建新对象

// ❌ 不好:每次 build 都创建新数组
build() {
  Column() {
    ForEach([1, 2, 3], (item) => {
      Text(item.toString())
    })
  }
}

// ✅ 好:数组定义在组件外或者 @State 里
private items: number[] = [1, 2, 3];

build() {
  Column() {
    ForEach(this.items, (item) => {
      Text(item.toString())
    })
  }
}
7.2 列表组件的性能

列表是性能重灾区。列表里的卡片组件,每一张都可能被滚动时复用。

优化列表项的技巧

  1. 组件层级越少越好:能一层解决的不要套三层
  2. 布局尽量简单:能用 Row/Column 就不用 Stack
  3. 图片设置明确的宽高:避免加载后重新布局
  4. 用 LazyForEach:不要一次性渲染所有项
7.3 纯展示组件用 @Builder

如果只是为了拆分代码,没有独立状态,用 @Builder 比抽组件更轻量:

// 同一个组件内,用 @Builder 拆分
@Builder
Header() {
  Row() {
    // ...
  }
}

什么时候用 @Builder,什么时候抽组件?

维度 @Builder 自定义组件
复用范围 同一个组件内 跨组件/跨页面
独立状态 没有(用父组件的) 有自己的状态
生命周期 没有
性能开销 极低 有一些
复杂度 简单 可以复杂

💡 简单口诀
只是代码太长、逻辑紧 → 用 @Builder
有独立职责、要复用 → 抽组件


⚠️ 常见问题与解决方案

问题1:Props 太多,组件越来越臃肿

现象
组件刚抽出来的时候只有 5 个 props,需求迭代加加加,加到 15 个、20 个,组件变成了"万能组件",什么都能干,但什么都干不好。

分析
这通常是因为组件的职责不单一。一个组件承担了太多功能,什么场景都想支持。

解决方案

方案1:拆分组件

把一个"万能组件"拆成多个"专用组件"。

// ❌ 不好:一个 Card 组件支持 N 种模式
// props: type, size, variant, showHeader, showFooter, bordered, shadowed...

// ✅ 好:拆成多个组件
PrimaryCard({ title, content })     // 主卡片
SimpleCard({ content })             // 简单卡片
ImageCard({ image, title })         // 图文卡片
ProfileCard({ user, stats })        // 个人资料卡片

方案2:组合大于继承

不要试图用一个组件覆盖所有场景。而是提供一些基础组件,让调用方自己组合。

// 基础组件
Card({ content: ... })       // 只有容器样式
CardHeader({ title: ... })   // 卡片头部
CardFooter({ actions: ... }) // 卡片底部

// 调用方自己组合
Card({
  content: () => {
    Column() {
      CardHeader({ title: '标题' })
      Divider()
      // 自定义内容
      CardFooter({ actions: [...] })
    }
  }
})

方案3:预设配置 + 自定义

对于常用的组合,提供"预设",同时保留自定义能力。

// 预设类型
type CardPreset = 'default' | 'primary' | 'outlined' | 'elevated';

@Prop preset: CardPreset = 'default';

// 根据预设应用不同的样式
private get cardStyle(): CardStyle {
  switch (this.preset) {
    case 'primary':
      return { backgroundColor: '#409EFF', textColor: '#FFF' };
    case 'outlined':
      return { borderWidth: 1, borderColor: '#DCDFE6' };
    // ...
  }
}

问题2:父子组件状态不同步

现象
父组件传了一个 prop 给子组件,子组件内部改了,但父组件没变。或者反过来,父组件改了,子组件没更新。

分析

ArkUI 中 @Prop 是单向数据流

  • 父组件 → 子组件:父组件的 prop 变了,子组件自动更新
  • 子组件 → 父组件:子组件不能直接改 prop,要通过回调通知父组件改

常见错误

// ❌ 错误:子组件直接修改 @Prop
@Prop count: number = 0;

increment() {
  this.count++; // 不推荐!可能会导致父组件和子组件状态不一致
}

// ✅ 正确:通过回调通知父组件
@Prop count: number = 0;
@Prop onCountChange?: (count: number) => void;

increment() {
  if (this.onCountChange) {
    this.onCountChange(this.count + 1);
  }
}

什么时候子组件可以有自己的状态?

  • 这个状态是组件内部的,和父组件无关(比如 isPressed)
  • 这个状态是 prop 的"本地副本",可以和父组件不同步(比如输入框的临时值)

什么时候不该有自己的状态?

  • 数据应该以父组件为准(比如收藏状态)
  • 多个地方需要同步(比如全局设置)

💡 状态归属原则
谁拥有数据,谁管理状态。
数据是父组件的,就父组件管,子组件只展示和发通知。
数据是组件内部的,就组件自己管。


问题3:组件太"深",传参要传好多层

现象
A → B → C → D,D 需要一个数据,要从 A 一层一层传下去,中间的 B、C 都得加 prop。

A(数据在这里)
  ↓ prop
B(不关心,只是往下传)
  ↓ prop
C(不关心,只是往下传)
  ↓ prop
D(真正用到的地方)

解决方案

方案1:Provider / Inject(跨代传值)

// A 组件提供数据
@Provide('userInfo') userInfo: UserInfo = user;

// D 组件直接取
@Consume('userInfo') userInfo: UserInfo;

适用于"祖孙"级别的传递,跳过中间层。

方案2:全局状态(AppStorage / Store)

如果是全局共享的数据,直接放 AppStorage 或者状态管理库:

// 任何地方都能读
@StorageLink('themeMode') themeMode: string = 'light';

方案3:重新考虑组件设计

如果发现数据要传很多层,可能是组件划分有问题。是不是应该把相关的逻辑放到一起?

💡 传参层级的判断

  • 1-2 层:正常,直接传
  • 3 层:可以考虑用 Provide/Inject
  • 4+ 层:要么放全局,要么重新设计组件结构

问题4:组件复用率低,大家都在复制粘贴

现象
抽了一个组件,但是别人用的时候觉得"不好用",还是自己写一份。结果组件库形同虚设,代码里到处都是复制粘贴的版本。

分析
组件复用率低,通常不是因为"大家不爱用组件",而是因为"组件不好用"。

常见原因

  1. 接口设计反直觉

    • 看名字不知道怎么用
    • 参数顺序记不住
    • 行为不符合预期
  2. 灵活性不够

    • 这个需求差一点点,组件不支持
    • 想改个样式改不了
    • 想加个功能加不了
  3. 文档缺失

    • 不知道有这个组件
    • 不知道怎么用
    • 不知道有哪些 props

改进方法

  1. 从使用方的角度设计接口

    • 写组件前,先写"使用示例"
    • 如果用起来很别扭,说明组件设计有问题
    • 好的组件,看一眼就知道怎么用
  2. 预留扩展点

    • 加插槽,而不是加一堆布尔值
    • 提供默认配置,但允许覆盖
    • 样式留口子,不要写死
  3. 写好文档和示例

    • 每个组件都要有使用示例
    • 列出所有 props、事件、插槽
    • 常见用法都给个例子

💡 组件的"可用性"比"功能多少"更重要
一个功能不多但很好用的组件,比一个功能齐全但难用的组件,复用率高得多。


问题5:组件测试难,不知道怎么测

现象
组件写好了,但不知道怎么测试。UI 组件不像工具函数,输入输出那么明确。

分析
组件测试确实比纯逻辑难,但不是不能测。

组件测试的思路

1. 逻辑单独抽出来测试

把组件中的业务逻辑抽成工具函数或者 service,单独测:

// 逻辑抽成纯函数,好测
export function calculateProgress(current: number, total: number): number {
  if (total <= 0) return 0;
  return Math.min(100, Math.max(0, (current / total) * 100));
}

// 组件里只调用
@Builder
build() {
  const progress = calculateProgress(this.current, this.total);
  // ...
}

2. 测组件的行为,不测实现

关注"组件做了什么",而不是"组件怎么做到的":

  • ✅ 给了 props,渲染的内容对不对
  • ✅ 点击按钮,回调有没有触发
  • ✅ 状态变化,UI 有没有更新
  • ❌ 内部用了几个变量
  • ❌ 方法调用顺序

3. 用组件测试框架

ArkUI 有自己的测试框架,可以模拟用户操作、断言 UI 状态。


📝 本章小结

核心知识点

本文从设计原则到具体实现,系统讲解了自定义组件的封装方法:

1. 组件设计原则

  • 单一职责:一个组件只做一件事
  • 开闭原则:对扩展开放,对修改关闭
  • 迪米特法则:只和直接朋友通信
  • 可预测性:同样的输入,同样的输出

2. Props 设计

  • 明确类型,合理默认值
  • 命名语义化,布尔值用 is/has 开头
  • props 数量控制在 10 个以内
  • 太多就合并、拆分

3. 事件设计

  • 动词开头:onClick、onChange
  • 描述"发生了什么",不是"点了什么"
  • 参数简洁,超过 2 个打包成对象
  • 合理控制事件冒泡

4. 插槽(@BuilderParam)

  • 传 UI 用插槽,传数据用 Props
  • 插槽不要太多,3 个以上要注意
  • 好的插槽设计是"骨架+血肉"

5. 样式定制

  • 用 $r() 资源,天然支持深色模式
  • 样式 Props 打包成对象,不要散着传
  • 组件控制骨架样式,内容样式交给调用方

6. 生命周期

  • aboutToAppear:初始化、注册监听
  • aboutToDisappear:清理资源、移除监听(防止泄漏)
  • onPageShow/onPageHide:页面级别的显示隐藏

7. 性能优化

  • 合理拆分组件,避免不必要的重渲染
  • 列表项轻量化,层级越少越好
  • 纯展示用 @Builder,不要过度封装
  • 图片设置明确宽高,避免重新布局

最佳实践总结

先想怎么用,再想怎么实现

写组件前,先写使用示例。
如果用起来很别扭,说明组件设计有问题。
从调用方的角度倒推设计,比从实现角度正推,质量高得多。

Props 宁少勿多,保持克制

一开始 props 少一点,不够再加。
加 props 容易,删 props 难(要考虑兼容)。
一个组件超过 10 个 props,就该想想是不是该拆了。

单向数据流,数据归谁谁管

父组件的数据,父组件管。
子组件不要直接改 prop,通过回调通知父组件。
状态归属清晰,才不会乱。

组合大于继承,预设覆盖常见场景

不要试图做一个"万能组件"。
提供基础组件 + 预设配置 + 组合能力,比一个巨型组件灵活得多。

好组件是"演化"出来的,不是"设计"出来的

不要上来就做通用组件。
先在一个地方用,第二个地方用的时候再抽。
用的地方多了,自然知道怎么设计才合理。

文档和示例,和代码一样重要

组件再好,别人不知道怎么用,等于没有。
每个组件配一个示例文件,比写十行注释管用。
好的组件,看示例就会用。

下一步预告

在下一篇文章中,我们将:

  • 👆 学习常见手势的处理:点击、长按、滑动、捏合、旋转
  • 🎯 理解手势冲突的产生原因与解决方法
  • 📱 掌握手势驱动的交互动效实现
  • 🎨 实现「民族图鉴」中的可拖拽卡片、下拉刷新等高级交互
  • 🚀 深入手势识别的原理与优先级控制

🔗 相关链接


💡 提示:组件封装是一门"平衡的艺术"——太简单了不够用,太复杂了又难用。最好的学习方法,就是多看优秀的组件库(比如 Element Plus、Ant Design),看它们的组件是怎么设计的、props 是怎么命名的、插槽是怎么留的。看得多了,再自己动手写,慢慢就有感觉了。

Logo

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

更多推荐