为什么你写 ArkTS UI 总觉得别扭,而别人写得又简洁又丝滑?——从入门到最佳实践,一次给你讲透
本文是ArkTS+ArkUI开发实用指南,作者分享从新手到熟练开发者的实战经验。文章首先澄清ArkTS并非简单"换皮版TS",而是强化工程化的语言,特别强调类型规范、模块化和装饰器用法。重点解析声明式UI的核心概念——通过build()描述UI结构与约束关系,并详细讲解各类装饰器的作用:@State管理组件内部状态、@Prop处理父组件传参、@Provide实现跨组件状态共享。
大家好,我是[晚风依旧似温柔],新人一枚,欢迎大家关注~
本文目录:
前言
老实说,我第一次上手 ArkTS + ArkUI 的时候,整个人是懵的:
既像 TypeScript,又哪儿哪儿都不太一样;
既说是声明式 UI,又跟 React / Vue 的套路不完全一样;
写着写着还冒出来一堆 @State、@Prop、@Provide……
一时间我甚至怀疑:是不是只有官方 Demo 才能流畅运行,我自己的项目注定又长又丑又难维护?
后来真把几个完整项目撸下来,我才发现 ArkTS 这套东西——
如果你只是“会用”,它确实有点怪;
但如果你“真正吃透”,写起来是真的顺、性能也真能打。
这篇文章,我想带你踩一遍“已经有人踩过”的坑,然后绕开它们。
按你的大纲,我们从这几块展开:
- ArkTS 语言特性——到底跟普通 TS 有啥不一样?
- 声明式 UI 语法——build 到底在“声明”什么?
- @State / @Prop / @Provide 等状态管理——数据到底怎么在组件树里流动?
- 自定义组件——写个 Button / 卡片,到底该怎么封装?
- 复用与模块化——别把所有页面写成一个 2000 行的地狱组件
- 性能优化技巧——别等真机一卡一卡才想起来“原来有性能这回事”
不整花架子,每一节都有代码、有思路、有“过来人的碎碎念”。
你要是正准备写一个鸿蒙 App,这篇可以直接当“心法 + 代码手册”一起用。
一、ArkTS 语言特性:不是“换皮版 TS”,是更偏工程化的一套玩意
先把一个误会扯清楚:ArkTS 不是“把 TS 改个名字”。
它的语法确实很接近 TypeScript,但做了很多“更偏系统 / 更偏工程”的增强,尤其是:
- 更严格的静态检查
- 更强调编译期优化
- 对 UI & 状态有原生支持(装饰器那一套)
1.1 类型系统:别再拿 any 当“万金油”
ArkTS 还是熟悉的强类型风格,但对“乱写类型”会更严格一点。比如:
let count: number = 1;
// ❌ 这种写法直接就会被教做人
count = '1';
// 推荐写法
let name: string = 'ArkTS';
let age: number | undefined;
几点习惯,真心建议尽早养成:
- 能写具体类型就别写
any - 避免“类型靠猜”,接口 / 模型自己定义清楚
- 该用
enum的地方不用字符串硬凑
比如 UI 模式常见的写法:
enum PageMode {
VIEW,
EDIT,
CREATE,
}
@State mode: PageMode = PageMode.VIEW;
比你到处写 "view" | "edit" | "create" 要稳太多。
1.2 类、模块、import/export 都很熟悉,但更讲究“规范”
ArkTS 模块化这块基本和 TS 一样,用法没什么门槛:
// user-model.ets
export interface User {
id: number;
name: string;
age: number;
}
// service.ets
import type { User } from './user-model';
export async function fetchUser(id: number): Promise<User> {
// ...
}
但真正在项目里要注意的是:
model、service、ui按层拆模块- UI 文件别同时干接口请求、数据持久化、业务逻辑所有事情
- 组件层只引用“向上封装好的” service,不要直接调最底层
后面讲“复用与模块化”时咱们会把这套结构捋得更细。
1.3 装饰器的“正经用法”:不只是好看,是 ArkUI 的灵魂
ArkTS 最大的特色之一,就是装饰器用在“UI + 状态”上,这跟普通 TS 里那种“装饰一个 class 玩玩注解”的思路完全不是一个级别。
最常见的几个:
@Entry:应用入口组件(一个 Ability 的 root)@Component:声明这是一个 UI 组件@State:组件自身持有、会触发刷新的状态@Prop:从父组件传入的属性(只读)@Provide/@Consume:上下文注入 / 依赖注入式的状态共享
例子先看一眼感受下:
@Entry
@Component
struct HomePage {
@State count: number = 0;
build() {
Column() {
Text(`当前计数:${this.count}`)
Button('加一')
.onClick(() => this.count++)
}
}
}
哪怕你没看文档,大概也能猜到:
每次 this.count++,UI 会自动重建显示新的数字。
这就是 ArkTS + ArkUI 的核心感觉:你写的是“状态 → UI 的映射关系”,不是传统那种手动改 DOM / 刷 UI。
二、声明式 UI 语法:build() 里面到底在“声明”什么?
很多人刚接触 ArkUI 时,看到这个写法会有点懵:
build() {
Column() {
Text('Hello')
Button('点我')
}
}
看起来像函数,又不像 JSX,那这玩意到底是什么?
2.1 你写的不是“过程”,你写的是“结构 + 约束”
理解 ArkUI 的关键一句话:
build() 里写的不是“执行过程”,而是在声明 UI 的结构和属性。
你可以把它当成一种特殊语法的 DSL(领域专用语言):
Column()/Row()/List()类似“组件标签”- 花括号
{ ... }内部是子组件 - 组件后面的
.width()、.height()、.backgroundColor()是属性链式设置
比如一个简单卡片:
build() {
Column() {
Text(this.title)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.margin({ bottom: 8 })
Text(this.desc)
.fontSize(14)
.opacity(0.8)
}
.padding(16)
.backgroundColor('#FFFFFF')
.borderRadius(12)
}
这段的含义是:
- 外面是一个列容器 Column(相当于
<div style="display:flex;flex-direction:column">那味) - 里面两个 Text 垂直排列
- 最外层 Column 有 padding / 背景色 / 圆角
2.2 生命周期钩子:不仅有 build,还有“出现 / 消失 / 首次构建”
常见生命周期:
aboutToAppear():组件即将出现在界面时调用,适合初始化数据aboutToDisappear():组件即将从界面移除onPageShow()/onPageHide():配合导航场景使用
举个常见场景:页面打开拉首屏数据:
@Entry
@Component
struct UserPage {
@State loading: boolean = true;
@State userName: string = '';
aboutToAppear() {
this.loadUser();
}
async loadUser() {
try {
const user = await fetchUserFromApi();
this.userName = user.name;
} finally {
this.loading = false;
}
}
build() {
Column() {
if (this.loading) {
Text('加载中…')
} else {
Text(`你好,${this.userName}`)
}
}
}
}
小心得:
- 在生命周期里做异步请求很正常,但一定记得做异常处理 + 状态兜底,不然 UI 很容易卡在一个奇怪的状态。
三、@State / @Prop / @Provide:ArkTS 状态管理写好了,UI 自然顺
实话说,很多鸿蒙项目一开始写得很痛苦,很大一部分就是因为状态管理没想明白:@State 到底什么时候用?
父子传参到底用 @Prop 还是 @Link?
全局主题 / 用户信息该用 @Provide 吗?
咱们一块一块拆开。
3.1 @State:组件自己的“小情绪”
@State 是“组件内部管理的状态”,特点:
- 只在当前组件内部可修改
- 修改会触发当前组件重新 build(以及使用它的子树)
- 不应该被外部强行控制
比如最经典的计数器:
@Component
struct Counter {
@State count: number = 0;
build() {
Row() {
Button('-').onClick(() => this.count--)
Text(this.count.toString()).margin({ left: 10, right: 10 })
Button('+').onClick(() => this.count++)
}
}
}
用它的时候,不需要操心它内部怎么管理 count,只要把它当“一个会自己变的组件”就行。
3.2 @Prop:只读的“外部输入”,不要在里面乱改
@Prop 表示“从父组件传进来的属性”,有几个原则:
- 子组件可以读,但不应该直接改
- 父组件改了
@Prop绑定的值,子组件会自动更新 - 更适合作为“配置项”而不是“状态源头”
例子:
@Component
struct UserCard {
@Prop name: string;
@Prop age: number;
build() {
Column() {
Text(this.name)
Text(`${this.age} 岁`)
.fontSize(12)
.opacity(0.8)
}
}
}
// 使用
UserCard({ name: '张三', age: 18 })
如果你在子组件里写:
this.name = '李四'; // ❌ 这是和数据流作对
那就说明这段设计思路一开始就不对。
3.3 @Link:父子双向绑定,一定要“克制”使用
有时候你确实希望子组件能“改动父组件状态”,比如:
- 开关组件:内部切换 on/off,其实要影响外部状态
- 表单输入:子组件是输入框,但数据存的是父组件那一坨 form 对象
这时候就轮到 @Link 出场了。
// 父组件
@Entry
@Component
struct SettingsPage {
@State enableDarkMode: boolean = false;
build() {
Column() {
SwitchRow({
label: '深色模式',
value: $enableDarkMode, // 注意这里的 $,传的是“引用”
})
Text(this.enableDarkMode ? '已开启深色' : '浅色模式')
}
}
}
// 子组件
@Component
struct SwitchRow {
@Prop label: string;
@Link value: boolean;
build() {
Row() {
Text(this.label)
Toggle({ checked: this.value })
.onChange((checked) => {
this.value = checked; // 这里会同步到父组件的 enableDarkMode
})
}
}
}
关键点:
- 父组件传
$state,子组件用@Link接- 子组件改
value,等于在改父组件的enableDarkMode- 用的时候一定要节制,别到处搞双向绑定,不然数据流难排查
3.4 @Provide / @Consume:全局 / 跨层级共享状态的“注入方案”
你总会遇到这种需求:
- 整个 App 共享一个主题对象 theme
- 全局用户信息 userInfo
- 多个页面都需要访问某个 config
这时候如果靠一层层用 @Prop 传,传到你自己都崩溃。
ArkTS 给了一套非常自然的做法:@Provide + @Consume。
// 顶层
@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
HomePage()
}
.backgroundColor(this.theme.pageBgColor)
}
}
子组件里:
@Component
struct HomePage {
@Consume theme;
build() {
Column() {
Text('首页')
.fontColor(this.theme.titleColor)
// ...
}
}
}
这个模式,非常适合做:
- 主题(Theme)
- 语言(I18N 文本资源)
- 全局配置 / 环境变量
3.5 小结:状态选型一句话口诀
- 组件内部小状态:@State
- 父传子只读配置:@Prop
- 父子同步改值:@Link(慎用)
- 全局 / 跨层级共享:@Provide + @Consume
记住一句话:
状态越“局部”,越好维护;一上来全想做全局,后面你会被自己整崩溃。
四、自定义组件:不要一上来就写“大页面”,先学会写好“小组件”
几乎所有“写着写着项目开始失控”的鸿蒙 UI,都有一个共同特征:
——页面里塞满了所有 UI 细节,完全不做组件拆分。
那怎么拆?从最小的 UI 片段开始:Button、Card、ListItem、Dialog……
4.1 写一个有点“人味”的 Button 组件
我们写一个略微“正式一点”的按钮:
- 支持主按钮 / 次按钮
- 支持禁用 / 加载中
- 支持宽度铺满
enum ButtonType {
Primary,
Secondary,
}
@Component
struct AppButton {
@Prop text: string;
@Prop type: ButtonType = ButtonType.Primary;
@Prop disabled: boolean = false;
@Prop loading: boolean = false;
@Prop fullWidth: boolean = false;
@Prop onClick?: () => void;
private getBgColor(): string {
if (this.disabled) {
return '#CCCCCC';
}
return this.type === ButtonType.Primary ? '#007DFF' : 'transparent';
}
private getTextColor(): string {
if (this.disabled) {
return '#888888';
}
return this.type === ButtonType.Primary ? '#FFFFFF' : '#007DFF';
}
build() {
Button(this.loading ? '处理中…' : this.text)
.backgroundColor(this.getBgColor())
.fontColor(this.getTextColor())
.borderRadius(12)
.borderWidth(this.type === ButtonType.Secondary ? 1 : 0)
.borderColor('#007DFF')
.width(this.fullWidth ? '100%' : 'auto')
.enabled(!this.disabled && !this.loading)
.onClick(() => {
if (this.disabled || this.loading) return;
this.onClick && this.onClick();
})
}
}
使用起来就很清爽:
AppButton({
text: '登录',
type: ButtonType.Primary,
fullWidth: true,
loading: this.logining,
onClick: () => this.submit()
})
你会发现,自定义组件本质上就是:
用一层“语义更强”的封装,把底层 Button / Text / Row 这些原子组件组织起来。
4.2 一个典型“卡片组件”:传数据、暴露点击事件
再来一个 UserCard 示例:
export interface UserInfo {
name: string;
role: string;
avatarUrl?: string;
}
@Component
struct UserCard {
@Prop user: UserInfo;
@Prop onClick?: () => void;
build() {
Row() {
// 左边头像
if (this.user.avatarUrl) {
Image(this.user.avatarUrl)
.width(40)
.height(40)
.borderRadius(20)
} else {
// 简单用首字母占位
Text(this.user.name.slice(0, 1))
.width(40)
.height(40)
.borderRadius(20)
.backgroundColor('#EEEEEE')
.fontSize(20)
.textAlign(TextAlign.Center)
.alignItems(VerticalAlign.Center)
}
// 右边文字
Column() {
Text(this.user.name)
.fontSize(16)
.fontWeight(FontWeight.Bold)
Text(this.user.role)
.fontSize(12)
.opacity(0.7)
}
.margin({ left: 12 })
}
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(12)
.shadow({ radius: 6, color: '#00000022', offsetX: 0, offsetY: 2 })
.onClick(() => this.onClick && this.onClick())
}
}
调用:
UserCard({
user: { name: '张三', role: '开发工程师' },
onClick: () => this.gotoUserDetail()
})
习惯用这种方式封装,哪怕 UI 改版,你只改
UserCard的内部实现,使用的地方基本不动,眼泪立刻少流一半。
五、复用与模块化:从“写页面”升级为“写界面系统”
到这一步,其实你已经可以写出“能看的 UI”了,但距离“工程级”还有一段——怎么把这些组件、页面和逻辑组织得有层次?
5.1 一个推荐的项目结构(可以直接抄过去用)
/entry/src/main
/common
theme.ets # 主题 / 颜色 / 字体等
constants.ets # 一些常量
types.ets # 全局类型
/model
user-model.ets
todo-model.ets
/service
http-client.ets
user-service.ets
todo-service.ets
/uikit # 你自己的组件库
/foundation
/tokens
/generic
/feedback
/pages
HomePage.ets
UserDetailPage.ets
SettingsPage.ets
/components # 业务级组件(跟 uikit 区分开)
TodoList.ets
UserList.ets
几个核心思路:
-
UI 基础组件(Button、Dialog)放
/uikit,可复用性高,尽量无业务逻辑 -
某个业务强相关的组件(例如“任务列表卡片”)放
/components -
页面级
.ets文件只负责:- 串业务
- 做路由
- 管理页面级状态
-
所有请求、缓存的细节尽量压到
/service里去
5.2 模块化 = “UI 复用 + 逻辑复用”
以一个“待办任务列表页面”为例:
-
TodoService负责:- 拉取任务列表
- 新增 / 完成 / 删除任务
- 本地缓存等
// /service/todo-service.ets
import type { TodoItem } from '../model/todo-model';
export class TodoService {
async listTodos(): Promise<TodoItem[]> {
// 请求接口 / 读本地存储…
}
async addTodo(title: string): Promise<void> {
// ...
}
async completeTodo(id: number): Promise<void> {
// ...
}
}
- UI 组件
TodoList负责展示和交互:
// /components/TodoList.ets
@Component
export struct TodoList {
@Prop items: TodoItem[];
@Prop onToggle?: (item: TodoItem) => void;
@Prop onDelete?: (item: TodoItem) => void;
build() {
List() {
LazyForEach(this.items, (item: TodoItem) => {
ListItem() {
Row() {
Checkbox({ checked: item.completed })
.onChange(() => this.onToggle && this.onToggle(item))
Text(item.title)
.decoration(item.completed ? TextDecoration.LineThrough : TextDecoration.None)
Blank()
Button('删除')
.onClick(() => this.onDelete && this.onDelete(item))
}
}
})
}
}
}
- 页面
TodoPage串起来:
// /pages/TodoPage.ets
@Entry
@Component
struct TodoPage {
private todoService: TodoService = new TodoService();
@State loading: boolean = true;
@State todos: TodoItem[] = [];
aboutToAppear() {
this.refresh();
}
async refresh() {
this.loading = true;
this.todos = await this.todoService.listTodos();
this.loading = false;
}
build() {
Column() {
if (this.loading) {
Text('加载中…')
} else {
TodoList({
items: this.todos,
onToggle: async (item: TodoItem) => {
await this.todoService.completeTodo(item.id);
await this.refresh();
},
onDelete: async (item: TodoItem) => {
await this.todoService.deleteTodo(item.id);
await this.refresh();
}
})
}
}
}
}
你看:
- 页面不关心 UI 细节(TodoList 包了)
- UI 不关心数据来源(通过 Props + 回调通信)
- Service 不关心 UI 怎么展示
这就是一个比较健康的 ArkTS 模块化结构。
六、性能优化技巧:写得优雅,也要跑得丝滑
等你页面写多了,就会遇到那句开发者名言:“能跑不代表好用。”
真机上一滑一卡的那种窘迫,谁体验谁知道。
ArkTS + ArkUI 这套在性能上其实挺给力,但用不好也能被你写卡。
下面这几条是我自己踩坑总结出来的“血泪经验”。
6.1 @State 粒度控制:状态写高了,整棵树都跟着抖
问题案例:
@Entry
@Component
struct BigPage {
@State selectedId: number = -1;
build() {
Column() {
Header()
// 列表非常长
List() {
LazyForEach(this.getItems(), (item) => {
ListItem() {
ItemRow({
item: item,
selected: this.selectedId === item.id,
onClick: (id: number) => this.selectedId = id
})
}
})
}
FooterInfo({ selectedId: this.selectedId })
}
}
}
每次你修改 selectedId,BigPage 整个 build() 会重新执行一遍,整棵 UI 树都可能参与重建。
优化方式:
- 把列表抽成子组件
- 甚至可以把“选中状态”局部化
@Entry
@Component
struct BigPage {
@State selectedId: number = -1;
build() {
Column() {
Header()
ItemList({
selectedId: this.selectedId,
onSelect: (id: number) => this.selectedId = id
})
FooterInfo({ selectedId: this.selectedId })
}
}
}
再进一步,在 ItemList 里想办法减少无关项的刷新。
6.2 避免在 build() 里做重逻辑 / 重计算
千万不要在 build() 里做任何“跟渲染无关的复杂逻辑”。build() 可能被调用很多次,把它当纯函数用,别在里面搞:
- 大量循环
- IO 操作
- 网络请求
- JSON 大解析
这些都应该放生命周期里,结果进 @State,build() 只负责根据 @State 渲染。
6.3 列表一定要用 LazyForEach,别拿 ForEach 硬刚
列表稍微长一点,就乖乖用 LazyForEach:
List() {
LazyForEach(this.items, (item: Item) => {
ListItem() {
ItemRow({ item })
}
})
}
LazyForEach 帮你做了可见区域复用,
如果用普通 ForEach,你就等着大列表滑动时白给。
6.4 大任务拆分:一帧做一点,别一口吃成胖子
有些计算你确实必须在前端做,比如:
- 复杂过滤
- 客户端排序 / 聚合
- 格式化大量数据
这时候尽量“分片执行”:
async function heavyCalcChunk(list: number[]): Promise<number[]> {
let result: number[] = [];
const CHUNK_SIZE = 2000;
let index = 0;
return new Promise(resolve => {
function runChunk() {
const start = Date.now();
while (index < list.length) {
result.push(list[index] * 2);
index++;
if (index % CHUNK_SIZE === 0 && Date.now() - start > 8) {
// 分帧执行,留时间给 UI
globalThis.setTimeout(runChunk, 0);
return;
}
}
resolve(result);
}
runChunk();
});
}
这种写法比一口气卡主线程要友好得多。
6.5 动画、过渡别乱写,善用系统能力
如果你要加一些小动画,优先用 ArkUI 提供的动画能力,不要自己用疯狂 setTimeout 去改状态。
原则:
- 动画用系统 API
- 状态更新频率控制在合理范围
- 仅动画相关的那一小块组件参与变化
写在最后:ArkTS 写久了,你会发现它更像“搭积木”,不是“凑页面”
如果你一开始上手 ArkTS + ArkUI 的感受是“有点别扭”,那很正常。
因为它强迫你从一开始就思考:
- 状态放哪一层最合理?
- 这个 UI 段落是不是应该抽成组件?
- 能不能通过主题注入统一样式?
- 这个计算重不重,会不会卡 UI?
说难听点,就是它不太允许你“随便糊一个能运行的界面就完事”;
但说好听点,只要你遵循这套思路,项目越大,你越庆幸当初没偷懒。
把今天这几点记住:
- ArkTS 是 TS 的进阶形态:更强调类型、更重视工程约束
- 声明式 UI = 写“状态到界面”的映射,build 要保持“干净”
- @State / @Prop / @Provide 把数据流理顺了,组件自然好用
- 自定义组件一定要语义化、可复用,不要 copy 贴页面代码
- 模块化拆分:uikit / 业务组件 / 页面 / service 各司其职
- 性能从一开始就要有意识,别等用户来当你的测试员
等你真用这套思路完整写完一个鸿蒙 App,你会发现:
以前那种“改个按钮颜色得全项目搜索”的噩梦,慢慢就离你远去了。
如果觉得有帮助,别忘了点个赞+关注支持一下~
喜欢记得关注,别让好内容被埋没~
更多推荐




所有评论(0)