@Link 是状态管理系列里能力最强也最容易伤到自己的一把刀:双向同步让父子组件共享同一份数据的读写权,用对了是效率,用错了是数据流灾难。上一篇确立了单向流的守则,这一篇讲 @Link 的正确打开场景、$ 传参的机制细节、双向同步的三个真实坑,以及"什么时候应该拒绝用 @Link"。

一、机制:@Link 是同一份数据的两个窗口
@Link 与 @Prop 的本质区别在数据所有权:@Prop 是父组件数据的**拷贝副本**,@Link 是父组件 @State 的**引用别名**------两边指向同一份存储,任何一方的修改另一方立刻可见。菜谱应用的收藏开关是标准场景:列表页持有收藏集合的 @State,收藏开关组件用 @Link 接收单个菜谱的收藏位,开关在子组件里翻转,列表页的角标、详情页的按钮、本地存储的持久化全部即时同步。一个开关三个视图联动,这就是双向同步的价值场景。
ts
// 父组件
@State favMap: Record<string, boolean> = {}
FavToggle({ isFav: $favMap[this.currentId] })
// 子组件
@Component
struct FavToggle {
@Link isFav: boolean
build() {
Image(this.isFav ? 'fav_on' : 'fav_off').onClick(() => { this.isFav = !this.isFav })
}
}
二、$ 前缀的机制:传的是引用通道
父组件传参时的 前缀(favMap)是 @Link 的语法要求,语义是"传递状态引用而非值拷贝"。漏写 的后果在上一篇提过:双向同步静默失效,不报错。这里补充三个细节:一是 只能用于父组件的 @State/@Link/@Storage 等状态装饰器变量,普通成员变量加了 也不生效;二是 @Link 子组件不能有本地默认值,引用语义下没有"默认的引用";三是数组元素的 @Link(如 favMapid)走的是元素级引用,父数组重排后引用跟随数据而非下标------这三个细节覆盖了 $ 传参的全部坑。
三、真实坑一:双向同步放大的刷新范围
@Link 的双向性有个容易被忽视的代价:子组件的修改立即写回父组件,父组件状态变化又触发所有订阅该状态的组件刷新。收藏列表场景实测:一个 @Link 开关翻转,列表页三个订阅区域(角标、计数、排序)全部重渲染------即使其中两个显示的内容与该开关无关。这不是机制错误,是刷新粒度的现实:状态的所有订阅者都会收到通知。缓解方案是把状态切得更细(整表收藏状态拆成单条目的 @Link),或者订阅侧用不影响显示的计算属性过滤。@Link 用在"确实需要多方联动"的状态上,单方消费的状态退回 @Prop。
| 状态特征 | 推荐装饰器 | 理由 |
|---|---|---|
| 子只展示父的状态 | @Prop | 单向,拷贝隔离 |
| 父子共同所有、双向修改 | @Link | 引用同步,即时联动 |
| 跨层级多组件共享 | @Provide/@Inject | 下一篇主题 |
四、真实坑二:循环触发的回环刷新
@Link 双向同步与 onChange 类监听组合时会出现回环:子组件改 @Link → 父组件状态变化触发监听回调 → 回调里又修改同一状态 → 再次触发监听。设置页的真实案例:开关 @Link 绑定后,父组件在状态上加了数据持久化的监听,监听里做了格式转换又写回状态,形成每次点击两次写盘外加一次额外刷新。解法是监听回调里做变更检测(值相同则跳过写回),或者把派生逻辑从监听挪到读取侧。回环的通用解法都是"写前比对",成本低,但要求团队知道这个坑的存在。
ts
// 父组件:写前比对防回环
onFavMapChange(newMap: Record<string, boolean>) {
if (JSON.stringify(newMap) === JSON.stringify(this.favMap)) return // 相同跳过
this.favMap = newMap
this.persist(newMap)
}
五、真实坑三:嵌套对象的 @Link 断链
@Link 对嵌套对象的观测同样受第一层限制:父组件 @State 一个菜谱对象,@Link 接收整个对象,子组件修改对象的嵌套属性(菜谱.meta.name),修改不会通过 @Link 传导回父组件的刷新------引用相同,系统检测不到内部变化。解法延续状态管理系列的统一答案:拆平数据结构,或者修改时整对象替换(子组件里 this.recipe = {...this.recipe, meta: newMeta},整体替换触发 @Link 同步)。嵌套+双向的组合是状态管理里最容易翻车的地形,能避开就避开。

六、@Link 数组:批量操作的同步语义
@Link 修饰数组时,数组级别的操作(push/splice/整体赋值)都会同步回父组件------这让"子组件管理一个可编辑列表"成为 @Link 的舒适区。购物清单的编辑页实测:清单数组用 @Link 交给编辑组件,增删改全在子组件完成,父组件的合计金额与提交按钮状态实时联动,零回调代码。注意点只有一个:数组内对象的属性修改仍然不触发同步(嵌套边界),单项更新要用 splice 整项替换。数组 @Link + 整项替换,是这个场景的标准组合拳。
七、什么时候应该拒绝 @Link:三个反模式
写完正确用法,更重要的三个反模式。反模式一:用 @Link 替代事件回调------子组件不该直接改父组件的筛选条件,而应该发回调让父组件自己改,否则筛选逻辑散落两处。反模式二:跨层 @Link 链------状态从祖父传到父再到子,层层 @Link,中间层成了纯管道,一旦要加逻辑就要穿透两层;这种场景该用 @Provide/@Inject 跨层直达。反模式三:用 @Link 共享大对象------引用共享加嵌套断链,双重风险叠加;大范围共享交给 AppStorage 或状态库。三个反模式的共性:都在用 @Link 解决"它不擅长的问题"。
八、调试:@Link 断链的定位三板斧
@Link 断链(同步失效)的排查三板斧:第一斧检查父组件传参有没有 前缀------最快也最常见;第二斧检查 @Link 变量的类型与父组件 @State 是否完全一致(类型不一致时同步静默失败);第三斧在父子两侧各打一条变更日志,确定变化发生在哪一侧丢失。三板斧按顺序走,九成断链问题在前两斧解决。断链的日志证据链:父侧日志显示值变了而子侧没收到,是 前缀或类型问题;两侧都变了界面没刷新,是观测边界问题,回到嵌套断链的解法。

九、设计视角:@Link 的权限模型
把 @Prop/@Link/@回调 三种方式放在一起,本质是三种"写权限"的分配模型:@Prop 是只读(子无写权),@Link 是读写(子有全权),回调是审批制(子申请,父执行)。组件设计的权利分配原则:越接近展示末端的组件,写权限越收紧;越靠近业务逻辑的组件,越可能需要读写权。用权限模型审视现有代码,会发现大部分 @Link 的使用其实应该降级为 @Prop 加回调------权限收窄后,数据流更可控,组件的可测试性也更好。这个视角比语法细节更重要,它回答的是"为什么"。
十、总结:@Link 的四条使用守则
收束成四条:$ 前缀与类型一致是同步的前提;双向同步只给"父子共同所有"的状态,展示型退回 @Prop;嵌套对象双向同步用整对象替换,或干脆拆平;出现回环刷新与跨层管道时,是架构在提示你换工具(写前比对、@Provide、AppStorage)。@Link 的能力边界清晰之后,它就是状态管理工具箱里锋利且安全的那把刀。
参考资料:
- 华为开发者:@Link 装饰器
- 华为开发者:状态管理与 $ 语法
- 华为开发者:嵌套对象的状态观测
#鸿蒙 #HarmonyOS 7(API 26)
十五、一次完整的联调记录:收藏开关的三天排查
把收藏开关从 @Prop 改造成 @Link 的过程完整记录,因为三天里踩的坑恰好覆盖了本篇的全部知识点。第一天:改造成 @Link 后开关不生效,用排查三板斧发现漏了 $ 前缀,十分钟修复。第二天:开关生效了,但列表页的其他收藏标记不联动------排查发现列表页还有两个组件各自持有一份 @Prop 副本,改为统一订阅同一个 @Link 源,联动恢复。第三天:快速连续点击开关出现状态回退,加写前比对与防抖后稳定。三天下来最大的收获不是修好了开关,而是确认了一条纪律:@Link 引入的当天就要建好它的变更日志锚点,否则双向同步的问题排查会退化成玄学。
ts
// 变更锚点:@Link 的每次翻转都留痕
setFav(id: string, val: boolean) {
if (this.favMap[id] === val) return
this.favMap[id] = val
console.info('[FAV]', id, '->', val)
this.persist()
}
这个联调记录还有一层意义:它证明了 @Link 的坑全部集中在"接入的第一天"与"联动的第一周",之后的稳定性反而非常好------前提是第一天的锚点建好,第一周的边界(回环、嵌套、刷新范围)全部过一遍。任何状态机制的学习曲线都是前陡后平,把陡坡的记录留下来,后来者就能省下最陡的那一段。
十六、与本系列其他篇目的衔接点
@Link 双向同步与本系列其他篇目有四个衔接点。与插槽篇:@BuilderParam 内容里修改 @Link,刷新范围同样遵循"订阅即刷新",插槽里的状态引用要更克制。与 WaterFlow 篇:瀑布流卡片内的 @Link 状态在复用时的重置时机,遵循复用篇的回填规范。与筛选面板案例(本系列上一篇):筛选条件用单向流,筛选结果影响的数据用双向同步,两种模式在同一条数据链上各司其职。状态管理的工具箱正在一件件摆开,下一篇的 @Provide/@Inject 解决的是这些工具都解决不了的跨层问题------父子之外的直达通道。
十七、@Link 与组件生命周期:引用的有效期
@Link 的引用在组件生命周期内有明确的语义:组件创建时建立引用通道,组件销毁时通道断开。两个生命周期细节值得记录。第一,aboutToAppear 里读取 @Link 是安全的------引用在组件初始化前已建立,读到的就是父组件当前值;但 aboutToDisappear 里**写** @Link 要谨慎,组件销毁边缘的写回可能与父组件的销毁清理竞争,菜谱应用的做法是销毁边缘只读不写。第二,父组件销毁时,子组件的 @Link 不会自动置空,而是保持最后一次的值------如果子组件生命周期比父组件长(通过层级脱离),这个残留值就是隐患,解法是在父组件 aboutToDisappear 里显式通知子组件。
ts
// 父组件销毁前的显式通知
aboutToDisappear() {
this.childReset?.() // 通知持有@Link的子组件重置
}
十八、性能实测:双向同步的刷新成本量化
用 debug 面板量化 @Link 的刷新成本:收藏开关翻转一次,直接重渲染的组件 3 个(开关、角标、计数),波及重渲染 5 个(列表项的容器层),全程耗时约 6 毫秒------远低于一帧预算。结论:低频状态的双向同步成本完全可接受,前面担心的"刷新范围放大"在低频场景只是理论问题。真正需要警惕的是高频状态:滚动位置、拖拽坐标、输入框逐字内容,这类高频变化用 @Link 双向同步会制造每帧多次的全链路刷新,高频场景的答案是本地 @State 加节流上报,或者干脆不进状态系统。频率分层,是状态管理从入门到进阶的分水岭。
| 状态频率 | 方案 | 案例 |
|---|---|---|
| 低频(点击触发) | @Link 双向 | 收藏开关、筛选 |
| 中频(输入触发) | 本地@State+防抖上报 | 搜索框、表单 |
| 高频(帧级触发) | 不进状态系统 | 滚动位置、拖拽坐标 |
十九、写给评审者:@Link 代码的四问检查单
评审带 @Link 的代码时固定问四个问题。一问:这个状态为什么需要双向------答不出联动场景就降级为 @Prop 加回调。二问:修改路径唯一吗------同一状态是否只存在一处业务写入点。三问:嵌套了吗------嵌套对象的修改是否用了整对象替换。四问:高频吗------触发频率是否低于每秒一次。四问全过,@Link 的使用就是安全的;任何一问答不上,退回设计阶段重新分配所有权。检查单的意义不在拦住所有问题,而在让每一次 @Link 的使用都是想清楚之后的决定。
收笔前的最后提醒:@Link 的调试日志在上线构建里同样要清理干净------双向通道的日志量是单向的数倍,残留的日志在高频操作下会拖慢主线程。项目里的做法是统一走 debugMode 开关的日志工具,发布构建整体剔除,开发体验与线上性能两不误。这篇写完,状态管理的第二把刀也归了鞘,下一篇 @Provide/@Inject 见。