Vue3响应式对象-ref

215 阅读5分钟

上篇博客细聊了reactive对象,这篇博客谈谈对于ref对象的理解

一、ref对象简要介绍

与reactive对象不同的一点是,ref对象不是通过代理实现的。从设计上看,ref对象和reactive对象所针对的数据类型就是不一致的。

  • reactive对象是对象类型的包装,使用Proxy实现
  • ref对象是number,boolean等基础类型的包装,用getter,setter读写器实现

为什么ref对象不使用proxy实现呢?这儿需要思考为什么ref被设计出来。其实并非不用,实者不能,因为代理只能作用于对象,这些简单的基础类型是不能使用代理的。要去实现这些基础类型操作拦截,只能将这些基础类型进行包装,转换为一个对象来处理。

ref对象分为2类,一个是内置好的ref对象,也是开发中最为常用的响应式对象之一。一个是可以自定义的ref对象,这个对象相较于内置的ref对象而言,有更多的灵活性。下面开始对ref对象进行源码分析。

二、内置ref对象

在开发中,使用ref对象时,都会访问其暴露的value属性,这个属性本质是一个读写器。我们看看ref对象的核心源码:

class RefImpl<T> {
  // 使用值
  private _value: T
  // 原始值
  private _rawValue: T
  // 依赖
  public dep?: Dep = undefined
  // 标志,表示是ref对象
  public readonly __v_isRef = true

  constructor(value: T, public readonly __v_isShallow: boolean) {
    // 浅响应式时,原始值就是传入值,否则原始值是传入值的原始值。因为传入值可以是代理对象
    this._rawValue = __v_isShallow ? value : toRaw(value)
    // 浅响应式时,使用值就是传入值,否则转换为响应式对象。
    // 当转换一个非对象基础类型时,会返回本身
    // ref虽然设计用于非对象基础类型,但也兼容了对象类型的包装
    this._value = __v_isShallow ? value : toReactive(value)
  }
  // getter方法
  get value() {
    // 收集依赖
    trackRefValue(this)
    // 返回使用值
    return this._value
  }
  // setter方法
  set value(newVal) {
    // 是否直接使用传入值,针对一些特殊场景
    // 传入值是只读或者浅代理或者这个ref对象本事是浅响应
    const useDirectValue =
      this.__v_isShallow || isShallow(newVal) || isReadonly(newVal)
    // 直接使用就把传入值当作原始值,否则获取传入值的原始值作为新的原始值
    newVal = useDirectValue ? newVal : toRaw(newVal)
    if (hasChanged(newVal, this._rawValue)) {
      // 如果传入值和原始值有变更,则修改原始值和使用值
      this._rawValue = newVal
      // 使用值在常规场景下需要进行响应式转换
      this._value = useDirectValue ? newVal : toReactive(newVal)
      // 触发依赖更新
      triggerRefValue(this, newVal)
    }
  }
}

代码其实很简单,本质上就是_value属性用于保存使用值,_rawValue属性用于保存原始值。修改时比较原始值之间是否变更,变更则触发依赖更新,读取时直接获取使用值,触发依赖收集。_value和_rawValue的区别在于_value属性是_rawValue的响应式包装

如果严格遵循ref的设计,只针对基础普通类型,其实_rawValue属性是可以移除的。就是由于兼容了对象的包装,需要考虑传入的是代理对象等等情况,所以才使用_rawValue用于保存原始数据。

其实传入的如果是一个对象,本质上已经和ref对象关系不大了。参考如下代码:

let data = ref({
  a:1
})
let calls = 0
effect(() => {
  calls++
  console.log(data.value.a) 
  // 此时进行依赖收集,收集了2次依赖
  // 第一次访问value属性,第二次访问a属性
})
data.value.a = 2 
// 触发依赖更新,实质是通过reactive对象触发的,而不是通过ref对象触发的
// 修改内层对象的属性值,不会触发setter方法

上述代码中,打印时使用了ref对象,且访问了其包装对象的a属性,这儿进行了2次依赖收集。代码执行流程是这样的,访问了value属性,此时依赖收集,得到的是一个响应式对象,再访问这个响应式对象的a属性,再次进行依赖收集。下面的更改是直接修改的a属性,所以是通过内部的响应式对象来触发的,而不是ref对象触发的。

简单的总结如下:直接操作value属性,那么是通过ref属性进行依赖收集和依赖更新,一旦使用了内层对象属性,本质上是操作的reactive对象。

三、自定义ref对象

自定义ref对象和普通ref对象的设计原理是一致的,区别在于get方法和set方法由开发者自己编写。核心源码如下:

class CustomRefImpl<T> {
  public dep?: Dep = undefined
  private readonly _get: ReturnType<CustomRefFactory<T>>['get']
  private readonly _set: ReturnType<CustomRefFactory<T>>['set']
  public readonly __v_isRef = true
  // 传入一个工厂方法,这个方法需要返回一个get和set
  constructor(factory: CustomRefFactory<T>) {
    const { get, set } = factory(
      () => trackRefValue(this),
      () => triggerRefValue(this)
    )
    this._get = get
    this._set = set
  }
  get value() {
    // 调用get
    return this._get()
  }
  set value(newVal) {
    // 调用set
    this._set(newVal)
  }
}

本质上和ref的设计没有区别,开发者只需要自己实现一个工厂方法。触发依赖收集和触发依赖更新的方法作为入参传入工厂方法。因此什么时候收集依赖,什么时候触发依赖更新都是开发者自己决定的,相对而言十分灵活。示例代码如下:

let value = 1
const custom = customRef((track, trigger) => ({
  get() {
    // 获取时依赖收集
    track()
    // 返回数据
    return value
  },
  set(newValue: number) {
    // 直接修改数据
    value = newValue
    // 触发依赖更新
    trigger()
  }
}))

可以看到,这种自定义的方式,get时的返回和set时的修改,完全由开发者自定逻辑,十分的灵活。因此是否使用,可以结合具体场景考虑。

四、总结

ref对象是响应式系统的基础响应式对象之一。和reactive对象相比,主要是弥补reactive对象无法针对普通类型数据实现响应式的不足,整体设计较为简单清晰,后续博客将继续分析计算属性和异步计算属性。