前言
前面我们用@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:
// 来源: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:
@Component struct GameHUD { @Prop score: number; // ← 从父接收 @Prop combo: ComboInfo; @Prop gameTime: number; build() { Row() { Column() { Text('得分'); Text(this.score.toString()) } // 读 @Prop /* ... */ } } }父组件Index传值:
GameHUD({ score: this.score, combo: this.combo, gameTime: this.gameTime })关键经验:@Prop = 父传子单向只读——父改值子同步刷新,子改值不回传父。
二、@Prop 基本用法
2.1 子组件声明 @Prop
@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 父组件传值
@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 单向同步机制
// 父改 @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
@Component struct GameHUD { @Prop score: number; build() { Button('加 10').onClick(() => { this.score += 10; // ❌ @Prop 只读,改了不回传父,且可能ArkUI 警告 }) } }为什么只读:@Prop是「父数据的子副本」,子改了不回传父,会导致父子数据不一致——ArkUI 为保证数据流单向,子改 @Prop 不影响父,且子下次重渲染会被父值覆盖。
3.2 子要改数据用 @Event 回调父
子组件想「加 10」,要通过回调让父改:
@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 的浅拷贝
@Prop combo: ComboInfo; // 接收对象机制:@Prop对父传入的对象做浅拷贝——子得到一份副本,改副本内部属性不影响父,但改了也不触发子重渲染(@Prop 浅观察)。
// 父 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:
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 引用并传值
// 来源: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 场景对照
// 场景 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 忘记父传值
// ❌ 错误:没传 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 类型不匹配
// 父 @State score: number = 0; // ❌ 错误:子 @Prop 声明 string,父传 number @Component struct GameHUD { @Prop score: string; // 类型不匹配 } // ✅ 正确:类型一致 @Component struct GameHUD { @Prop score: number; // 与父 @State 类型一致 }6.3 子改 @Prop 期望回传父
// ❌ 错误:改 @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 对象改内部属性不触发重渲染
// ❌ 错误:父改 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