从源码的角度看 toRef computed 在业务中如何使用

472 阅读2分钟

前言

自从 Vue3 发布后,已经有不少项目在使用,响应式在业务开发中还是很香的,很多框架也都在往 Signal 方面靠。但响应式并不算万能银弹,在掘金也能看到很多文章吐槽 Vue API 多且杂,很多场景下会有解构丢失响应式的问题,今天从源码的角度着重讨论一下 toRefcomputed 两个 API 在业务中的一些实践。

业务开发中,经常会遇到这种情况。为了减少接口调用,后端会针对功能尽可能的压缩接口数量,可能一个页面上的很多数据都只在一个接口中,但前端页面一般是由多个板块组成,比如表单板块和表格板块,两个板块都会设计成独立的组件,为了便利的使用数据,我们会把数据从后端返回的大对象解构出来,这个时候就遇到了 toRefcomputed 两个 API 的使用场景。

使用

Vue 对计算属性的定义是用来描述依赖响应式状态的复杂逻辑,会基于其响应式依赖被缓存,我们大多数使用 computed 的场景是用来缓存一个复杂计算,如果把 computedtoRef 比较,仅仅用来缓存对象中的某个变量,似乎有点浪费,别急,我们先来看看下面这个 示例(点我跳转)

<script setup>
import { reactive, toRef, computed } from 'vue'

const vnode = reactive({
    name: 'div',
    attrs: {
        class: 'tag',
        style: 'width: 100px'
    }
})

const nameForToRef = toRef(vnode, 'name')
const styleForToRef = toRef(vnode.attrs, 'style')

const nameForComputed = computed(()=> vnode.name)
const styleForComputed = computed(()=> vnode.attrs.style)

function fn(){
  vnode.name = 'span'
  vnode.attrs = {
    class: 'tag',
    style: `width: ${parseInt(Math.random() * 100)}px`
  }
}
</script>

<template>
  <h1>toRef: {{ nameForToRef }} 的 style:{{ styleForToRef }}</h1>
  <h1>computed: {{ nameForComputed }} 的 style:{{ styleForComputed }}</h1>
  <button @click="fn">change</button>
</template>

image.png

上面这个例子,我们需要解构出 namestyle 两个变量在页面上展示,点击按钮会改变 vnode,直接修改内部的 nameattrs 属性。

不管是基于 toRef 还是 computed 解构出来变量,在模板上展示的都相同,但我们尝试修改一下 vnode 的属性,只基于有 computedstyle 变量触发响应式,基于 toRefstyle 变量依旧是 100px

image.png

我们从源码的角度分析一下出现这种情况的原因。

源码分析

toRef 的源码稍简单一些

class ObjectRefImpl{
  public readonly __v_isRef = true
  private readonly _object,
  private readonly _key,

  constructor(
    object,
    key,
  ) {
    // 在内部保存一份 vnode.attrs 的引用及要解构的 key 值
    // 此时的 this._object 即 vnode.attrs
    this._object = object
    this._key = key
  }

  get value() {
    // 在外层 style.value 的时候会触发此函数,返回原对象上的值
    // 经过访问原对象 vnode.attrs.style 会把当前环境存入 vnode.attrs.style 的 dep 中
    // 一旦原对象的值变更,会遍历 dep,触发依赖此值的 watch computed 等更新
    const val = this._object[this._key]
    return val
  }

  set value(newVal) {
    this._object[this._key] = newVal
  }
}

function toRef(
  object,
  key
) {
  return new ObjectRefImpl(object, key)
}

const style = toRef(vnode.attrs, 'style')
console.log(style.value) // 触发 ObjectRefImpl 中的 get value()

传入要解构的对象及 key 值,通过 ObjectRefImpl 保存一份原对象的引用。这也就解释了为什么 vnode.attrs = { class: 'tag', style: `width: ${parseInt(Math.random() * 100)}px` } 后,toRefstyle 值没有更新,因为 vnode.attrs 被赋予了一个新值,ObjectRefImpl 内部保存的引用不再和 vnode.attrs 共通,vnode.attrs 发生变更,自然也就和 style.value 无关。我们再看看 computed 为什么可以保证响应式。

以下是 computed源码

export class ComputedRefImpl<T> {
  constructor(
    getter: ComputedGetter<T>
  ) {
    // 通过 ReactiveEffect 注册响应式
    this.effect = new ReactiveEffect(getter, () => {
      if (!this._dirty) {
        this._dirty = true
        triggerRefValue(this)
      }
    })
  }

  get value() {
    const self = toRaw(this)
    if (self._dirty || !self._cacheable) {
      self._dirty = false
      // .run 执行 getter,同时将当前 computed 环境存放到依赖的响应式变量 dep 中
      // 只要依赖的响应式变量发生变化,就会触发依赖 computed 的环境
      self._value = self.effect.run()!
    }
    return self._value
  }

  set value(newValue: T) {
    this._setter(newValue)
  }
}

computedtoRef 不同,computed 是监听内部的响应式变量,对于 vnode.attrs.style 这样深层访问,会逐层监听 attrs -> style 的变更。

总结

如果你的响应式变量不是深层对象,属性仅仅类似于 { a, b, c } 这样简单的基本类型,那可以大胆的使用 toRef,或者可以保证这个深层对象都是 obj.a.b = 1 这样的细粒度变更,使用 toRef 还不会再次创建一个依赖列表,但是在实际业务中,如果对象嵌套层级很深,并且无法保证细粒度更新,建议使用 computed 去解构属性,这样不会丢失响应式