鸿蒙状态管理V1-V2的一些思考

HarmonyOs鸿蒙TSArkTS

# 前言

开始学过React,工作后基本用Vue做开发,之前没有做过应用端开发,不过目前在进行鸿蒙应用的开发。

鸿蒙的开发用的是ArkTS,基于TypeScript开发的语言,感觉很多方面是参考前端主流框架来做的,对前端开发入门还是很友好的

开始看鸿蒙文档和代码的时候,我是觉得熟悉但又有点陌生,熟悉是对于语法是很快能理解,开发上手也快;陌生是一些语法细节上,觉得很怪,用不习惯,主要体现在状态管理这方面

特别是一开始用4.0版本的时候,就觉得好奇怪:

  • @Prop不能装饰对象数组
  • @Link能直接修改父子组件数据,是双向数据流,而不是数据单项流动
  • @ObjectLink装饰深层级的class对象,一个深层级属性就要一个组件
  • 没有计算属性
  • ···

后来升级到Next版本后,发现好些了,但是有些问题还是存在

现在推出了状态管理V2,我的疑问和用法上的不适基本都解决了,不少升级更符合前端主流框架VueReact的思想,对于之前用惯前端框架的开发伙伴还挺友好的。

下面我将介绍几个重点的V2版本的Api,并写下我的理解和思考

# V1-V2

# @Prop -> @Param

在V1中,@Prop装饰器用于从父组件传递参数给子组件,这些参数在子组件中可以被直接修改。

在V2中,@Param取代了@Prop的作用,但@Param是只读的,子组件不能直接修改参数的值

这样设计更符合前端框架开发思维:数据单项流动,子组件不能修改prop

不过如果项目是从V1迁移到V2,难免存在之前使用V1时直接改@Prop装饰变量的情况,所以V2新增了@Once

# @Once

加上@Once装饰器可以让变量可改,只不过改变只会发生在子组件,并不会修改父组件状态。但是用了@Once当前子组件只会被初始化一次,后续并没有父组件到子组件的同步能力

@Entry
@ComponentV2
struct OncePage {
  @Local message: string = 'Hello World';

  build() {
    Column({ space: 10 }) {
      TextInput({ text: this.message })
        .onChange((value: string) => {
          this.message = value
        })

      OnceChild({ message: this.message })
    }
  }
}

@ComponentV2
struct OnceChild {
  // 使用了@Once,会让初始化子组件显示Hello World,而不是Child。
  // 不过后续父组件修改,子组件并不会发生变化
  @Param @Once message: string = 'Child';

  build() {
    Column({ space: 10 }) {
      TextInput({ text: this.message })
        .onChange((value: string) => {
          this.message = value
        })
    }
  }
}


# @Watch -> @Monitor

在V1中,监听用@Watch;在V2中,监听用@Monitor

上一点说到@Once,不会同步父组件。若开发中既希望子组件能改,又需要父组件更新后子组件的同步,则不能使用@Once,需借助@Monitor。具体为:子组件@Local声明自己的变量,@Param接收父组件变量,然后通过@Monitor监听父组件变量,给子组件自己的变量赋值即可,代码如下:

父组件

@Entry
@ComponentV2
struct Parent {
  @Local message: string = 'Hello World';

  build() {
    Column() {
      Text('Parent ' + this.message)
      Button('Parent改变').onClick(() => {
        this.message = 'Parent改变'
      })
      Divider()
      Child({ message: this.message })
    }
  }
}

子组件

@ComponentV2
struct Child {
  @Param @Require message: string
  @Local childMessage: string = ''

  @Monitor('message')
  onMessageChange(monitor: IMonitor) {
    console.log(`apple changed from ${monitor.value()?.before} to ${monitor.value()?.now}`);
    this.childMessage = monitor.value()?.now as string || ''
  }

  build() {
    Column() {
      Text('Child ' + this.childMessage)
      Button('Child改变').onClick(() => {
        this.childMessage = 'Child改变'
      })
    }
  }
}

@Watch装饰函数的入参只能获取到监听的变量名,用着很不习惯;@Monitor装饰的函数入参IMonitor,能获取到数据前后的值

@Watch只能一个监听函数监听一个变量;@Monitor一个监听函数监听多个变量

@Monitor这两点挺像Vue

@ComponentV2
struct Child {
  @Local childMessage: string = ''
  @Local num: number = 0

  @Monitor('childMessage','num')
  onMessageChange(monitor: IMonitor) {
      // 监听多个变量可以遍历monitor.dirty获取name
    monitor.dirty.forEach((name: string) => { // name为监听的变量名
      console.log(`${name} changed from ${monitor.value(name)?.before} to ${monitor.value(name)?.now}`);
    });
  }

  build() {
    Column() {
      Text('Child ' + this.childMessage)
      Button('Child改变').onClick(() => {
        this.childMessage = 'Child改变'
      })
      Button('num改变' + this.num).onClick(() => {
        this.num++
      })
    }
  }
}

符合前端框架开发思维:数据单项流动、用@Event装饰器,看起来更明确是父组件传递过来,是子组件需要分发的事件

V1中传递函数无需装饰器,V2中传递函数必须@Event装饰器,否则编译器会提示报错的

@Entry
@ComponentV2
struct EventPage {
  @Local message: string = 'Hello World';

  build() {
    Column({ space: 10 }) {
      TextInput({ text: this.message })
        .onChange((value: string) => {
          this.message = value
        })
      Divider()
      // !!可以进行双向绑定
      EventChild({ message: this.message!! })

      Divider()
      ChildV1({ message: this.message, emitMessage: (msg: string) => (this.message = msg) })

    }
    .height('100%')
    .width('100%')
  }
}

@ComponentV2
struct EventChild {
  @Param message: string = ''
  /* v2中传递函数必须@Event装饰器。双向绑定@Event方法名需要声明为“$”+ @Param属性名 */
  @Event $message: (msg: string) => void = () => {
  }
  @Local childMessage: string = this.message


  @Monitor('message')
  onMessageChange(monitor: IMonitor) {
    this.childMessage = monitor.value()?.now as string
  }

  build() {
    TextInput({ text: this.childMessage })
      .onChange((value: string) => {
        this.childMessage = value
        this.$message(this.childMessage)
      })
  }
}

V2中新增了!!以进行数据双向绑定,就类似于vue的v-model语法糖,写起来更简洁一些

# @Observed@Track -> @ObservedV2@Trace

# 不用为了一个属性而写一个组件了

  • V1若是想要深层级属性变化后马上更新UI,需要一层属性写一个组件,属性class@Observed装饰器,子组件通过@ObjectLink接收

  • V2就可以不需要这个子组件,属性class@ObservedV2装饰,相应属性@Trace声明,父组件就可以直接改变对象第二层属性,让视图更新了

# class中更新变量的函数可以使用箭头函数了

  • V1中class已经@Observed装饰,变量用@Track装饰,但是使用箭头函数修改该变量,UI仍不会更新
@Observed
class Task {
  @Track taskName: string = '';
  @Track isFinish: boolean = false;

  constructor(taskName: string, isFinish: boolean) {
    this.taskName = taskName;
    this.isFinish = isFinish;
  }

  changeName() {
    this.taskName = '修改后' + this.taskName
  }

  // 用箭头函数声明,修改已经@Track的变量,但是UI不更新
  // (虽然本案例中,这里使用箭头函数是非必须的,但在一些其他情况,是必须的)
  changeFinish = () => {
    this.isFinish = !this.isFinish
  }
}

V1解决的做法是:

  1. 修改其为非箭头函数,但有时这是必须的;
  2. 那么就只能通过调用函数将修改的变量分发出去,让组件声明自己的状态变量,赋值修改让ui发生变化
  • 而V2版本可以直接使用箭头函数,UI正常更新
@ObservedV2
class Task2 {
  @Trace taskName: string = '';
  @Trace isFinish: boolean = false;

  constructor(taskName: string, isFinish: boolean) {
    this.taskName = taskName;
    this.isFinish = isFinish;
  }

  changeName() {
    this.taskName = '修改后' + this.taskName
  }

  // 用箭头函数声明,修改已经@Trace的变量,UI会更新
  changeFinish = () => {
    this.isFinish = !this.isFinish
  }
}

完整案例代码可参考文档 (opens new window),此外我这里还加了changeName、changeFinish函数来修改变量

# @Computed

V2终于有计算属性了

在V1,复杂的状态,都是拿的普通函数调用的,这样会调用多次计算

在V2,有了计算属性,就可以缓存了,在被计算的值变化时,仅会计算一次,减少重复计算开销

可以看下面的例子,由于V2@Computed目前只能装饰getter,所以V1和V2写法上差别不算大,不过由于@Computed有缓存功能,getter只调用了一次

@Entry
@Component
struct ComputedPage {
  build() {
    Column({ space: 10 }) {
      ChildV1NoComputed()

      Divider()

      ChildV2Computed()
    }
  }
}

@Component
struct ChildV1NoComputed {
  @State firstName: string = 'Kevin'
  @State surName: string = 'De Bruyne'

  fullName() {
    // 调用两次,因为有两个引用
    console.log('ChildV1NoComputed获取fullName')
    return this.firstName + ' ' + this.surName
  }

  build() {
    Column() {
      TextInput({ text: $$this.firstName })
      TextInput({ text: $$this.surName })
      TextInput({ text: this.fullName() }).onChange((value: string) => {
        this.firstName = value.split(' ')[0]
        this.surName = value.split(' ').slice(1).join(' ')
      })
      Text(this.fullName())
    }
  }
}

@ComponentV2
struct ChildV2Computed {
  @Local firstName: string = 'Bernardo'
  @Local surName: string = 'Silva'

  @Computed
  get fullName() {
    // 调用一次,因为有缓存
    console.log('ChildV2Computed获取fullName')
    return this.firstName + ' ' + this.surName
  }

  build() {
    Column() {
      TextInput({ text: $$this.firstName })
      TextInput({ text: $$this.surName })
      TextInput({ text: this.fullName }).onChange((value: string) => {
        this.firstName = value.split(' ')[0]
        this.surName = value.split(' ').slice(1).join(' ')
      })
      Text(this.fullName)
    }
  }
}

内置组件的双向绑定可以用$$,不用change事件再赋值了,方便一些。这是之前就有的

# makeObserved

makeObserved,V2新增的Api,可将普通不可观察数据变为可观察数据。具体应用可看下面例子:

在V1时,使用@BuilderParam插槽,子组件给父组件传参,子组件将参数修改,但父组件并不发生变化,无法实时更新UI,哪怕参数传递是引用类型也不行。如下代码所示

(至少我是在V1中没有找到解决办法的,有大佬知道可以评论下哈)

@Entry
@ComponentV2
struct BuilderParamPage {
  @Builder
  contentSlot(params: ReferenceType) {
    Text('父组件使用插槽' + params.num)
  }

  build() {
    RelativeContainer() {
      BuilderParamChild({
        contentSlot: this.contentSlot
      })
    }
    .height('100%')
    .width('100%')
  }
}

class ReferenceType {
  num: number = 0;
}

@Component
struct BuilderParamChild {
  @Builder
  defaultBuilder() {
  }

  @BuilderParam contentSlot: (params: ReferenceType) => void = this.defaultBuilder
  @State data: ReferenceType = new ReferenceType()

  build() {
    Column() {
      Text('子组件')
      this.contentSlot(this.data)
      Button(this.data.num.toString()).onClick(() => {
        this.data.num++
      })
    }
  }
}

但是在V2中,利用@Local加上就UIUtils.makeObserved,父组件UI就可以更新。不过V2也得传引用数据类型才行,而且光用@Local还不行,因为@Local观察不到第一层的变化,得加上UIUtils.makeObserved

@ComponentV2
struct BuilderParamChild {
  @Builder
  defaultBuilder() {
  }

  @BuilderParam contentSlot: (params: ReferenceType) => void = this.defaultBuilder
  @Local data: ReferenceType = UIUtils.makeObserved(new ReferenceType())

  build() {
    Column() {
      Text('子组件,测试插槽')
      this.contentSlot(this.data)
      Button(this.data.num.toString()).onClick(() => {
        this.data.num++
      })
    }
  }
}

# 当前版本觉得有些可以进一步优化的地方

# 样式不能抽离到公共文件,即@Styles@Extend,只能写在当前文件中,不利于大型项目的代码复用。

  • 虽然可以使用动态属性设AttributeModifier,但是写起来还是有些麻烦的,对于我这个前端开发来说,挺不习惯的。特别是用V2后因为@Local观察不到第一层的变化,写起来还更麻烦一些,具体可见Modifier文档 (opens new window)
  • 鸿蒙开发一开始就挺类似前端开发的,看到V2的状态管理,也是参考了像Vue的一些Api,那么公共样式的复用上,export @Styles function xxxexport @Extend(UIComponentName) function xxx 类似于这样的写法,其实更符合前端开发习惯

附一张@Styles@ExtendAttributeModifier对比图 对比

# ide的支持

ide对V2支持的优先级不高,比如新建page,默认模板是@Component@State;打comp,出来的也是V1的。估计是看什么时候大家都用V2了,才改过来?那可能是挺长一段时间了,如果能有配置就好了

# 组件名重名报错

在不同page页面,用@ComponentV2装饰的组件名如果重复,build是会报错的,不太理解为啥不能重名,而且我这两个组件都没有导出。虽然不重名找到对应组件更清晰了,但是对于开发者来说,命名变得更加麻烦了一些,特别是对于一些功能简单且不需要导出的组件,感觉限制重名是有些没必要。

报错如图1、3点报错图

# @Computed增加setter

目前@Computed装饰的属性是只读的,如果能增加setter方法就好了,就像vue一样。就上面那个例子来说,就不用在fullname的change事件写了,在setter中写就行了。

# 写在最后

看着鸿蒙的状态管理升级,越来越符合前端开发的习惯,等纯血鸿蒙更多应用后,鸿蒙开发岗位应该是会越来越多的,说不好就是未来前端转行方向呢,所以学起来吧!

# 参考文档

V1-V2迁移 (opens new window)

makeObserved接口:将非观察数据变为可观察数据 (opens new window)

Wonderwall
Oasis