大家好,我是[晚风依旧似温柔],新人一枚,欢迎大家关注~

前言

先问一句扎心的:
你是不是也干过这种事——一个 Button 写完 copy 到下一个页面,改个颜色又 copy 一份,再改下圆角再 copy 一份,两周之后整个项目里出现了 PrimaryButtonPrimaryBtnMainBtnBigRedBtn 四个完全不一样但干同一件事的玩意?

这时候产品说:

“按钮圆角统一改成 12px,主色从蓝色改成绿色。”

你好,恭喜你,准备开一场“全项目手改按钮”的人肉重构大会。

其实,这不是你不会写组件,而是——你根本没拿它当“组件库”来设计

这篇我们就来较真一把:

在鸿蒙(HarmonyOS / ArkTS + ArkUI)下,怎么系统性地设计一套“能复用、能扩展、能换肤”的自定义 UI 组件库?

按你给的大纲,我会从四个维度展开:

  1. 组件分层设计——不要所有组件都往一个文件里怼
  2. API 设计规范——一个好组件,配置项必须“好看好记好用”
  3. 主题 / 样式扩展——如何做到“改主题不用推翻重来”
  4. 组件封装示例(Button / Dialog)——真刀真枪写一遍,不整 PPT 工程

全程带情绪、带吐槽、带代码,写完你可以直接把这套套路搬进自己的项目里,用一两版迭代慢慢长成自家风格的组件库


一、组件分层设计:别再“一把梭”,组件库也是需要架构的

很多人一听“组件库”,脑子里的画面是这样的:

/components 目录底下一堆 .ets 文件,
想到啥写啥,名字想到什么就起什么: MyButton.etsSimpleButton.etsRoundButton.ets……

写的时候爽,用的时候头大,后期维护直接崩溃。

一个成熟一点的组件库,其实是有分层的。不分层就等着“越写越乱”。

1.1 推荐的组件库目录结构示意

下面是一个比较健康的鸿蒙组件库结构示例(只是一种思路,你可以按项目调整):

/src
  /uikit              # 组件库根目录
    /foundation       # 基础能力:颜色、排版、阴影、Radius、ZIndex...
      colors.ets
      typography.ets
      metrics.ets
    /tokens           # 设计 Token:主题变量
      lightTheme.ets
      darkTheme.ets
    /generic          # 通用基础组件(低依赖)
      ButtonBase.ets
      Overlay.ets
      Icon.ets
    /form             # 表单类组件
      TextField.ets
      Checkbox.ets
      Switch.ets
    /feedback         # 反馈类组件
      Dialog.ets
      Toast.ets
      Snackbar.ets
    /navigation       # 导航类组件
      TabBar.ets
      NavBar.ets
    index.ets         # 统一导出入口

理念非常简单:

  • 底层是“设计语言”(颜色、字号、边距、圆角等)
  • 中层是“基础可组合组件”
  • 上层是“完整场景组件”(带逻辑、带动效)

1.2 分层的意义(不是为了好看,而是为了好改)

  • 样式统一:改一次主题文件,所有组件外观随之更新
  • 依赖清晰:foundation 层任何时候都不应该依赖上层组件
  • 迭代可控:要重构只需要替换某一层,比如想推翻 Button 实现,但还保留颜色体系

你可以把它想象成:

设计 Token → 通用视觉规范 → 基础组件 → 业务组件

组件库写着写着,一定要问自己一句:

“我现在写的是** Button 本身**,还是写的是 Button 下面那一层 ButtonBase?”

如果你能区分这两者,你就已经比一大半“全部直接写页面”式开发者更进了一步。


二、API 设计规范:一个好组件,光好看还不够,好用才配叫“库”

看过太多这种组件:

MyButton({
  t: '确认',
  c: '#FF0000',
  s: 18,
  w: 220,
  h: 42,
  r: 8,
  click: this.onOk
})

用的人要打开源码才看得懂每个缩写是啥,这已经不是组件,是猜谜游戏了。

2.1 一个“像样”的组件 API 应该什么样?

以下面这个为例(ArkTS 风格):

@CustomDialog
@Component
export struct AppButton {
  @Prop text: string;
  @Prop type: ButtonType = ButtonType.Primary;
  @Prop size: ButtonSize = ButtonSize.M;
  @Prop disabled: boolean = false;
  @Prop loading: boolean = false;
  @Prop icon?: Resource;
  @Prop fullWidth: boolean = false;
  @Prop onClick?: () => void;

  // ...
}

配合使用:

AppButton({
  text: '登录',
  type: ButtonType.Primary,
  size: ButtonSize.L,
  fullWidth: true,
  loading: this.isSubmitting,
  onClick: () => this.submit()
})

你会发现几个特点:

  1. 语义清晰type / size / disabled 很好理解
  2. 易于扩展:新增一个 type: Danger 是加枚举项,不是多起一个组件名
  3. 布尔属性常见: loading / fullWidth 都是 UI 逻辑的常见需求
  4. 事件命名规范onClickonCancelonConfirm

2.2 组件 API 命名几条铁律

  1. 不要缩写,除非是全世界都认识的那种

    • txtbgw, h
    • text, backgroundColor, width, height(ArkUI 环境里很多是链式样式就不用了)
  2. 属性优先用枚举而不是魔法字符串

    • type: "primary" / "secondary"
    • type: ButtonType.Primary
  3. 布尔属性要“语义正向”

    • enabled: false
    • disabled: true(读起来更直观)
  4. 禁止通过同一个属性做两三件事

    • mode: "danger_outline_big"
    • type + variant + size 拆开
  5. 事件都用 onXxx 前缀

    • onClick, onCancel, onConfirm, onOpenChange

简单说:你写组件 API 的时候,要假装你在给别人写 SDK 文档。

2.3 ArkTS 组件 API 常见写法模式

ArkUI 里常见的是结构体 + @Prop

@Component
export struct AppButton {
  @Prop text: string = '';
  @Prop type: ButtonType = ButtonType.Primary;
  @Prop size: ButtonSize = ButtonSize.M;
  @Prop disabled: boolean = false;
  @Prop loading: boolean = false;
  @Prop onClick?: () => void;

  build() {
    Button(this.loading ? '处理中…' : this.text)
      .enabled(!this.disabled && !this.loading)
      .onClick(() => {
        if (this.disabled || this.loading) return;
        this.onClick && this.onClick();
      })
  }
}

这类组件 API 的核心就是:“Prop 都是纯粹的输入,不在内部随意修改”,状态交给使用者去管理。


三、主题 / 样式扩展:一键换肤,才是“库”的高级感

写 UI 时最痛的一件事是什么?
不是布局难,而是“产品后期突然说:我们要支持暗黑模式、支持多品牌换肤”。

如果你一开始就用各种“写死的颜色字符串”,比如:

Button('登录')
  .backgroundColor('#007DFF')
  .fontColor('#FFFFFF')

那你后面一定会哭着问自己:

“我当时为什么不用颜色变量……”

3.1 建一套 Theme Token,是组件库活下去的关键

建议至少分三个层级的样式:

  1. 基础色板(Raw Colors):红、黄、蓝、灰阶等
  2. 语义色(Semantic Colors):主色、成功、警告、错误、边框、背景
  3. 组件级 Token:按钮背景、按钮 hover 色、Dialog蒙层颜色、圆角大小等

示例:

// /uikit/foundation/colors.ets
export const RawColors = {
  blue500: '#006CFF',
  blue600: '#0052CC',
  red500: '#FF3B30',
  gray100: '#F5F5F5',
  gray900: '#121212',
  white: '#FFFFFF',
  black: '#000000',
};

// /uikit/tokens/lightTheme.ets
export const LightTheme = {
  // 语义色
  primary: RawColors.blue500,
  primaryHover: RawColors.blue600,
  textPrimary: RawColors.gray900,
  textSecondary: '#666666',
  bgBody: RawColors.white,
  bgElevated: '#FAFAFA',
  borderSubtle: '#E5E5E5',

  // 组件级
  buttonPrimaryBg: RawColors.blue500,
  buttonPrimaryText: RawColors.white,
  buttonRadius: 12,
  dialogBg: RawColors.white,
  dialogMask: 'rgba(0,0,0,0.4)',
};

// /uikit/tokens/darkTheme.ets
export const DarkTheme = {
  primary: RawColors.blue500,
  primaryHover: '#4C8DFF',
  textPrimary: RawColors.white,
  textSecondary: '#BBBBBB',
  bgBody: '#000000',
  bgElevated: '#1C1C1E',
  borderSubtle: '#3A3A3C',

  buttonPrimaryBg: RawColors.blue500,
  buttonPrimaryText: RawColors.white,
  buttonRadius: 12,
  dialogBg: '#1C1C1E',
  dialogMask: 'rgba(0,0,0,0.6)',
};

3.2 用 @Provide / @Consume 整个工程共享主题

在 App 顶层用 @Provide 注入当前主题:

import { LightTheme, DarkTheme } from '../uikit/tokens/lightTheme';

@Entry
@Component
struct AppRoot {
  @State isDark: boolean = false;
  @Provide theme = this.isDark ? DarkTheme : LightTheme;

  build() {
    Column() {
      // 顶部加个切换按钮
      Row() {
        Text(this.isDark ? '🌙 暗黑模式' : '☀ 亮色模式')
        Button('切换主题')
          .onClick(() => this.isDark = !this.isDark)
      }
      .padding(12)

      // 下方所有页面都能通过 @Consume 拿到 theme
      MainPage()
    }
    .backgroundColor(this.theme.bgBody)
  }
}

在组件里通过 @Consume 拿主题:

@Component
export struct AppButton {
  @Consume theme;
  @Prop text: string;
  @Prop type: ButtonType = ButtonType.Primary;

  build() {
    let bg = this.theme.buttonPrimaryBg;
    let textColor = this.theme.buttonPrimaryText;

    Button(this.text)
      .backgroundColor(bg)
      .fontColor(textColor)
      .borderRadius(this.theme.buttonRadius)
  }
}

好处是什么?

  • 主题可以在运行时切换
  • 按钮本身不关心“是 light 还是 dark”,只管用 theme 里的值
  • 新增“某品牌主题”时,只要多配一份 Token,不用改组件

3.3 样式扩展策略:别把死样式写死在组件里

有时候产品会说:

“这个按钮在某个页面要特别一点,主色、圆角都改一下。”

如果你在组件中把所有样式都封死,那用的人只能 copy 一份组件再改,组件库直接破功。

可以考虑以下策略:

  • style 作为兜底扩展点,让使用者在不破坏整体样式的前提下做微调
  • 或者允许传入部分覆盖样式:

示例(Button 支持“部分覆盖”):

export interface ButtonStyleOverride {
  bgColor?: string;
  textColor?: string;
  radius?: number;
}

@Component
export struct AppButton {
  @Consume theme;
  @Prop text: string;
  @Prop type: ButtonType = ButtonType.Primary;
  @Prop styleOverride?: ButtonStyleOverride;

  build() {
    let bg = this.theme.buttonPrimaryBg;
    let textColor = this.theme.buttonPrimaryText;
    let radius = this.theme.buttonRadius;

    if (this.styleOverride) {
      bg = this.styleOverride.bgColor ?? bg;
      textColor = this.styleOverride.textColor ?? textColor;
      radius = this.styleOverride.radius ?? radius;
    }

    Button(this.text)
      .backgroundColor(bg)
      .fontColor(textColor)
      .borderRadius(radius)
  }
}

使用者在特定页面可以这么写:

AppButton({
  text: '危险操作',
  styleOverride: {
    bgColor: '#FF3B30',
    radius: 4
  },
  onClick: () => this.deleteAllData()
})

不破坏整体的主题体系,又给了使用方一点“调味空间”。


四、组件封装实战:Button & Dialog,从样式到行为一条龙

说了这么多,是时候动手写点“真东西”了。
我们搞两个最常见也最容易写崩的组件:

  • Button:通用按钮,支持类型、尺寸、loading、禁用
  • Dialog:带蒙层、标题、内容、按钮,支持外部控制显示隐藏

4.1 Button:从一个“长得像按钮的 div”升级成真正组件

4.1.1 类型与大小枚举先定好
// /uikit/generic/ButtonTypes.ets
export enum ButtonType {
  Primary,
  Secondary,
  Ghost,
  Danger,
}

export enum ButtonSize {
  S,
  M,
  L,
}
4.1.2 Button 组件核心实现
// /uikit/generic/AppButton.ets
import { ButtonType, ButtonSize } from './ButtonTypes';

export interface ButtonStyleOverride {
  bgColor?: string;
  textColor?: string;
  radius?: number;
}

@Component
export struct AppButton {
  @Consume theme;
  @Prop text: string = '';
  @Prop type: ButtonType = ButtonType.Primary;
  @Prop size: ButtonSize = ButtonSize.M;
  @Prop disabled: boolean = false;
  @Prop loading: boolean = false;
  @Prop fullWidth: boolean = false;
  @Prop icon?: Resource;
  @Prop styleOverride?: ButtonStyleOverride;
  @Prop onClick?: () => void;

  private getHeight(): number {
    switch (this.size) {
      case ButtonSize.S: return 32;
      case ButtonSize.M: return 40;
      case ButtonSize.L: return 48;
      default: return 40;
    }
  }

  private getTextSize(): number {
    switch (this.size) {
      case ButtonSize.S: return 14;
      case ButtonSize.M: return 16;
      case ButtonSize.L: return 18;
      default: return 16;
    }
  }

  private computeColors(): { bg: string; text: string; border?: string } {
    let bg = '';
    let text = '';
    let border = '';

    switch (this.type) {
      case ButtonType.Primary:
        bg = this.theme.buttonPrimaryBg;
        text = this.theme.buttonPrimaryText;
        break;
      case ButtonType.Secondary:
        bg = 'transparent';
        text = this.theme.primary;
        border = this.theme.primary;
        break;
      case ButtonType.Ghost:
        bg = 'transparent';
        text = this.theme.textPrimary;
        border = this.theme.borderSubtle;
        break;
      case ButtonType.Danger:
        bg = this.theme.dangerBg ?? '#FF3B30';
        text = this.theme.buttonPrimaryText;
        break;
    }

    if (this.disabled) {
      bg = this.theme.buttonDisabledBg ?? '#D1D1D6';
      text = this.theme.buttonDisabledText ?? '#A1A1A6';
      border = border || bg;
    }

    if (this.styleOverride) {
      bg = this.styleOverride.bgColor ?? bg;
      text = this.styleOverride.textColor ?? text;
    }

    return { bg, text, border };
  }

  build() {
    const { bg, text, border } = this.computeColors();
    const height = this.getHeight();
    const fontSize = this.getTextSize();
    const radius = this.styleOverride?.radius ?? this.theme.buttonRadius;

    Button(this.loading ? '处理中…' : this.text)
      .height(height)
      .width(this.fullWidth ? '100%' : 'auto')
      .backgroundColor(bg)
      .fontColor(text)
      .borderRadius(radius)
      .fontSize(fontSize)
      .borderWidth(border ? 1 : 0)
      .borderColor(border ?? 'transparent')
      .enabled(!this.disabled && !this.loading)
      .onClick(() => {
        if (this.disabled || this.loading) return;
        this.onClick && this.onClick();
      })
  }
}

是的,这个 Button 已经具备:

  • 类型区分(主 / 次 / 幽灵 / 危险)
  • 尺寸控制(S/M/L)
  • loading / disabled 状态处理
  • 支持主题 & 样式覆盖
  • 适合作为“组件库级别的 Button”

实际使用:

AppButton({
  text: '登录',
  type: ButtonType.Primary,
  size: ButtonSize.L,
  fullWidth: true,
  loading: this.logining,
  onClick: () => this.handleLogin()
})

AppButton({
  text: '取消',
  type: ButtonType.Ghost,
  size: ButtonSize.M,
  onClick: () => this.goBack()
})

4.2 Dialog:一个组件里塞交互、动画、蒙层、按钮,还得可扩展

一个好用的 Dialog,大概要具备:

  • 可控显示 / 隐藏(visible / onOpenChange 或直接 show(): void 机制)
  • 标题 / 内容 / 底部按钮支持
  • 点击蒙层是否关闭(可配置)
  • 按钮样式可控(配合组件库 Button)
  • 支持插槽式内容(比如传入一个复杂布局)
4.2.1 Dialog Props 设计
// /uikit/feedback/AppDialog.ets
@Component
export struct AppDialog {
  @Consume theme;

  @Prop visible: boolean = false;
  @Prop title?: string;
  @Prop message?: string;
  @Prop cancelText: string = '取消';
  @Prop confirmText: string = '确认';
  @Prop showCancel: boolean = true;
  @Prop barrierDismissible: boolean = true; // 点击蒙层是否关闭
  @Prop onCancel?: () => void;
  @Prop onConfirm?: () => void;
  @Prop onVisibleChange?: (v: boolean) => void;

  // 插槽:允许传入自定义内容
  @Slot customContent?: () => void;

  private close() {
    this.onVisibleChange && this.onVisibleChange(false);
  }

  private handleCancel() {
    this.onCancel && this.onCancel();
    this.close();
  }

  private handleConfirm() {
    this.onConfirm && this.onConfirm();
    this.close();
  }

  build() {
    if (!this.visible) {
      // 不渲染
      return;
    }

    Stack() {
      // 蒙层
      Blank()
        .width('100%')
        .height('100%')
        .backgroundColor(this.theme.dialogMask)
        .onClick(() => {
          if (this.barrierDismissible) {
            this.close();
          }
        })

      // 弹窗主体
      Column() {
        if (this.title) {
          Text(this.title)
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .margin({ bottom: 8 })
        }

        if (this.customContent) {
          this.customContent!();
        } else if (this.message) {
          Text(this.message)
            .fontSize(15)
            .textAlign(TextAlign.Center)
            .margin({ bottom: 16 })
        }

        Row() {
          if (this.showCancel) {
            AppButton({
              text: this.cancelText,
              type: ButtonType.Ghost,
              fullWidth: true,
              onClick: () => this.handleCancel()
            })
          }

          AppButton({
            text: this.confirmText,
            type: ButtonType.Primary,
            fullWidth: true,
            onClick: () => this.handleConfirm()
          })
            .margin({ left: this.showCancel ? 8 : 0 })
        }
        .margin({ top: 16 })
      }
      .padding(20)
      .backgroundColor(this.theme.dialogBg)
      .borderRadius(16)
      .width('80%')
      .alignItems(HorizontalAlign.Center)
      .zIndex(10)
    }
    .width('100%')
    .height('100%')
  }
}
4.2.2 使用示例:页面中调用 Dialog
@Entry
@Component
struct DemoPage {
  @State showLogoutDialog: boolean = false;

  build() {
    Column() {
      AppButton({
        text: '退出登录',
        type: ButtonType.Danger,
        fullWidth: true,
        onClick: () => this.showLogoutDialog = true
      })
      .margin({ top: 40 })

      // Dialog 放在页面末尾
      AppDialog({
        visible: this.showLogoutDialog,
        title: '确定要退出登录吗?',
        message: '退出后需要重新输入账号密码才能登录。',
        onVisibleChange: (v: boolean) => this.showLogoutDialog = v,
        onConfirm: () => {
          // 执行退出逻辑
          this.logout();
        }
      })
    }
    .width('100%')
    .height('100%')
    .alignItems(HorizontalAlign.Center)
    .justifyContent(FlexAlign.Center)
  }

  logout() {
    // 具体退出逻辑
  }
}

这一套 Button + Dialog,再加上前面的主题机制,已经构成一个最小可用 UI 库雏形


五、把组件库当“产品”做,而不是当“代码堆砌仓库”

写到这里你会发现:
写一个鸿蒙 UI 组件库,并不是“多写几个 .ets 文件”这么简单,而是一整个思路的转变:

  1. 从页面思维 → 变成组件思维
  2. 从一次性写死 → 变成可复用 + 可扩展
  3. 从颜色写死 → 变成有主题、有 Token
  4. 从“先凑合用” → 变成“别人用起来也舒服”

如果你愿意稍微“职业病”一点,把组件文档也搞起来——给每个组件写:

  • 使用示例
  • API 参数解释
  • 主题配置说明
  • 场景建议(适合在哪些页面用)

那么你未来不光是自己团队的救星,甚至可以把这套组件库抽掉,独立成为你们公司的设计体系一部分

如果觉得有帮助,别忘了点个赞+关注支持一下~
喜欢记得关注,别让好内容被埋没~

Logo

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

更多推荐