

前言
前面我们用 @State 让数据驱动 UI,所有状态都在 Index 这一个组件里。但实战中组件要拆分------HUD、棋盘、底部栏各自独立,才好维护复用。拆分后遇到第一个问题:父组件的得分怎么传给子组件 HUD? @State 只在当前组件有效,跨组件要用 @Prop(单向只读) 或 @Link(双向)。
本篇以「猫猫大作战」把 HUD 拆成独立子组件、用 @Prop 接收父组件得分/时间为锚点,把 @Prop 声明与传递 、单向只读语义 、@Prop 的浅拷贝 、@Prop vs @Link 取舍四大要点讲透。
提示:本系列不讲 ArkTS 基础语法与环境搭建,假设你已跟完第 1--39 篇。本篇是阶段二第十篇。
一、场景拆解:HUD 拆分子组件
回顾「猫猫大作战」HUD(第 11、23、24 篇)是 Index 内的 @Builder:
ts
// 来源:entry/src/main/ets/pages/Index.ets
@Entry
@Component
struct Index {
@State score: number = 0;
@State combo: ComboInfo = { /* ... */ };
@State gameTime: number = 0;
@Builder
GameHUD() {
Row() {
Column() { Text('得分'); Text(this.score.toString()) } // ← 直接读 Index 的 @State
/* ... 连击、时间 */
}
}
}
痛点 :HUD 逻辑和 Index 主逻辑混在一起,Index 越来越臃肿。想把 HUD 拆成独立组件 GameHUD:
ts
@Component
struct GameHUD {
@Prop score: number; // ← 从父接收
@Prop combo: ComboInfo;
@Prop gameTime: number;
build() {
Row() {
Column() { Text('得分'); Text(this.score.toString()) } // 读 @Prop
/* ... */
}
}
}
父组件 Index 传值:
ts
GameHUD({ score: this.score, combo: this.combo, gameTime: this.gameTime })
关键经验 :@Prop = 父传子单向只读------父改值子同步刷新,子改值不回传父。
二、@Prop 基本用法
2.1 子组件声明 @Prop
ts
@Component
struct GameHUD {
@Prop score: number; // 从父接收,只读
@Prop combo: ComboInfo;
@Prop gameTime: number;
build() {
Row() {
Column() {
Text('得分').fontSize(11).fontColor('#95A5A6')
Text(this.score.toString()) // 读 @Prop
.fontSize(22).fontWeight(FontWeight.Bold).fontColor('#2C3E50')
}
/* ... 连击、时间 */
}
}
}
拆解:
| 片段 | 含义 |
|---|---|
@Prop |
装饰器,标记「从父接收只读数据」 |
score |
变量名 |
number |
类型(必须与父传值类型一致) |
关键约束 :@Prop 不需要初始值------值由父传入,不赋默认。但 ArkTS 严格模式可能要显式类型。
2.2 父组件传值
ts
@Entry
@Component
struct Index {
@State score: number = 0;
@State combo: ComboInfo = { count: 0, multiplier: 1, lastMergeTime: 0 };
@State gameTime: number = 0;
build() {
Column() {
GameHUD({
score: this.score, // 传 @State score
combo: this.combo, // 传 @State combo
gameTime: this.gameTime // 传 @State gameTime
})
/* ... 棋盘、底部栏 */
}
}
}
传递语法 :子组件名({ prop1: 父值1, prop2: 父值2 })------类似 React 的 props。
2.3 单向同步机制
ts
// 父改 @State,子 @Prop 自动同步
this.score = 99;
// ArkUI:
// 1. Index 的 @State score = 99
// 2. 检测到 GameHUD 的 @Prop score 依赖父 @State score
// 3. 把 99 同步到子 @Prop score
// 4. 子组件 build() 重渲染,Text('99')
关键经验 :@Prop 是「父改子同步,子改父不回」的单向流------数据从父流向子,子组件不能改 @Prop 反向影响父。
三、@Prop 的只读语义
3.1 子组件不能改 @Prop
ts
@Component
struct GameHUD {
@Prop score: number;
build() {
Button('加 10').onClick(() => {
this.score += 10; // ❌ @Prop 只读,改了不回传父,且可能ArkUI 警告
})
}
}
为什么只读 :@Prop 是「父数据的子副本」,子改了不回传父,会导致父子数据不一致------ArkUI 为保证数据流单向,子改 @Prop 不影响父,且子下次重渲染会被父值覆盖。
3.2 子要改数据用 @Event 回调父
子组件想「加 10」,要通过回调让父改:
ts
@Component
struct GameHUD {
@Prop score: number;
@Event onScoreAdd: (delta: number) => void; // 事件回调(第 49 篇专讲)
build() {
Button('加 10').onClick(() => {
this.onScoreAdd(10); // 触发事件,让父改 @State score
})
}
}
// 父
@Entry
@Component
struct Index {
@State score: number = 0;
build() {
GameHUD({
score: this.score,
onScoreAdd: (delta: number) => { this.score += delta; } // 父改 @State
})
}
}
关键经验 :子改数据用 @Event 回调父,@Prop 只用于显示------这是单向数据流的标准模式。本系列第 49 篇会专讲 @Event。
3.3 @Prop 的浅拷贝
ts
@Prop combo: ComboInfo; // 接收对象
机制 :@Prop 对父传入的对象做浅拷贝------子得到一份副本,改副本内部属性不影响父,但改了也不触发子重渲染(@Prop 浅观察)。
ts
// 父
this.combo = { count: 5, multiplier: 3, lastMergeTime: Date.now() };
// 子 @Prop combo 收到新副本,重渲染
// 子改 @Prop combo 内部属性
this.combo.count = 10; // ⚠️ 不触发子重渲染,且不回传父
// 父的 combo.count 还是 5
实战经验 :@Prop 对象当作只读快照用------别改内部属性,要改让父改整个对象传入。
四、完整改造:HUD 拆为子组件
4.1 创建 GameHUD 子组件
新建 entry/src/main/ets/components/GameHUD.ets:
ts
import { ComboInfo } from './GameTypes';
import { formatTime } from './GameUtils'; // 假设有工具函数
@Component
export struct GameHUD {
@Prop score: number;
@Prop combo: ComboInfo;
@Prop gameTime: number;
build() {
Row() {
// 左:得分
Column() {
Text('得分')
.fontSize(11).fontColor('#95A5A6')
Text(this.score.toString())
.fontSize(22).fontWeight(FontWeight.Bold).fontColor('#2C3E50')
}
.layoutWeight(2)
.displayPriority(1)
.alignItems(HorizontalAlign.Start)
// 中:连击(条件渲染)
if (this.combo.count > 1) {
Row() {
Text(`🔥 x${this.combo.multiplier}`)
.fontSize(18).fontWeight(FontWeight.Bold).fontColor('#E74C3C')
}
.layoutWeight(1)
.displayPriority(2)
.justifyContent(FlexAlign.Center)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.backgroundColor('rgba(231, 76, 60, 0.1)')
.borderRadius(16)
}
// 右:时间
Column() {
Text('时间')
.fontSize(11).fontColor('#95A5A6')
Text(formatTime(this.gameTime))
.fontSize(18).fontWeight(FontWeight.Medium).fontColor('#2C3E50')
}
.layoutWeight(2)
.displayPriority(1)
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding({ left: 20, right: 20, top: 12, bottom: 8 })
}
}
4.2 Index 引用并传值
ts
// 来源:entry/src/main/ets/pages/Index.ets(改造后)
import { GameHUD } from '../components/GameHUD';
import { Cat, CatLevel, GameConfig, CatConfig, GameState, ComboInfo } from '../components/GameTypes';
import { GameEngine } from '../components/GameEngine';
import { formatTime } from '../components/GameUtils';
@Entry
@Component
struct Index {
@State gameState: GameState = GameState.IDLE;
@State score: number = 0;
@State cats: Cat[] = [];
@State combo: ComboInfo = { count: 0, multiplier: 1, lastMergeTime: 0 };
@State nextCatLevel: CatLevel = CatLevel.SMALL;
@State highScore: number = 0;
@State gameTime: number = 0;
@State maxCombo: number = 0;
@State mergeCount: number = 0;
@State highestLevel: CatLevel = CatLevel.SMALL;
@State @Watch('onGameStateChange') gameState: GameState = GameState.IDLE;
private gameEngine: GameEngine = new GameEngine();
private gameLoopTimer: number = -1;
private spawnTimer: number = -1;
private timeTimer: number = -1;
private readonly cols: number[] = [0, 1, 2, 3, 4];
private readonly rows: number[] = [0, 1, 2, 3, 4, 5, 6, 7];
/* startGame / pauseGame / resumeGame / endGame / handleColumnClick / clearTimers 筁略 */
formatTime(seconds: number): string {
const min = Math.floor(seconds / 60);
const sec = seconds % 60;
return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
}
build() {
Stack() {
if (this.gameState === GameState.IDLE) {
this.MainMenuView()
} else {
this.GameView()
}
if (this.gameState === GameState.PAUSED) {
this.PauseOverlay()
}
if (this.gameState === GameState.GAME_OVER) {
this.GameOverOverlay()
}
}
.width('100%').height('100%')
}
@Builder
GameView() {
Column() {
// 改造:用子组件替代 @Builder GameHUD
GameHUD({
score: this.score, // 传 @State
combo: this.combo,
gameTime: this.gameTime
})
Column() {
/* 预告区、棋盘 */
}.alignItems(HorizontalAlign.Center)
Spacer()
/* 底部控制栏 */
}
.width('100%').height('100%')
.linearGradient({
direction: GradientDirection.Bottom,
colors: [['#E8F4F8', 0.0], ['#D6EEF5', 0.5], ['#C9E8F2', 1.0]]
})
.alignItems(HorizontalAlign.Center)
}
/* MainMenuView / PauseOverlay / GameOverOverlay / StatItem 等略 */
}
4.3 改造对比
| 维度 | 原 @Builder 版 | 改造 @Prop 子组件版 |
|---|---|---|
| HUD 代码位置 | Index 内的 @Builder |
独立 GameHUD.ets 文件 |
| 数据传递 | 直接读 this.score |
@Prop 接收 + 父传值 |
| 复用性 | ❌ 只能在 Index 用 | ✅ 任何组件可引用 |
| 可维护性 | ❌ Index 越来越臃肿 | ✅ 分文件维护 |
| 单向数据流 | ❌ 子可直接改父 state | ✅ @Prop 只读,强制单向 |
五、@Prop vs @Link vs @Event
ArkUI 三种跨组件数据流,容易混淆:
5.1 对比表
| 装饰器 | 方向 | 读写 | 适合 |
|---|---|---|---|
@Prop |
父→子 | 只读 | 显示型子组件(HUD、卡片) |
@Link |
父↔子 | 双向 | 子要改父数据(表单输入) |
@Event |
子→父 | 事件回调 | 子触发父方法(按钮点击) |
5.2 场景对照
ts
// 场景 1:HUD 显示得分 → @Prop(只读)
GameHUD({ score: this.score })
// 场景 2:表单子组件改父的 username → @Link(双向)
TextInput({ value: this.username }) // 内部用 @Link 同步
// 场景 3:子按钮点击让父加 10 → @Event(回调)
GameHUD({ score: this.score, onScoreAdd: (d) => this.score += d })
关键经验 :显示型子组件用 @Prop,改父数据用 @Link,触发父方法用 @Event。本系列第 41、49 篇会专讲 @Link、@Event。
5.3 取舍决策树
子组件要改父数据?
├─ 是 → 子要改父的 @State?
├─ 是 → @Link(双向)
└─ 否 → @Event(回调让父改)
└─ 否 → @Prop(只读显示)
六、踩坑提示
6.1 忘记父传值
ts
// ❌ 错误:没传 score,子 @Prop 是 undefined
GameHUD({ combo: this.combo, gameTime: this.gameTime })
// 子 Text(this.score.toString()) → Text('undefined')
// ✅ 正确:所有 @Prop 都传
GameHUD({ score: this.score, combo: this.combo, gameTime: this.gameTime })
6.2 类型不匹配
ts
// 父
@State score: number = 0;
// ❌ 错误:子 @Prop 声明 string,父传 number
@Component
struct GameHUD {
@Prop score: string; // 类型不匹配
}
// ✅ 正确:类型一致
@Component
struct GameHUD {
@Prop score: number; // 与父 @State 类型一致
}
6.3 子改 @Prop 期望回传父
ts
// ❌ 错误:改 @Prop 期望影响父
@Component
struct GameHUD {
@Prop score: number;
build() {
Button('加 10').onClick(() => {
this.score += 10; // 改 @Prop,不回传父!
})
}
}
// ✅ 正确:用 @Event 回调父
@Component
struct GameHUD {
@Prop score: number;
@Event onScoreAdd: (delta: number) => void;
build() {
Button('加 10').onClick(() => {
this.onScoreAdd(10);
})
}
}
6.4 对象改内部属性不触发重渲染
ts
// ❌ 错误:父改 combo.count,子 @Prop combo 不重渲染
this.combo.count = 5; // @State 浅观察,不触发;子也不同步
// ✅ 正确:父重新赋值整个 combo
this.combo = { count: 5, multiplier: 3, lastMergeTime: Date.now() };
// 父 @State 触发,子 @Prop 同步新副本
七、调试技巧
console.info在子 build 首行 :logthis.score,追 @Prop 接收的值。- 子不刷新排查:检查父是否重新赋值(对象/数组要整体赋值);检查类型是否匹配;检查是否忘传值。
- 子改了不回传排查:@Prop 本就单向,要回传用 @Event 或 @Link。
- DevEco ArkUI Inspector:查看组件树和 @Prop 绑定。
八、性能与最佳实践
- 显示型子组件用 @Prop------HUD、卡片、列表项等只读显示。
- 所有 @Prop 都要父传值------忘传子是 undefined。
- 类型与父 @State 一致------number 配 number,ComboInfo 配 ComboInfo。
- 对象/数组整体赋值才同步------@Prop 和 @State 都是浅观察。
- 子改数据用 @Event 或 @Link------@Prop 只读,改了不回传。
- @Prop 浅拷贝对象------子改副本不影响父,但也不触发重渲染。
九、阶段二进度小结(31-40)
本篇是阶段二「状态管理 + 交互 + 动画」第 10 篇,进度小结:
| 篇 | 主题 | 核心要点 |
|---|---|---|
| 31 | @State | 响应式状态,改变触发重渲染 |
| 32 | 多 @State | 同帧批量更新,一次重渲染 |
| 33 | setInterval 主循环 | 100ms 物理周期,暂停短路 |
| 34 | 计时器 | 1000ms 秒级,formatTime 格式化 |
| 35 | spawnTimer | 2000ms 自动生成,随机列选择 |
| 36 | clearTimers | -1 哨兵,三时机清理,aboutToDisappear 兜底 |
| 37 | onClick 列投放 | 闭包捕获 col,handleColumnClick 三步 |
| 38 | 箭头函数 this | 回调统一箭头保留 this,普通函数打断链 |
| 39 | @Watch | 状态变化副作用集中,防递归守卫 |
| 40(本篇) | @Prop | 父子单向只读,浅拷贝,显示型子组件 |
接下来第 41-50 篇会进入 V1 状态管理深水区:@Link 双向、@Provide/@Consume 跨层、@Observed+@ObjectLink 深观察、数组项替换、批量更新、嵌套陷阱,然后 V2 迁移:@Local、@Param、@Event、@ObservedV2+@Trace、@Monitor、AppStorageV2、PersistenceV2。
总结
本篇我们从 @Prop 父子单向切入,掌握了**@Prop 声明与父传值语法**、单向只读语义(子改不回传) 、浅拷贝与浅观察 、@Prop vs @Link vs @Event 取舍 四大要点,并给出了 HUD 拆为子组件的完整改造代码。核心要点:显示型子组件用 @Prop;所有 @Prop 都要父传值;对象整体赋值才同步;子改数据用 @Event 或 @Link。
下一篇我们将拆解 @Link------父子双向绑定。
如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!
相关资源:
- 「猫猫大作战」项目源码:本仓库
entry/src/main/ets/pages/Index.ets、entry/src/main/ets/components/GameTypes.ets - ArkUI @Prop 父子单向官方指南
- ArkUI 状态管理概述
- ArkUI 组件化与数据流最佳实践
- 开源鸿蒙跨平台社区
- HarmonyOS 开发者官方文档首页
- 系列索引:本仓库
articles/INDEX.md