基于 Vue Composition API ,实现面向对象的状态管理

在大部分时候前端都使用简单的对象字面量(Plain Object)来定义状态。这一方面是因为很多前端业务比较简单。例如纯数据展示的场景,可以直接使用后端返回的 JSON 结构,或只需要做一些基础的转换。另一方面,在经典的 React + Redux 架构中,我们需要保持数据的不可变性以触发视图更新,而这依赖大量的对象解构,也促使大家优先选择对象字面量进行操作。

然而前端并不是只有视图层,还有复杂的业务场景,像一些具有复杂结构和联动关系的表单、可视化编辑器之类的需求。在这些需求中,前后端的攻守之势易矣。后端只需要提供基础的增删改查接口,而所有的业务逻辑和状态流转都需要放到前端进行。

在这样的需求中,简单的组件级别状态显然不够用了,必须引入全局或模块级别的状态管理。关于全局状态管理的必要性,我们已在上一篇文章中探讨过了。而本文主要聊聊状态的组织,以及笔者在实践中总结出的使用 Composition API 组织状态的技巧。

引入派生状态与操作

相比于简单的纯展示需求,在复杂业务中,我们除了需要保存状态数据本身,还需要额外处理派生状态,以及对状态的操作。可以使用最直接的,也是最函数式的做法 —— 把这些逻辑放到单独的函数中。举个最简单的例子:

const person = { firstName: 'John', lastName: 'Smith' }

function getFullName(person) {
  return `${person.firstName} ${person.lastName}`
}

function sayHi(person) {
  console.log(`Hi, ${getFullName(person)}`)
}

这种做法类似于“贫血模型”。它的状态对象被保持为 Plain Object,这是我们最熟悉的做法,可以与任何前端框架结合起来使用。

不过这种做法也有一定的问题:

首先是这样做导致了逻辑的分散。它的状态、派生状态和操作是三个独立的东西,在实践中往往会放在不同的地方(例如 Vuex 的 storegettersaction)。但如今无论是 React Hooks 还是 Vue Composition API,动机之一都是重新组织代码的结构,使得同一逻辑关注点的代码能够被聚集在一起,并抽离到函数中,以方便逻辑复用。将状态、逻辑分散的做法并不符合最佳实践,也与当今趋势背道而驰。

其次,这种做法放大了状态和派生状态的区别。尤其是在 React 中,当你有一个复杂的派生状态时,你很难保证正确地使用它。你需要小心翼翼地编写代码,甚至利用 reselect 等第三方库,才能保证界面不被无谓地重新渲染。

我们可以利用面向对象的做法改写这个例子:

class Person {
  firstName = 'John'
  lastName = 'Smith'

  get fullName() {
    return `${this.firstName} ${this.lastName}`
  }

  sayHi() {
    console.log(`Hi, ${this.fullName}`)
  }
}

通过定义一个 class,我们把所有 Person 相关的状态和方法都放到了一起,更加内聚。在使用上,面向对象的做法也更符合人们的思维习惯,更加直观。

此外,这样做还相当于构建了一个防腐层,可以防止后端改了一个字段名,前端就要改一百个地方。对于纯 JavaScript 项目来说,它还能一定程度上起到 TypeScript 的作用,能让开发者清晰地看到一个业务对象定义了哪些字段,并获得 IDE 的自动补全。

更深层次的好处是,通过面向对象的分析与设计,搭建具有高度复杂度的系统,在历史上已经有了很多成熟的方法论,可以为我们所用。

与视图层结合

我们把状态、派生状态和操作一起封装成了对象。接下来我们要把它放到视图层中使用。

在 Vue 中,组件的 data 可以不是 Plain Object。我们直接把一个类的实例放到 data 中,它的响应式也毫无问题。

<template>
  <div>
    <div>{{ person.fullName }}</div>
    <button @click="person.sayHi()">Say Hi!</button>
  </div>
</template>
<script>
export default {
  data() {
    return {
      person: new Person()
    }
  }
}
</script>

看上去这样已经足够了。不过又有了个新的问题:class 中的 getter 只是一个普通函数,并不能享受到 Vue 的自动收集依赖与记忆功能:

<template>
  <div>
    <div>{{ person.fullName }}</div>
    <div>{{ fullName }}</div>
    <button @click="counter++">render count: {{ counter }}</button>
  </div>
</template>
<script>
export default {
  data() {
    return {
      person: new Person(),
      counter: 1
    }
  },
  computed: {
    fullName() {
      console.log('recalculate')
      return `${this.person.firstName} ${this.person.lastName}`
    }
  }
}
</script>

可通过点击按钮触发组件的重新渲染进行测试。由于 person 的属性均没有变化,在重新渲染组件时,并不会重新触发 computed 打印 log。但如果把打印语句放到 class 的 getter 中,可以看到每次重新渲染都会打印 log。

使用 Composition API 改写对象

使用 class 语法会造成功能缺失和性能损失,这显然是不可接受的。而使用 computed 又得修改 SFC ,导致数据与 UI 的耦合。有没有两全其美的做法呢?所幸 Vue Composition API 的推出,使得 Vue 的响应式核心可以脱离视图层单独使用,我们可以使用 Composition API 改写 Person 对象:

export function usePerson() {
  const firstName = ref('John')
  const lastName = ref('Smith')
  const fullName = computed(() => `${firstName.value} ${lastName.value}`)

  const sayHi = () => {
    console.log(`Hi, ${fullName.value}`)
  }

  return reactive({
    firstName,
    lastName,
    fullName,
    sayHi
  })
}
<template>
  <div>
    <div>{{ person.fullName }}</div>
    <button @click="counter++">render count: {{ counter }}</button>
  </div>
</template>
<script setup>
const person = usePerson()
const counter = ref(1)
</script>

我们可以将这种写法与使用 class 的写法进行对比:

class Person {

  /* 实例变量 */
  firstName = 'John'

  constructor() {
    /* 构造函数 */
  }

  /* getter */
  get fullName() {
    return ...
  }

  /* 方法 */
  sayHi() {
    ...
  }
}
/* 构造函数 */
export function usePerson() {

  /* 实例变量 */
  const firstName = ref('John')

  /* getter */
  const fullName = computed(() => ...)

  /* 方法 */
  const sayHi = () => {
    ...
  }

  return reactive({
    firstName,
    fullName,
    sayHi
  })
}

可以看到,虽然我们用的是 Composition API,和 class 的形式颇为不同,但两者的结构和能力是大体相同的,可以互相转换。这也容易理解,毕竟闭包和对象本就是一体两面。

值得注意的是,在返回时我们包裹了一层 reactive ,而这在普通的组合式函数中并不常见:

-return { ... }
+return reactive({ ... })

通常,组合式函数是为了抽取一段公共的逻辑。当一个组合式函数返回一个对象时,本质上只是为了同时返回多个值。外部只用解构出自己需要的成员使用。

const { start, stop } = useResizeObserver()

在我们的例子中,技术上当然也可以这么使用,不添加 reactive

// 不使用 reactive
const { fullName, sayHi } = usePerson()
console.log(fullName.value)
sayHi()

// 使用 reactive
const person = usePerson()
console.log(person.fullName)
person.sayHi()

person 对象并不只是简单的响应式变量的集合,它是一个真切的拥有业务意义的对象,是“面向对象”这一概念的“对象”。后者的写法显然更为直观易懂。

还有一个更为现实的原因。一个复杂业务对象的层级往往较多,意味着会经常有组合式函数嵌套的情况,而这是常规使用 Composition API 时非常少见的场景。例如:

function useCompany() {
  const boss = usePerson()
  return reactive({ boss })
  // 若不使用 reactive 包裹:
  // return { boss }
}

const company = useCompany()
console.log(company.boss.fullName) 
// 若 useCompany 不使用 reactive 包裹:
// company.boss.value.fullName
// 若 useCompany 和 usePerson 都不使用 reactive 包裹:
// company.boss.value.fullName.value

如果不使用 reactive ,在对象变得复杂以后,你很快就会困惑于什么时候需要加 .value ,而什么时候又不需要。别说你了,TypeScript 都分不清楚。因此我们建议任何时候都加上 reactive ,以保持调用方式的一致性。

虽然同样是使用 Composition API,但一个是抽离重复且功能内聚的代码片段,一个是利用面向对象思维进行业务建模。思考的出发点不同,导致了编写代码时的细节差异。

与 class 比较

让我们再更多探讨一些使用 Composition API 构建对象,与使用 class 语法的不同点。

最重要的区别,也是我们最开始这么做的出发点,就是 class 的 getter 无法达到与 computed 同等的能力。

不过 JavaScript 是一门灵活的语言。硬要说的话,也可以用一些高阶函数等手段,将 class 的字段包装为响应式属性(类似于使用 @vue/reactivity 造了个 mobx 轮子)。但一方面你要自己造轮子,还要教会你的团队成员使用轮子。另一方面,也不是所有人都喜欢 class 语法,而使用 Composition API 对于 Vue 使用者来说更为简单易懂,且无可指摘。

在不喜欢 class 的原因之中,很多是对 JavaScript 中 this 的设计颇有微词。而使用 Composition API 规避了 this,也需要你更加清晰地理顺各个对象之间的依赖关系。

class 的一个好处是可以使用 instanceof 判断类型,而这是闭包不具备的。如有需要我们可以自己额外定义个 type 属性,使用字符串或 Symbol 去判断。

另一个 class 独有的关键字是 extends 即继承能力。但继承能力并不是必需的,组合优于继承已经成为了大家的共识。而在组合这一点上,借助解构语法,Composition API 甚至能实现得更加简洁。

// 利用 class 实现组合
class Child {
  constructor(parent) {
    this.parent = parent
  }

  get firstName() {
    return this.parent.firstName
  }

  get lastName() {
    return this.parent.lastName
  }

  get fullName() {
    return this.parent.fullName
  }

  sayHi() {
    this.parent.sayHi()
    console.log('This is from child!')
  }
}
// 利用 function 实现组合
function useChild(parent) {
  const { sayHi: parentSayHi, ...restParentProperties } = toRefs(parent)

  const sayHi = () => {
    parentSayHi.value()
    console.log('This is from child!')
  }

  return reactive({ sayHi, ...restParentProperties })
}

在使用 class 实现组合时,为了封装被组合对象的属性,还需要写一些模板代码去做转发。而后者的实现中我们只需要关注 childparent 的不同点,剩下的透传即可。

对 watch 的使用

在 Composition API 中,watch 是一个常用的方法。不过当我们改变了对 Composition API 的使用思路后,对 watch 的使用也有了新的注意事项。

在 Vue 官网中提到:“侦听器必须用同步语句创建:如果用异步回调创建一个侦听器,那么它不会绑定到当前组件上,你必须手动停止它,以防内存泄漏。”

如果我们只是普通地使用 Composition API,那么我们基本上就是这么做的,大多数时候并不用关心 watcher 的停止问题。但如果我们把 Composition API 当对象来用,脱离组件的范畴,那就会出现诸如网络请求回来,异步初始化对象的情况。这就需要我们仔细审视对 watch 的使用。

最笨的情况当然是我们在对象结束使用的时候手动去调用停止 watcher 的方法。相当于人肉垃圾回收。有更优雅些的做法吗?

其实很多时候我们并不是真的需要使用 watch 。假如我们现在需要添加一个联动关系:在 firstName 变化时清空 lastName 。第一反应是用 watch 实现:

function usePerson() {
  const firstName = ref('John')
  const lastName = ref('Smith')
  watch(firstName, () => (lastName.value = ''))
}

但既然我们已经知道是 firstName 的变化引发 lastName 的变化,我们可以为 firstName 做一层封装,在它的 setter 中去实现联动的逻辑,这样就可以规避对 watch 的使用:

const _firstName = ref('John')
const firstName = computed({
  get: () => _firstName.value,
  set: (newValue) => {
    _firstName.value = newValue
    lastName.value = ''
  }
})

还有些时候,我们可以确定某段逻辑是由视图层触发的。可以把这段逻辑作为一个方法定义在对象中,而在触发事件的源头 —— 即视图层中,再真正调用它:

function usePerson() {
  ...
  const clearLastName = () => (lastName.value = '')

  return reactive({ ..., clearLastName })
}
<template>
  <input v-model="person.firstName" @change="person.clearLastName()" />
</template>
<script setup>
const person = usePerson()
</script>

还有些时候,我们可以通过拆分组件,实现异步变同步。例如原本的逻辑:

<template>
  <div>
    <template v-if="person">...</template>
  </div>
</template>
<script setup>
const person = ref(null)
onMounted(async () => {
  const defaultValue = await fetch('...')
  person.value = usePerson(defaultValue)
})
</script>

可以拆分成:

<!-- DataProvider.vue -->
<template>
  <div>
    <template v-if="defaultValue">
      <PersonView :default-value="defaultValue" />
    </template>
  </div>
</template>
<script setup>
const defaultValue = ref(null)
onMounted(async () => {
  defaultValue.value = await fetch('...')
})
</script>

<!-- PersonView.vue -->
<template>
  <div>...</div>
</template>
<script setup>
const props = defineProps({
  defaultValue: {
    type: Object,
    required: true,
  }
})

const person = usePerson(props.defaultValue)
</script>

最后的最后,让我们重新看待 watch 的例子。确实 watch 使用不当容易产生内存泄漏,但它一定会内存泄漏吗?

我们知道内存泄露往往是由于长生命周期的对象引用了短生命周期的对象,导致后者虽然不需要了,但不会被 GC。而在上文的示例中,watch 建立的是 firstNamelastName 之间的联动,而这两个变量的生命周期是一致的。当 person 对象被 GC 时,两者会一起被回收,从而不会导致内存泄露。

watch 具体是如何建立引用链的,需要查看 Vue 的源码。我们让 Claude 整理了一下。只要在写代码时遵循这些原则,我们并不需要谈“内存泄漏”色变。

source callback 引用 自动 GC? 说明
局部 局部 ✅ 会 整条引用链生命周期一致,source 回收时全部回收
局部 全局 ✅ 会 callback 引用全局对象不影响;GC 方向是 source → effect → callback,source 回收后 effect 和 callback 就不可达了
全局 局部 ❌ 不会 全局 source 的 dep set 一直持有 effect 引用,watcher 永远存活
全局 全局 ❌ 不会 同上,source 永远存活导致 watcher 永远存活

关键结论:决定因素是 source 的生命周期,不是 callback。因为引用链方向是 source → dep → effect → callback,只要 source 活着,整条链就不会被 GC。

最后的碎碎念

得益于 Vue Composition API,组合式函数可以脱离视图使用,为状态管理提供了极大的灵活性。它的背后由 @vue/reactivity 库支持。这个库本身是独立的,并不依赖 Vue 生态,可惜的是它目前还依附于 Vue 的代码仓库,没有独立的文档,社区对它的讨论也少之又少。在我看来,它有很大的潜力。期待未来能够与其他的框架、语法(例如装饰器等)结合,擦出更多的火花。

TAGS:  Vue状态管理
正在加载,请稍候……