鸿蒙状态管理V1-V2的一些思考
# 前言
开始学过React
,工作后基本用Vue
做开发,之前没有做过应用端开发,不过目前在进行鸿蒙应用的开发。
鸿蒙的开发用的是ArkTS
,基于TypeScript
开发的语言,感觉很多方面是参考前端主流框架来做的,对前端开发入门还是很友好的
开始看鸿蒙文档和代码的时候,我是觉得熟悉但又有点陌生,熟悉是对于语法是很快能理解,开发上手也快;陌生是一些语法细节上,觉得很怪,用不习惯,主要体现在状态管理这方面
特别是一开始用4.0版本的时候,就觉得好奇怪:
@Prop
不能装饰对象数组@Link
能直接修改父子组件数据,是双向数据流,而不是数据单项流动- 用
@ObjectLink
装饰深层级的class
对象,一个深层级属性就要一个组件 - 没有计算属性
- ···
后来升级到Next版本后,发现好些了,但是有些问题还是存在
现在推出了状态管理V2,我的疑问和用法上的不适基本都解决了,不少升级更符合前端主流框架Vue
、React
的思想,对于之前用惯前端框架的开发伙伴还挺友好的。
下面我将介绍几个重点的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++
})
}
}
}
# @Link
-> @Param
和@Event
符合前端框架开发思维:数据单项流动、用@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解决的做法是:
- 修改其为非箭头函数,但有时这是必须的;
- 那么就只能通过调用函数将修改的变量分发出去,让组件声明自己的状态变量,赋值修改让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 xxx
、export @Extend(UIComponentName) function xxx
类似于这样的写法,其实更符合前端开发习惯
附一张@Styles
、@Extend
、AttributeModifier
对比图
# ide的支持
ide对V2支持的优先级不高,比如新建page,默认模板是@Component
和@State
;打comp,出来的也是V1的。估计是看什么时候大家都用V2了,才改过来?那可能是挺长一段时间了,如果能有配置就好了
# 组件名重名报错
在不同page页面,用@ComponentV2
装饰的组件名如果重复,build是会报错的,不太理解为啥不能重名,而且我这两个组件都没有导出。虽然不重名找到对应组件更清晰了,但是对于开发者来说,命名变得更加麻烦了一些,特别是对于一些功能简单且不需要导出的组件,感觉限制重名是有些没必要。
报错如图1、3点
# @Computed
增加setter
目前@Computed
装饰的属性是只读的,如果能增加setter
方法就好了,就像vue一样。就上面那个例子来说,就不用在fullname的change
事件写了,在setter
中写就行了。
# 写在最后
看着鸿蒙的状态管理升级,越来越符合前端开发的习惯,等纯血鸿蒙更多应用后,鸿蒙开发岗位应该是会越来越多的,说不好就是未来前端转行方向呢,所以学起来吧!