官方文档说“状态变化时触发“,但 @Watch 的真实执行时机比这复杂得多——而 AI 生成的代码,恰好踩中了这个坑

0 阅读3分钟

官方文档说"状态变化时触发",但 @Watch 的真实执行时机比这复杂得多——而 AI 生成的代码,恰好踩中了这个坑


我们遇到这个问题的时候,QA 反馈的原话是:"切换到其他 Tab 再切回来,网络请求会多发一条。"

我当时的第一反应是:路由栈的问题。查了一圈路由参数,逻辑是对的,onPageShow 也没有多余调用。又去翻了一遍请求封装,防重放机制没有漏洞。倒是同事随口来了一句"要不看看 @Watch?",才发现真正的问题在哪。

说起来这段 @Watch 代码本来就是用 Cursor 生成的,当时觉得逻辑简单,让 AI 写一下省事。AI 给的代码长这样——

@Component
struct FilterPanel {
  @State selectedCategory: string = 'all'

  @Watch('selectedCategory')
  onCategoryChange(): void {
    this.fetchList(this.selectedCategory)
  }

  fetchList(category: string): void {
    // 发起网络请求
    HttpClient.get(`/api/list?category=${category}`)
      .then(res => {
        // 处理数据
      })
  }

  build() {
    // UI 代码
  }
}

第一眼看,逻辑没毛病:状态变了就请求,状态名对上了,写法符合官方示例。我们 review 的时候也没发现问题,就这么合进去了。

@Watch 触发了几次?

我们在 onCategoryChange 里加了一个 console.log('watch triggered') 来排查。正常切换筛选分类,打印一次,符合预期。然后我从 Tab A 切到 Tab B,再切回 Tab A——打印了两次。

一次是预期的(状态初始化触发),另一次是……它就是多的。

这时候就有点意思了:selectedCategory 的初始值是 'all',从 B 切回来,值并没有变,但 @Watch 还是触发了。

@Watch 的真实执行逻辑(官方文档没写这段)

去翻 ArkTS 状态管理的框架设计,@Watch 的触发条件在官方文档里只有一句:

"当被 @Watch 监听的状态变量值发生变化时,自动调用指定的函数。"

这句话本身没错,但它省略了一个关键前提:组件在重新挂载(re-mount)时,所有 @State 变量会经历一次"初始化赋值"的过程。而在 ArkTS 的框架实现里,这个初始化赋值过程会通知 @Watch,即使新值和旧值完全相同。

换句话说,@Watch 在以下两种情况都会触发:

  1. 状态值确实发生了变化(这是我们预期的)
  2. 组件被重新挂载,状态执行初始化赋值(这是我们没预期的)

第二种情况恰好对应页面从导航栈弹出再压入时的生命周期——组件被销毁后重新创建,@State 重新初始化,@Watch 照单全收地触发了一次。

用官方的话来说,ArkTS 的状态管理是"值变更通知模型",但它的"变更检测"粒度在组件生命周期层面存在一个设计上的宽松:初始化赋值也算一次变更事件。

AI 的代码为什么生成了这个版本?

说白了,AI(不论是 Cursor 还是 DevEco Code)在生成 @Watch 代码时,参考的是 ArkTS 文档和社区里的典型示例。而这些示例几乎全都是"简单状态变化触发简单回调"的场景,没有一个 demo 专门演示了页面重新挂载时的行为差异。

AI 的训练数据里大概率也缺这个"重新挂载时触发"的 edge case,所以它给出了文档示例级别的代码——语法对,逻辑对,就是没有守卫。

我个人觉得这是"正确但不完整"的代码,比"直接错误的代码"更难发现,因为它平时跑得好好的。

三种更可靠的写法

写法一:加初始化状态守卫(最简单)

@Component
struct FilterPanel {
  @State selectedCategory: string = 'all'
  private isInitialized: boolean = false

  aboutToAppear(): void {
    this.isInitialized = true
  }

  @Watch('selectedCategory')
  onCategoryChange(): void {
    if (!this.isInitialized) return   // 初始化阶段直接跳过
    this.fetchList(this.selectedCategory)
  }

  fetchList(category: string): void {
    HttpClient.get(`/api/list?category=${category}`)
      .then(res => {
        // 处理数据
      })
  }

  build() {
    // UI 代码
  }
}

缺点是稍微绕了一圈。aboutToAppear 在组件完成初始化后被调用,在此之前 isInitialized 是 false,@Watch 触发时会被跳过。这个方式能准确隔离"初始化期"的伪变更通知。

写法二:记录上一次值,主动比较(更可靠但啰嗦)

@Component
struct FilterPanel {
  @State selectedCategory: string = 'all'
  private prevCategory: string = ''

  @Watch('selectedCategory')
  onCategoryChange(): void {
    if (this.selectedCategory === this.prevCategory) return
    this.prevCategory = this.selectedCategory
    this.fetchList(this.selectedCategory)
  }

  fetchList(category: string): void {
    HttpClient.get(`/api/list?category=${category}`)
      .then(res => {
        // 处理数据
      })
  }

  build() {
    // UI 代码
  }
}

初始化时 selectedCategory'all'prevCategory 是空字符串,两者不等——这里有个小陷阱,第一次初始化时反而会触发一次请求。要解决这个,初始值要保持一致:private prevCategory: string = 'all'

我这里写出来是因为这种写法在 React/Vue 里很常见,移植过来的同学容易自然而然地这么写,但要注意初始值对齐的问题。

写法三:改用 onChange 事件绑定(从架构上绕开 @Watch)

有时候问题不是 @Watch 本身,而是把"UI 交互驱动的请求"交给了状态变化监听——这两件事本来就不是同一层的职责。

更干净的写法是直接在 Select/Tabs 等组件的 onChange 回调里触发请求:

@Component
struct FilterPanel {
  @State selectedCategory: string = 'all'

  build() {
    Column() {
      Tabs() {
        TabContent() { /* ... */ }.tabBar('全部')
        TabContent() { /* ... */ }.tabBar('推荐')
      }
      .onChange((index: number) => {
        const category = index === 0 ? 'all' : 'recommend'
        this.selectedCategory = category
        this.fetchList(category)   // 明确由用户交互触发,不依赖 @Watch
      })
    }
  }

  fetchList(category: string): void {
    HttpClient.get(`/api/list?category=${category}`)
      .then(res => {
        // 处理数据
      })
  }
}

这种写法更符合"用户操作 → 副作用"的直接映射,不经过状态变化的中间层,就不存在"状态初始化也算变化"的歧义。当然,如果你的 selectedCategory 还会被其他地方(比如路由参数)修改并触发请求,这种方式就不够用了,需要退回到写法一或写法二。

排查这个问题的教训

我们后来在 code review 流程里加了一条:凡是 @Watch 回调里有副作用(网络请求、存储操作、路由跳转),必须加守卫条件,并在注释里说明为什么。不是不信任 AI 的代码,是因为这类"在特定生命周期下的二义性触发"很难被 AI 感知到——它没有跑过我们这套页面栈的测试场景。

顺便提一句,我手头的 App 雷达鸭(鸿蒙版)的列表筛选模块就踩过这个坑,当时是通过 DevTools 抓包才发现多了一条请求,排查了快两个小时。现在已经统一改成写法三了,清爽很多。

这类问题说大不大、说小不小,但藏得很深——等 QA 发现的时候,你大概已经忘了这段代码是 AI 写的还是你写的了。

你遇到过 @Watch 在非预期时机触发的情况吗?或者你的项目里有没有类似的"状态监听 + 副作用"的写法,欢迎留言,看看大家踩的坑是不是同一个。


关于作者:10+ 年开发经验,软件设计师,注册人工智能工程师,专注鸿蒙 ArkTS 北向开发和 Web 前端,同时在探索 AI 自动化。不定期在 CSDN 写点鸿蒙和 AI 方向的实战笔记。

本文遵循 MIT 协议,转载请注明出处。