组件都写成一坨,还敢叫 UI 库?——从 0 打造鸿蒙自定义组件库的实战心法
本文探讨了如何系统化设计鸿蒙(HarmonyOS/ArkTS)下的可复用UI组件库。主要内容包括: 组件分层架构:提出基础能力层、通用组件层和业务组件层的分层设计思想; API设计规范:强调语义化命名、枚举类型、布尔属性等设计原则; 主题扩展方案:推荐建立三级主题系统(基础色板/语义色/组件Token); 组件封装示例:通过Button组件展示分层实现和主题应用。 核心观点:组件库设计应当遵循&q
大家好,我是[晚风依旧似温柔],新人一枚,欢迎大家关注~
本文目录:
前言
先问一句扎心的:
你是不是也干过这种事——一个 Button 写完 copy 到下一个页面,改个颜色又 copy 一份,再改下圆角再 copy 一份,两周之后整个项目里出现了 PrimaryButton、PrimaryBtn、MainBtn、BigRedBtn 四个完全不一样但干同一件事的玩意?
这时候产品说:
“按钮圆角统一改成 12px,主色从蓝色改成绿色。”
你好,恭喜你,准备开一场“全项目手改按钮”的人肉重构大会。
其实,这不是你不会写组件,而是——你根本没拿它当“组件库”来设计。
这篇我们就来较真一把:
在鸿蒙(HarmonyOS / ArkTS + ArkUI)下,怎么系统性地设计一套“能复用、能扩展、能换肤”的自定义 UI 组件库?
按你给的大纲,我会从四个维度展开:
- 组件分层设计——不要所有组件都往一个文件里怼
- API 设计规范——一个好组件,配置项必须“好看好记好用”
- 主题 / 样式扩展——如何做到“改主题不用推翻重来”
- 组件封装示例(Button / Dialog)——真刀真枪写一遍,不整 PPT 工程
全程带情绪、带吐槽、带代码,写完你可以直接把这套套路搬进自己的项目里,用一两版迭代慢慢长成自家风格的组件库。
一、组件分层设计:别再“一把梭”,组件库也是需要架构的
很多人一听“组件库”,脑子里的画面是这样的:
/components目录底下一堆.ets文件,
想到啥写啥,名字想到什么就起什么:MyButton.ets、SimpleButton.ets、RoundButton.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()
})
你会发现几个特点:
- 语义清晰:
type/size/disabled很好理解 - 易于扩展:新增一个
type: Danger是加枚举项,不是多起一个组件名 - 布尔属性常见:
loading/fullWidth都是 UI 逻辑的常见需求 - 事件命名规范:
onClick、onCancel、onConfirm
2.2 组件 API 命名几条铁律
-
不要缩写,除非是全世界都认识的那种
- ❌
txt、bg、w,h - ✔
text,backgroundColor,width,height(ArkUI 环境里很多是链式样式就不用了)
- ❌
-
属性优先用枚举而不是魔法字符串
- ❌
type: "primary"/"secondary" - ✔
type: ButtonType.Primary
- ❌
-
布尔属性要“语义正向”
- ❌
enabled: false - ✔
disabled: true(读起来更直观)
- ❌
-
禁止通过同一个属性做两三件事
- ❌
mode: "danger_outline_big" - ✔
type + variant + size拆开
- ❌
-
事件都用
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,是组件库活下去的关键
建议至少分三个层级的样式:
- 基础色板(Raw Colors):红、黄、蓝、灰阶等
- 语义色(Semantic Colors):主色、成功、警告、错误、边框、背景
- 组件级 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 文件”这么简单,而是一整个思路的转变:
- 从页面思维 → 变成组件思维
- 从一次性写死 → 变成可复用 + 可扩展
- 从颜色写死 → 变成有主题、有 Token
- 从“先凑合用” → 变成“别人用起来也舒服”
如果你愿意稍微“职业病”一点,把组件文档也搞起来——给每个组件写:
- 使用示例
- API 参数解释
- 主题配置说明
- 场景建议(适合在哪些页面用)
那么你未来不光是自己团队的救星,甚至可以把这套组件库抽掉,独立成为你们公司的设计体系一部分。
如果觉得有帮助,别忘了点个赞+关注支持一下~
喜欢记得关注,别让好内容被埋没~
更多推荐


所有评论(0)