HarmonyOS应用开发实战:猫猫大作战-@Link 声明与 $val 传参、双向同步机制、@Link vs @Prop vs @Event 取舍、
前言
上一篇我们用@Prop把得分从父传给子 HUD——单向只读,子改不了父。但有些场景子组件要直接改父的数据:表单子组件改父的 username、滑块子组件改父的音量、暂停弹窗里的「继续/重开」按钮改父的 gameState。这时@Prop不够用,要用@Link双向绑定——子改父同步,父改子也同步。
本篇以「猫猫大作战」暂停弹窗子组件用@Link改父 gameState 为锚点,把@Link 声明与 $val 传参、双向同步机制、@Link vs @Prop vs @Event 取舍、双向数据流的边界四大要点讲透。
提示:本系列不讲 ArkTS 基础语法与环境搭建,假设你已跟完第 1–40 篇。本篇是阶段二第十一篇。
一、场景拆解:暂停弹窗改父 gameState
回顾「猫猫大作战」PauseOverlay(第 93 篇会专讲,此处简提):
// 来源:entry/src/main/ets/pages/Index.ets PauseOverlay() @Builder PauseOverlay() { Column() { Text('游戏暂停') Button('继续游戏').onClick(() => { this.resumeGame(); }) // ← 调父方法 Button('重新开始').onClick(() => { this.clearTimers(); this.startGame(); }) Button('返回主菜单').onClick(() => { this.clearTimers(); this.gameState = GameState.IDLE; }) } }现在想把 PauseOverlay 拆成独立子组件PauseOverlay,子组件的「继续」按钮要改父的 gameState——用@Link:
@Component export struct PauseOverlay { @Link gameState: GameState; // 双向绑定父的 gameState @Prop score: number; // 只读显示当前得分 build() { Button('继续游戏').onClick(() => { this.gameState = GameState.PLAYING; // ← 子直接改 @Link,同步到父 }) /* ... */ } } // 父 PauseOverlay({ gameState: this.$gameState, score: this.score })关键经验:@Link = 父↔子双向引用——子改 @Link 等于改父的 @State,父改 @State 子也同步。
二、@Link 基本用法
2.1 子组件声明 @Link
@Component export struct PauseOverlay { @Link gameState: GameState; // 双向绑定 @Prop score: number; // 只读显示 build() { Column() { Text('游戏暂停') Text(`当前得分: ${this.score}`) // 读 @Prop Button('继续游戏').onClick(() => { this.gameState = GameState.PLAYING; // 改 @Link,同步父 }) Button('重新开始').onClick(() => { this.gameState = GameState.IDLE; // 改 @Link,触发父切主菜单 // 注意:重新开始的定时器清理由父的 @Watch 或 aboutToDisappear 处理 }) Button('返回主菜单').onClick(() => { this.gameState = GameState.IDLE; // 改 @Link }) } } }拆解:
| 片段 | 含义 |
|---|---|
@Link | 装饰器,标记「双向绑定父的 @State」 |
gameState | 变量名(必须与父传参键名一致) |
GameState | 类型(与父 @State 一致) |
2.2 父组件用 $val 传参
@Entry @Component struct Index { @State gameState: GameState = GameState.IDLE; @State score: number = 0; build() { Stack() { if (this.gameState !== GameState.IDLE) { this.GameView() } if (this.gameState === GameState.PAUSED) { PauseOverlay({ gameState: this.$gameState, // ← $val 传双向引用 score: this.score // 普通值传 @Prop }) } } } }关键语法:this.$gameState——$前缀传「@State 的引用」,子组件用 @Link 接收。普通this.gameState传值(@Prop),this.$gameState传引用(@Link)。
2.3 $val 的本质
this.$gameState // 引用,子改会影响父 this.gameState // 值,子改不影响父| 传递方式 | 子装饰器 | 数据流 |
|---|---|---|
this.$gameState | @Link | 双向(子改父同步) |
this.gameState | @Prop | 单向(只读) |
关键经验:@Link 必须用$val传引用——传普通值会编译报错或退化为 @Prop。
三、双向同步机制
3.1 子改父同步
// 子 PauseOverlay Button('继续').onClick(() => { this.gameState = GameState.PLAYING; // 改 @Link }) // ArkUI: // 1. 子改 gameState // 2. 检测到 @Link 绑定父的 @State gameState // 3. 同步到父 @State gameState = PLAYING // 4. 父的 @Watch('onGameStateChange') 触发(第 39 篇) // 5. 父 build() 重渲染(if (gameState === PAUSED) 不再渲染 PauseOverlay)关键经验:子改 @Link 等于改父 @State——会触发父的 @Watch、父的重渲染、父依赖该 state 的所有组件更新。
3.2 父改子同步
// 父 Index this.gameState = GameState.PAUSED; // 父改 @State // ArkUI: // 1. 父改 gameState // 2. 检测到子 PauseOverlay 的 @Link gameState 绑定本 @State // 3. 同步到子 @Link gameState = PAUSED // 4. 子 build() 重渲染(如果有依赖 gameState 的显示)实战经验:@Link 真双向——无论父改还是子改,对方都同步。
3.3 双向 vs 单向对比
| 维度 | @Prop(单向) | @Link(双向) |
|---|---|---|
| 子改父 | ❌ 不回传 | ✅ 同步父 |
| 父改子 | ✅ 同步子 | ✅ 同步子 |
| 传递语法 | 普通值this.score | 引用this.$gameState |
| 适合 | 显示型 | 编辑型 |
四、完整改造:PauseOverlay 拆为子组件
4.1 创建 PauseOverlay 子组件
新建entry/src/main/ets/components/PauseOverlay.ets:
import { GameState } from './GameTypes'; @Component export struct PauseOverlay { @Link gameState: GameState; // 双向改父 gameState @Prop score: number; // 只读显示当前得分 build() { Column() { Column() { Text('⏸️') .fontSize(48) .margin({ bottom: 12 }) Text('游戏暂停') .fontSize(24) .fontWeight(FontWeight.Bold) .fontColor('#2C3E50') .margin({ bottom: 8 }) Text(`当前得分: ${this.score}`) // 读 @Prop .fontSize(16) .fontColor('#7F8C8D') .margin({ bottom: 24 }) // 继续按钮:改 @Link 切回 PLAYING Button('继续游戏') .width('80%').height(48) .fontSize(17).fontWeight(FontWeight.Bold) .fontColor('#FFFFFF').backgroundColor('#2ECC71') .borderRadius(24) .margin({ bottom: 12 }) .onClick(() => { this.gameState = GameState.PLAYING; // ← 改 @Link }) // 重新开始:改 @Link 切 IDLE(父 @Watch 处理定时器清理) Button('重新开始') .width('80%').height(48) .fontSize(17) .fontColor('#FFFFFF').backgroundColor('#F39C12') .borderRadius(24) .margin({ bottom: 12 }) .onClick(() => { this.gameState = GameState.IDLE; // ← 改 @Link }) // 返回主菜单:改 @Link 切 IDLE Button('返回主菜单') .width('80%').height(48) .fontSize(17) .fontColor('#7F8C8D').backgroundColor('#ECF0F1') .borderRadius(24) .onClick(() => { this.gameState = GameState.IDLE; // ← 改 @Link }) } .width('80%').padding(32) .backgroundColor('#FFFFFF').borderRadius(20) .shadow({ radius: 20, color: 'rgba(0,0,0,0.15)', offsetY: 8 }) .alignItems(HorizontalAlign.Center) } .width('100%').height('100%') .backgroundColor('rgba(44, 62, 80, 0.6)') .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) } }4.2 Index 引用并 $val 传参
// 来源:entry/src/main/ets/pages/Index.ets(改造后) import { PauseOverlay } from '../components/PauseOverlay'; import { GameHUD } from '../components/GameHUD'; import { Cat, CatLevel, GameConfig, CatConfig, GameState, ComboInfo } from '../components/GameTypes'; import { GameEngine } from '../components/GameEngine'; @Entry @Component struct Index { @State @Watch('onGameStateChange') 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; 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]; // gameState 变化回调(第 39 篇):处理定时器清理等副作用 onGameStateChange(newVal: GameState): void { switch (newVal) { case GameState.IDLE: // 切回主菜单时清定时器(重新开始/返回主菜单都会走这里) this.clearTimers(); break; case GameState.GAME_OVER: // 结束时清定时器 this.clearTimers(); break; } } /* startGame / pauseGame / resumeGame / endGame / handleColumnClick / clearTimers / formatTime / aboutToDisappear 等略 */ build() { Stack() { if (this.gameState === GameState.IDLE) { this.MainMenuView() } else { this.GameView() } // 改造:用子组件替代 @Builder PauseOverlay if (this.gameState === GameState.PAUSED) { PauseOverlay({ gameState: this.$gameState, // ← $val 传双向引用 score: this.score // 普通值传 @Prop }) } if (this.gameState === GameState.GAME_OVER) { this.GameOverOverlay() } } .width('100%').height('100%') } /* GameView / MainMenuView / GameOverOverlay / StatItem 筥略 */ }4.3 改造要点
| 原写法 | 改造后 | 效果 |
|---|---|---|
this.resumeGame()调父方法 | this.gameState = PLAYING改 @Link | 子直接改父 state |
this.clearTimers(); this.startGame()父处理 | this.gameState = IDLE改 @Link | 父 @Watch 处理清理 |
| PauseOverlay 在 Index 内 | 独立PauseOverlay.ets | 分文件维护 |
关键经验:@Link + 父 @Watch 是「子改父 state,父处理副作用」的标准模式——子只改 state,副作用(清定时器、播音效)由父 @Watch 集中处理。
五、@Link vs @Prop vs @Event 三选
5.1 三种跨组件数据流完整对比
| 装饰器 | 方向 | 读写 | 传递语法 | 适合 |
|---|---|---|---|---|
@Prop | 父→子 | 只读 | this.score | 显示型(HUD、卡片) |
@Link | 父↔子 | 双向 | this.$gameState | 编辑型(表单、弹窗按钮) |
@Event | 子→父 | 事件回调 | this.onScoreAdd | 触发父方法(按钮点击) |
5.2 场景对照
// 场景 1:HUD 显示得分 → @Prop GameHUD({ score: this.score }) // 场景 2:暂停弹窗改 gameState → @Link PauseOverlay({ gameState: this.$gameState }) // 场景 3:子按钮点击让父加 10 → @Event GameHUD({ score: this.score, onScoreAdd: (d) => this.score += d })5.3 @Link vs @Event 的细微取舍
「子改父 state」既可以用 @Link 直接改,也可以用 @Event 回调让父改:
// 方式 A:@Link 直接改 @Component struct PauseOverlay { @Link gameState: GameState; build() { Button('继续').onClick(() => { this.gameState = GameState.PLAYING; // 直接改 }) } } // 方式 B:@Event 回调父改 @Component struct PauseOverlay { @Event onResume: () => void; build() { Button('继续').onClick(() => { this.onResume(); // 回调父,父改 gameState }) } } // 父:PauseOverlay({ onResume: () => { this.gameState = GameState.PLAYING; } })| 方式 | 优点 | 缺点 |
|---|---|---|
| @Link 直接改 | 简洁、少一层回调 | 子要懂父 state 语义 |
| @Event 回调 | 子不耦合父 state | 多一层回调代码 |
关键经验:「子改父单一 state」用 @Link 更简洁——「子触发父复杂逻辑」用 @Event 更解耦。
六、踩坑提示
6.1 忘用 $val 传引用
// ❌ 错误:传普通值,@Link 退化为 @Prop 或报错 PauseOverlay({ gameState: this.gameState }) // 传值不是引用 // ✅ 正确:$val 传引用 PauseOverlay({ gameState: this.$gameState }) // $val 传引用6.2 @Link 类型与父 @State 不一致
// 父 @State gameState: GameState = GameState.IDLE; // ❌ 错误:子 @Link 声明 string @Link gameState: string; // ✅ 正确:类型一致 @Link gameState: GameState;6.3 子改 @Link 期望触发父方法
// ❌ 错误:子改 @Link 期望自动调父的 resumeGame() this.gameState = GameState.PLAYING; // 只改 state,不调方法 // ✅ 正确:父用 @Watch 监听 state 变化,在回调里调方法 // 父 @State @Watch('onGameStateChange') gameState: GameState = GameState.IDLE; onGameStateChange(newVal: GameState): void { if (newVal === GameState.PLAYING) { this.resumeGame(); // 父在 @Watch 里调方法 } } `` ### 6.4 @Link 对象改内部属性不触发 ```ts // ❌ 错误:改 @Link 对象内部属性,不触发双向同步 this.combo.count = 5; // @Link 也是浅观察 // ✅ 正确:重新赋值整个对象 this.combo = { count: 5, multiplier: 3, lastMergeTime: Date.now() };七、调试技巧
console.info在子改 @Link 后:log 父的this.gameState,追是否同步。- 子改了父没变排查:检查是否用
$val传引用;检查类型是否一致;检查对象是否整体赋值。 - 父 @Watch 没触发排查:检查父 @State 是否有 @Watch;检查回调字符串是否拼错。
- DevEco ArkUI Inspector:查看组件树和 @Link 绑定关系。
八、性能与最佳实践
- 编辑型子组件用 @Link——弹窗按钮改父 state、表单输入改父数据。
- $val 传引用——普通值传 @Prop,引用传 @Link。
- 类型与父 @State 一致——number 配 number,GameState 配 GameState。
- 子改 @Link + 父 @Watch 处理副作用——子只改 state,副作用由父集中处理。
- @Link 对象整体赋值才同步——和 @State/@Prop 一样浅观察。
- 「子改单一 state」用 @Link,「子触发父复杂逻辑」用 @Event——按耦合度选。
总结
本篇我们从 @Link 双向绑定切入,掌握了**@Link 声明与val 传引用**、**双向同步机制(子改父同步,父改子同步)**、**@Link + 父 @Watch 标准模式**、**@Link vs @Prop vs @Event 取舍**四大要点,并给出了 PauseOverlay 拆为子组件的完整改造代码。核心要点:**val 传引用才双向;子改 @Link 触发父 @Watch;编辑型用 @Link 显示型用 @Prop;对象整体赋值才同步**。
下一篇我们将拆解 @Provide/@Consume——跨层隐式共享。
如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!
相关资源:
- 「猫猫大作战」项目源码:本仓库
entry/src/main/ets/pages/Index.ets、entry/src/main/ets/components/PauseOverlay.ets - ArkUI @Link 双向绑定官方指南
- ArkUI 状态管理概述
- ArkUI 组件化与数据流最佳实践
- 开源鸿蒙跨平台社区
- HarmonyOS 开发者官方文档首页
- 系列索引:本仓库
articles/INDEX.md