前言

做搜索框的时候,你是不是也这么干过:用户每敲一个字就发一次请求,一秒钟打了十几个接口调用?这体验太糙了。

鸿蒙的 @Watch 装饰器就是为这种场景准备的——状态一变就触发回调,你在回调里加个防抖就搞定了。再加上 @Track 做精确字段追踪,可以避免很多不必要的 UI 刷新。

今天把这两个装饰器彻底讲清楚,附带几个真实踩坑案例。

@Watch:状态变化时搞点事

@Watch 的核心用法很简单:监听一个 @State 变量,值变了就执行回调函数。

@Component
struct CounterPage {
  @State @Watch('onCountChange') count: number = 0
  @State message: string = '还没开始'

  onCountChange() {
    if (this.count > 10) {
      this.message = '超过 10 了!'
    } else if (this.count > 5) {
      this.message = '过半了,继续加油'
    } else {
      this.message = `当前 ${this.count}`
    }
  }

  build() {
    Column({ space: 16 }) {
      Text(this.message)
        .fontSize(18)

      Button(`+1 (${this.count})`)
        .onClick(() => { this.count++ })
    }
  }
}

A clean Notion-style technical diagram explaining

@Watch 的回调函数名写成字符串参数。当 count 变化时,onCountChange 自动执行。

注意@Watch 在组件初始化时不会触发,只在后续值变更时才执行。这个设计很合理——初始化阶段你在 aboutToAppear 里该干嘛干嘛就好。

实战:搜索框防抖

实际开发中,搜索防抖大概是 @Watch 最常见的用法了。

@Component
struct SearchBar {
  @State @Watch('onQueryChange') query: string = ''
  @State results: string[] = []
  @State isSearching: boolean = false

  private debounceTimer: number = -1

  onQueryChange() {
    // 清掉上一次的定时器
    if (this.debounceTimer !== -1) {
      clearTimeout(this.debounceTimer)
    }

    // 空值直接清空结果
    if (this.query.trim() === '') {
      this.results = []
      this.isSearching = false
      return
    }

    this.isSearching = true

    // 500ms 防抖
    this.debounceTimer = setTimeout(() => {
      this.doSearch(this.query)
    }, 500)
  }

  async doSearch(keyword: string) {
    // 模拟网络请求
    await new Promise<void>((resolve) => setTimeout(resolve, 300))

    // 模拟搜索结果
    this.results = [
      `${keyword} 的结果 1`,
      `${keyword} 的结果 2`,
      `${keyword} 的结果 3`,
    ]
    this.isSearching = false
  }

  build() {
    Column({ space: 12 }) {
      Row() {
        TextInput({ placeholder: '搜索...', text: this.query })
          .onChange((value: string) => {
            this.query = value
          })
          .layoutWeight(1)

        if (this.isSearching) {
          LoadingProgress()
            .width(24)
            .height(24)
            .margin({ left: 8 })
        }
      }
      .width('100%')

      if (this.results.length > 0) {
        List() {
          ForEach(this.results, (item: string) => {
            ListItem() {
              Text(item)
                .fontSize(15)
                .padding(12)
            }
          })
        }
      }
    }
    .padding(16)
  }
}

A logical flowchart illustrating a search bar debo

A comparison table or split-view graphic highlight

这里有个细节:防抖的 timer 我用的是普通变量,不是 @State。因为 timer id 本身不需要驱动 UI 刷新。

@Watch 的常见坑

坑一:循环触发。回调里改了另一个被 @Watch 监听的变量,那边又触发回调改回来,死循环了。

// 反面教材
@State @Watch('onAChange') a: number = 0
@State @Watch('onBChange') b: number = 0

onAChange() {
  this.b = this.a * 2  // 触发 onBChange
}

onBChange() {
  this.a = this.b / 2  // 又触发 onAChange,死循环
}

解决办法:要么去掉其中一个 @Watch,要么在回调里加条件判断,值没变就不继续执行。

坑二:回调里干太多事@Watch 的回调是同步执行的,你在里面做一堆耗时操作会卡 UI。重活交给异步处理,回调里只负责"发起"。

坑三:和生命周期搞混@WatchaboutToAppear 之前不会触发,但在 aboutToDisappear 之后如果还有异步回调跑回来,组件已经销毁了。保险起见,在 aboutToDisappear 里清掉所有 timer 和未完成的请求。

@Track:精确到字段的追踪

@Observed 类的所有可观测属性变更都会触发 UI 刷新。但有时候你只关心其中一两个字段,其他字段变了不需要刷新。

@Track 就是干这个的——标记在 @Observed 类的特定属性上,只有被标记的属性变化才会触发使用到这个属性的组件刷新。

来看个表单的例子:

@Observed
class FormData {
  @Track username: string = ''
  @Track email: string = ''
  @Track phone: string = ''
  @Track lastModified: number = Date.now()  // 这个变化不需要刷新 UI
  logs: string[] = []  // 没加 @Track 也不会触发刷新

  updateField(field: string, value: string) {
    if (field === 'username') this.username = value
    if (field === 'email') this.email = value
    if (field === 'phone') this.phone = value
    this.lastModified = Date.now()
    this.logs.push(`Updated ${field} at ${this.lastModified}`)
  }
}

注意这里有个关键区别:@Observed 类的所有属性都加了 @Track,那就只有被 @Track 标记的属性变更才触发刷新。没加 @Track 的属性(比如上面的 lastModifiedlogs),变更了不会影响 UI。

子组件用 @ObjectLink 接收:

@Component
struct UsernameField {
  @ObjectLink form: FormData

  build() {
    Row() {
      Text('用户名')
        .width(80)
      TextInput({ text: this.form.username })
        .onChange((value: string) => {
          this.form.updateField('username', value)
        })
    }
  }
}

@Component
struct EmailField {
  @ObjectLink form: FormData

  build() {
    Row() {
      Text('邮箱')
        .width(80)
      TextInput({ text: this.form.email })
        .onChange((value: string) => {
          this.form.updateField('email', value)
        })
    }
  }
}

改了 username,只有 UsernameField 会刷新。EmailField 纹丝不动——因为它只依赖 form.email,而 email 没变。

实战:表单验证联动

@Watch@Track 结合起来,做个带实时验证的注册表单:

@Observed
class RegisterForm {
  @Track username: string = ''
  @Track email: string = ''
  @Track usernameError: string = ''
  @Track emailError: string = ''
}

@Component
struct RegisterPage {
  @State form: RegisterForm = new RegisterForm()

  build() {
    Column({ space: 20 }) {
      Text('注册')
        .fontSize(22)
        .fontWeight(FontWeight.Bold)

      // 用户名
      Column() {
        UsernameField({ form: this.form })
        if (this.form.usernameError !== '') {
          Text(this.form.usernameError)
            .fontSize(12)
            .fontColor('#e74c3c')
        }
      }

      // 邮箱
      Column() {
        EmailField({ form: this.form })
        if (this.form.emailError !== '') {
          Text(this.form.emailError)
            .fontSize(12)
            .fontColor('#e74c3c')
        }
      }

      // 提交按钮
      Button('注册')
        .width('100%')
        .backgroundColor(
          this.form.usernameError === '' &&
          this.form.emailError === '' &&
          this.form.username !== '' &&
          this.form.email !== ''
            ? '#3498db' : '#ccc'
        )
        .enabled(
          this.form.usernameError === '' &&
          this.form.emailError === '' &&
          this.form.username !== '' &&
          this.form.email !== ''
        )
    }
    .padding(20)
    .width('100%')
  }
}

验证逻辑放在子组件里用 @Watch 触发:

@Component
struct UsernameField {
  @ObjectLink form: RegisterForm

  // 监听 username 变化,做验证
  @Watch('validateUsername') _watchTrigger: number = 0

  aboutToAppear() {
    // 初始不需要触发验证
  }

  validateUsername() {
    const name = this.form.username
    if (name.length === 0) {
      this.form.usernameError = ''
    } else if (name.length < 3) {
      this.form.usernameError = '用户名至少 3 个字符'
    } else if (name.length > 20) {
      this.form.usernameError = '用户名不能超过 20 个字符'
    } else {
      this.form.usernameError = ''
    }
  }

  build() {
    Row() {
      Text('用户名')
        .width(80)
      TextInput({ text: this.form.username })
        .onChange((value: string) => {
          this.form.username = value
          this.validateUsername()
        })
        .layoutWeight(1)
    }
  }
}

这样改了用户名,验证错误信息只影响用户名那一行,邮箱区域完全不受影响。@Track 保证了字段级别的精确刷新。

我的使用心得

@Watch@Track 这两个装饰器,解决的问题其实不一样:

@Watch 是"数据变了要去做某件事"——侧重副作用触发。防抖、验证、联动、日志,都是它的活。

@Track 是"别刷不该刷的地方"——侧重性能优化。数据模型字段多了以后,不加 @Track 的话改一个字段全组件树跟着抖,体验很差。

两者配合起来用效果最好。@Track 管"该不该刷",@Watch 管"刷完之后做点什么"。把这两个职责分清楚,状态管理的代码就好写多了。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐