初探 Vue 3响应式源码(五):Ref

为什么需要ref

我们前两章讲讲解了reactive源码解析effect源码解析,并且知道了它们是如何实现响应式的,还没看过的小伙伴可以先阅读一下。

我们回顾一下,reactive函数可以创建通过Proxy实现的响应式对象,响应式对象需要在effect中使用才能收集到依赖,在更改响应式对象时,代理会通过trigger通知所有依赖的effect对象,并执行effect的监听方法。

正因为reactive创建的响应式对象是通过Proxy来实现的,所以传入数据不能为基础类型,比如numberstringboolean

什么是ref

ref对象是对reactive不支持的数据的一个补充,让如基础数据响应式进行支持,以及更方便的对象替换操作推出的。下面我们先了解一下ref的特性。

  • 使用refshallowRef函数创建ref对象,ref通过value属性进行访问和修改传入参数。

  • reactive不同,ref的参数没有任何限制。

  • 使用reactive可接受的对象为ref参数对象时,isReactive(ref.value)true

  • refeffect监听函数中使用可响应式

  • refeffect中只有value属性是可响应式的

  • customRef可以创建自定义gettersetterref,创建时需要提供一个创建get, set工厂方法,工厂方法会传入收集方法和触发方法,由用户主动触发。如

    js 复制代码
    let value = 1
    const custom = customRef((track, trigger) => ({
      get() {
        track()
        return value
      },
      set(newValue: number) {
        value = newValue
        trigger()
      }
    }))
  • 使用toRef可以通过proxy的某个属性生成为可以有默认值的ref对象

  • 使用toRefs可以通过proxy的数据结构以及所有属性,生成与proxy数据结构一致的,所有属性值为ref对象的对象

综合上面的特性和之前讲解effect的实现原理,能猜得到ref对象会对value属性的修改和获取时进行拦截,在valueget的时候收集依赖,在set的时候获取依赖关联的effect再触发依赖函数。ref对属性修改和获取时不能通过proxy来实现,ref支持基础类型而proxy不支持。收集依赖时不能使用effect文件中的targetMap关联effecttargetMapWeakMap类型,WeakMap类型仅支持对象作为key,不支持基础类型。

ref和shallowRef的具体实现

接下来我们看看refshallowRef的具体实现:

js 复制代码
// 是否是ref根据属性的__v_isRef决定
export function isRef(r: any): r is Ref {
  return Boolean(r && r.__v_isRef === true)
}


export function ref(value?: unknown) {
  return createRef(value, false)
}

export function shallowRef(value?: unknown) {
  return createRef(value, true)
}

// 创建ref对象,传入raw和是否是shallow
function createRef(rawValue: unknown, shallow: boolean) {
  // 如果之前时ref则直接返回
  if (isRef(rawValue)) {
    return rawValue
  }
  return new RefImpl(rawValue, shallow)
}

reactive一样ref创建的响应式对象也分为是否是shallowref对象支持对value深度响应式,也就是说ref.value.a.b中的修改都能被拦截,shallowRef对象只支持对value值的响应式。

refshallowRef函数都使用createRef来创建ref对象,只是参数的区别。创建的ref对象会附加__v_isRef属性来标识是否是ref对象。在创建ref对象之前会检查入参是否是ref如果是就直接返回入参参数。

我们看到ref函数创建的真实对象是RefImpl,采用了class写法,将rawshallow作为构造函数,下面我们看看这个class的实现:

js 复制代码
// Ref对象类
class RefImpl<T> {
  // 存放 reactive(raw) 后的proxy
  private _value: T
  // 存放 raw
  private _rawValue: T

  // 建立与effect的关系
  public dep?: Dep = undefined
  // 是否ref的标识
  public readonly __v_isRef = true

  // 构造,传入raw 和 shallow
  constructor(value: T, public readonly _shallow: boolean) {
    // 存储 raw 
    this._rawValue = _shallow ? value : toRaw(value)
    // 如果是不是shallow则 存储 reactive proxy 否则存储传入参数
    this._value = _shallow ? value : toReactive(value)
  }

  // getter value拦截器
  get value() {
    // track Ref 收集依赖
    trackRefValue(this)
    return this._value
  }

  // setter value拦截器
  set value(newVal) {
    // 如果是需要深度响应的则获取 入参的raw
    newVal = this._shallow ? newVal : toRaw(newVal)

    // 查看要设置值是否与当前值是否修改
    if (hasChanged(newVal, this._rawValue)) {
      // 存储新的 raw
      this._rawValue = newVal
      // 更新value 如果是深入创建的还需要转化为reactive代理
      this._value = this._shallow ? newVal : toReactive(newVal)
      // 触发value,更新关联的effect
      triggerRefValue(this, newVal)
    }
  }
}

如果不是shallow传入的value会通过toReactive转化为reactive,然后存在ref._value中。在get的时候直接返回这个reactive,这就是使用reactive可接受的对象为ref参数对象时,isReactive(ref.value)true的原因,也是为什么能深度响应的原因。

ref还会存储入参和set的原始值,如果不是shallow则通过toRaw获取,存储在_rawValue属性中,存储这个值是为了能正确的判断值是否被修改。所以下方这种情况是不会调用triggerRefValue的,因为原始值是一样的。

js 复制代码
const target = { name: 'bill' }
const reTarget = reactive(target)
const targetRef = ref(reTarget)

targetRef.value = target

ref对象还有个非常重要的属性depreactive对象是通过targetMapDep关联的。reactive收集时通过track函数获取dep,然后通过dep对象调用trackEffects函数来将effectDep关联。

reactive触发时通过trigger函数整理相关联的多个dep最终合并成一个dep,然后通过dep调用triggerEffects获取关联的effect收集函数并触发。

dep中的具体细节管理是通过trackEffects函数和effect对象管理的,将depeffect是由trackEffects函数处理的, 触发是由triggerEffects函数执行的。

也就是说基于现有effect的基础上,创建响应式对象只需要收集时获取dep并调用trackEffects(dep), 触发时获取收集时的dep并调用triggerEffects(dep) dep属性就是ref能成为响应式对象的根本原因。

接下来我们看看ref是如何实现trackEffects(dep)triggerEffects(dep)的。refget value时会调用trackRefValue,在set value时,如果value值发生了更改则调用triggerRefValue。可以猜到这两个方法就是实现响应式的关键,接下来我们看看他们的具体实现

js 复制代码
// 收集 ref 依赖 调用trackEffects(dep)
export function trackRefValue(ref: RefBase<any>) {
  // 如果当前开启了跟踪
  if (isTracking()) {
    // 获取raw ref数据
    ref = toRaw(ref)
    // 如果当前ref还未初始化dep则创建
    if (!ref.dep) {
      ref.dep = createDep()
    }
    // 如果是开发环境,则传入track细节, 
    if (__DEV__) {
      trackEffects(ref.dep, {
        target: ref,
        type: TrackOpTypes.GET,
        key: 'value'
      })
    } else {
      trackEffects(ref.dep)
    }
  }
}

// 触发 ref 调用trackEffects(dep)
export function triggerRefValue(ref: RefBase<any>, newVal?: any) {
  // 获取raw ref数据
  ref = toRaw(ref)
  // 如果当前ref 有关联的dep
  if (ref.dep) {
    // 如果当前是开发环境则发送具体触发细节
    if (__DEV__) {
      triggerEffects(ref.dep, {
        target: ref,
        // SET引起的变化
        type: TriggerOpTypes.SET,
        key: 'value',
        newValue: newVal
      })
    } else {
      triggerEffects(ref.dep)
    }
  }
}

trackRefValue会在适当的时候初始化dep并调用trackEffectstriggerRefValue会获取refdep并调用triggerEffects,就是我们上面说的内容。

大家注意到传入的ref会调用toRaw方法来重新赋值,这个方法是获取reactive的原始数据的。因为用户可能使用reactive(ref(raw))来获取数据,如果直接使用可能会收集到dep属性的依赖。另外大家思考一下下面这段代码的effect监听函数会触发几次?

js 复制代码
const countRef = ref(0)
const reCount = reactive(countRef)

effect(() => {
  console.log(reCount.value)
})

reCount.value = 3

答案是四次,第一次是首次收集依赖,reactive会收到value的获取,存储value属性的dep附加到targetMap。然后调用ref.valueref在获取value时会调用trackRefValue,创建dep附加到自身属性上。注意ref.value返回this._value,这时候reactive收到_value属性的获取,存储_value属性的dep,附加到targetMap中。所以创建了三个dep。当发生更改新值存储到ref._value中,而对于reactive来说value_value是完全没关联的所以会触发两次,而ref自身会触发一次没所以一共是四次。

customRef

接下来我们看看自定义ref方法customRef是如何实现的:

js 复制代码
// 自定义ref对象类
class CustomRefImpl<T> {
  // 依赖dep 存储effets
  public dep?: Dep = undefined

  // 缓存getter setter
  private readonly _get: ReturnType<CustomRefFactory<T>>['get']
  private readonly _set: ReturnType<CustomRefFactory<T>>['set']

  // mark ref
  public readonly __v_isRef = true

  // 传入ref工厂函数
  constructor(factory: CustomRefFactory<T>) {
    // 构建getter setter,传入track trigger函数
    const { get, set } = factory(
      () => trackRefValue(this),
      () => triggerRefValue(this)
    )
    this._get = get
    this._set = set
  }

  get value() {
    return this._get()
  }

  set value(newVal) {
    this._set(newVal)
  }
}

// 创建自定义ref
export function customRef<T>(factory: CustomRefFactory<T>): Ref<T> {
  return new CustomRefImpl(factory) as any
}

customRef会创建CustomRefImpl的一个实例并返回,CustomRefImpl的实现和Ref差不多,使用trackRefValuetriggerRefValuedepeffect关联实现响应式。不过CustomRefImpl会在工厂函数中传入trackRefValuetriggerRefValue,将收集依赖和触发执行权交给用户。让用户在适当的时候调用。在使用value时候调用生产的get方法,在设置value是调用生产的set方法。一般是在get的时候调用收集函数,set的时候触发函数。

toRefs和ObjectRefImpl

我们在使用reactive时通过缓存属性值很可能会失去响应式特性。因为属性值可能是reactive不支持深入响应的值,这时候缓存属性值,或者是通过ES6解构出来的值是不具备响应特性的。比如在下面这两种使用方式:

js 复制代码
const reuser = reactive({ name: 'bill', sex: '男' })
const { name } = reuser
const sex = reuser.sex

这样的话就得一直使用reuser.name的方式来进行访问,vue有两个api能很好的解决这个问题,就是toReftoRefs

toRef通过reactive代理和代理的某个属性生成为ref并且可以携带默认值。而toRefs根据reactive代理生成所有属性值为ref的对象。生成的refvalue是代理属性值的映射,两端更改都会实时同步,我们看看是如何使用的:

js 复制代码
const reuser = reactive({ name: 'bill', sex: '男' })
const name = toRef(reuser, 'name', '未命名')
const { sex } = toRefs(reuser)

console.log(reuser.name, reuser.sex)  //bill 男
console.log(name.value, sex.value)    //bill 男

name.value = 'lzb'
sex.value = '女'

console.log(reuser.name, reuser.sex)  //lzb 女
console.log(name.value, sex.value)    //lzb 女

delete reuser.name

console.log(reuser.name, reuser.sex)  //undefined 女
console.log(name.value, sex.value)    //未命名 女

接下来我们看看toReftoRefs的具体实现:

js 复制代码
// 将proxy对象和目标的属性 转化为ref 并拥有默认值
class ObjectRefImpl<T extends object, K extends keyof T> {
  public readonly __v_isRef = true

  constructor(
    // 记录代理
    private readonly _object: T,
    // 要辅助的key
    private readonly _key: K,
    // 默认值
    private readonly _defaultValue?: T[K]
  ) {}

  // 获取value
  get value() {
    const val = this._object[this._key]
    return val === undefined ? (this._defaultValue as T[K]) : val
  }

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

// 将proxy对象和目标的属性 转化为ref 并拥有默认值
export function toRef<T extends object, K extends keyof T>(
  object: T,
  key: K,
  defaultValue?: T[K]
): ToRef<T[K]> {
  const val = object[key]
  return isRef(val)
    ? val
    : (new ObjectRefImpl(object, key, defaultValue) as any)
}

// 将proxy对象所有属性转化为ref值
export function toRefs<T extends object>(object: T): ToRefs<T> {
  // 只有内部代理才能toRefs
  if (__DEV__ && !isProxy(object)) {
    console.warn(`toRefs() expects a reactive object but received a plain one.`)
  }
  // 分别对所有属性toRef
  const ret: any = isArray(object) ? new Array(object.length) : {}
  for (const key in object) {
    ret[key] = toRef(object, key)
  }
  return ret
}

toRef会先查看proxy[key]是否是ref如果是的话直接返回,如果不是则创建ObjectRefImpl并且将参数传入,ObjectRefImpl会标识当前对象是ref类型 (通过__v_isRef属性) ,并且缓存proxykey和默认值。get value时直接通过proxy[key]来获取并返回,如果回去的值是undefined则使用默认值。set value时则通过proxy[key] = newVal来设置。

toRefs则是将每个属性都调用一次没有默认值的toRef,并且返回与proxy一致的数据结构。

为什么这里的ObjectRefImpl类不需要dep属性和收集依赖和触发更改呢?这是因为_object属性本身是proxy类型,当我们在使用proxy[key]就实现了收集依赖,在proxy[key] = newVal是就触发了更改。

其他辅助方法

ref文件中还声明了其他辅助方法,比如triggerRef手动触发ref的更改使关联的effect重新执行收集函数;unref获取ref的原始值。这两个方法比较简单直接看源码即可,这里就不再讲解了。

js 复制代码
// 手动触发 ref
export function triggerRef(ref: Ref) {
  // 开发环境用当前值做最新值变化
  triggerRefValue(ref, __DEV__ ? ref.value : void 0)
}

// 解构ref,直接返回value
export function unref<T>(ref: T | Ref<T>): T {
  return isRef(ref) ? (ref.value as any) : ref
}

还有一个辅助方法proxyRefs,这个方法将一个对象直属属性内的所有ref属性值解构访问 (不需要通过value下标访问) 。什么是直属属性就是第一层属性,比如下方的代码:

js 复制代码
const name = ref('bill')
const unUser = proxyRefs({
  name: name,
  adderss: {
    city: ref('珠海')
  }
})

console.log(unUser.name)  // bill
console.log(unUser.address.city)  // Ref

unUser.name = 'lzb'

console.log(unUser.name)  // lzb 

proxyRefs只对第一层属性的ref解构。我们看看它的源码:

js 复制代码
// 浅解构ref处理器
const shallowUnwrapHandlers: ProxyHandler<any> = {
  // getter将unref方便访问
  get: (target, key, receiver) => {
    return unref(Reflect.get(target, key, receiver))
  },
  // setter先查看是否是ref,如果是则更新value
  set: (target, key, value, receiver) => {
    const oldValue = target[key]
    // 如果新数据不是ref但旧数据是则更新value
    if (isRef(oldValue) && !isRef(value)) {
      oldValue.value = value
      return true
    } else {
      return Reflect.set(target, key, value, receiver)
    }
  }
}

// 创建代理ref 解构方便使用
export function proxyRefs<T extends object>(
  objectWithRefs: T
): ShallowUnwrapRef<T> {
  // 如果是reactive对象则无需解构
  return isReactive(objectWithRefs)
    ? objectWithRefs
    : new Proxy(objectWithRefs, shallowUnwrapHandlers)
}

proxyRefs入参是reactive对象时则直接返回,reactive对象本身会对ref解构,而且是深度的,这里就不需要处理。有个特殊情况shallowReactive对象不会对ref解构,但是也直接返回了,也就是说这个方法对shallowReactive对象时无效的!

如果proxyRefs入参不是reactive对象,则创建代理,get拦截器通过unref来获取值返回,set拦截器通过判断当前要更新的是否是ref如果是则更新value

到这里我们ref的所有内容就已经讲完了,接下来日常小结。

小结

  • ref对象自身附加了dep,在收集依赖时通过trackEffects函数,触发时通过triggerEffects函数
  • ref能够创建深度响应式是依赖了reactive
  • Proxy代理对象可以通过toReftoRefs辅助方法保持对单个属性的引用,赋值修改会映射到Proxy
  • customRef函数可以创建自由度极高的响应式对象
相关推荐
getapi3 分钟前
flutter app实现分辨率自适应的图片资源加载
前端·javascript·flutter
—Qeyser32 分钟前
用 Deepseek 写的html油耗计算器
前端·javascript·css·html·css3·deepseek
萌萌哒草头将军35 分钟前
VsCode Colipot 🚗 + MCP Tools ✈️ = 让你的编程体验直接起飞 🚀🚀🚀
前端·visual studio code·mcp
萌萌哒草头将军41 分钟前
🚀🚀🚀MCP SDK 快速接入 DeepSeek 并添加工具!万万没想到MCP这么简单好用!
前端·javascript·mcp
拉不动的猪1 小时前
简单回顾下useMemo
前端·javascript·面试
昔我往昔2 小时前
Java面试中问单例模式如何回答
java·单例模式·面试
烛阴2 小时前
JavaScript 求幂运算符:告别 Math.pow(),拥抱更优雅的次方计算!
前端·javascript
染的人2 小时前
Layui Table组件,设置data数据源,以及page为False,表格只能显示10条数据的问题
前端·layui
玖玖passion2 小时前
js中的栈
前端·算法
只会安静敲代码的 小周3 小时前
【长按图片识别】uniapp vue开发时,点击图片识别—实现转发、收藏、识别图片二维码
前端·vue.js·uni-app