Vue3硬核源码解析系列(5)ref源码解析

891 阅读12分钟

前言

​ 本文是Vue3硬核源码解析系列的第五篇文章,在之前文章中,我们了解到了reactive effect的源码实现原理,并抽丝剥茧输出了mini版本的reactive + effect,带领大家充分理解reactive的实现原理,同时我们也发现了reactive在使用上的一些局限性,比如无法代理基础类型。

​ 正因为此,Vue3提供了另一个API ref,面对proxy无法代理基础类型数据的问题,ref又是如何实现其响应式的呢,本文将带领大家一起走进vue3源码世界,看看ref的实现原理

逻辑图

因为ref既可以传入基础类型,也可以传入复杂类型,所以其总体实现逻辑要比reactive更加复杂,并且依赖reactive

前置知识

如果关于class get set已经很了解,请跳过前置知识

为了降低大家理解ref源码的难度,我们在正式阅读源码之前,先学习一下JavaScript的 class以及修饰符get set相关知识点

class Obj {
  _value = '张三'
  get value() {
    console.log('value的get行为触发')
    return this._value
  }
  set value(val) {
    console.log('value的set行为触发', val)
    this._value = val
  }
}

let obj = new Obj()

get: 被get修饰的方法,允许通过属性读取的方式,触发方法

set: 被set修饰的方法,允许通过属性赋值的方式,触发方法

当访问obj.value的时候,会执行被get修饰的value(),打印log,并得到返回值**‘张三’**

当我们执行obj.value = ’李四‘,进行赋值的时候,将会执行被set修饰的**value()**方法,打印log,并完成变量_value的赋值

​ 看到这里,大家是否有点似曾相识的感觉,访问与赋值触发get set,和proxy代理的对象的get set很相似,大家能理解到这一点就足够了。

​ 因为ref可以代理简单类型,同时也可以代理复杂类型,并且这两种情况下的响应式实现逻辑是完全不同的。

​ 所以接下来,我们从这两个角度分别解读ref的源码实现,以及其核心逻辑。

​ 首先我们看相对简单的基础类型场景,从源码的角度去了解ref是如何实现响应式的。

基础类型场景

案例

let { ref, effect } = Vue

const name = ref('卖鱼强')
effect(() => {
  document.querySelector('#app').innerText = name.value
})

setTimeout(() => {
  name.value = '狂飙强'
}, 2000)

​ 上述代码现象:

  1. 页面初始化的时候显示“卖鱼强”

  2. 2s之后,name发生改变,变成了“狂飙强”。

通过现象与我们之前分析reactive的经验,这个我们可以将ref的实现分为三大模块

  1. 初始化
  2. 读取(依赖收集)
  3. 赋值(依赖触发)

初始化

packages/reactivity/src/ref.ts

export function ref(value?: unknown) {
  // ref 实际上就是createRef
  return createRef(value, false)
}

function createRef(rawValue: unknown, shallow: boolean) {
  // 如果已经是ref,则直接返回
  if (isRef(rawValue)) {
    return rawValue
  }
  // ref API 参数shallow 为 false 含义是 代理是否是浅层的,浅层则只会代理第一层数据
  // ref 就是RefImpl的实例
  return new RefImpl(rawValue, shallow)
}

class RefImpl<T> {
  private _value: T // 被代理对象
  private _rawValue: T // 原始对象

  public dep?: Dep = undefined // Dep是reative阶段声明的Set, 内部存放的是ReactiveEffect
  public readonly __v_isRef = true // 将RefImpl实例默认为true, 未来的isRef判断就一定为true

  constructor(value: T, public readonly __v_isShallow: boolean) { 
    // 寻找原始类型,如果是基础类型不会做任何处理
    this._rawValue = toRaw(value) 
    // 如果value是基础类型,toReactive内部不会做任何处理
    this._value = toReactive(value)
  }

  get value() {
    return this._value
  }

  set value(newVal) {
    newVal = toRaw(newVal)
    // 判断新旧值是否一致,不一致进入if
    if (hasChanged(newVal, this._rawValue)) {
      // 每次value的值发生修改的时候,都保存一下原始对象
      this._rawValue = newVal
     	// 如果value是基础类型 toReactive不会做任何处理
     	// 如果value是复杂类型,则重新进行proxy处理
      this._value = toReactive(newVal)
 
      // 依赖触发,后面单独说
    }
  }
}

通过源码分析,我们可以发现,ref的本质就是new RefImpl

我们ref传入的参数 原始对象被保存到_rawValue,同时将参数(“卖鱼强”)保存到-value中,便于后续的get set

读取

调用name.value的时候,会触发RefImplget value(),方法内部返回最新的_value,完成读取。

get value() {
  // trackRefValue(this) // 依赖收集,后面单独说
  
  return this._value
}

赋值

name.value发生赋值的时候,会触发RefImpl的**set value()**方法,方法内部进行_value的赋值,完成数据更新。

set value(newVal) {
  // 判断新旧值是否一致,不一致进入if
  if (hasChanged(newVal, this._rawValue)) {
    // 如果value是基础类型 toReactive不会做任何处理
    this._value = toReactive(newVal)

    // triggerRefValue(this)// 依赖触发,后面单独说
  }
}

到此为止,ref的基础逻辑就完成,我们已经具备给ref赋值、读取的能力。

但是还不具备响应式的能力,接下来就让我们看看,ref的响应式系统是如何实现的。

依赖收集(trackRefValue)

​ 根据我们解读reactive的源码经验,我们可以猜到,ref一定是在get中完成依赖收集的,事实也是如此。

​ 而第一次refget是何时触发的呢?

​ 答案是初始化时期的effecteffect触发后,内部fn被保存到activeEffect中,并触发fnfn访问了name.value,触发了refget行为,所以接下来我们前往RefImplget中,看看ref是如何完成依赖收集的。

get value() {
  // 依赖收集函数 将当前RefImpl实例传入方法
  trackRefValue(this)
  return this._value
}

export function trackRefValue(ref) {
  // shouldTrack一定为true,activeEffect在effect执行阶段保存了fn,所以一定存在
  if (shouldTrack && activeEffect) {
    // createDep我们在reactive中见过,含义为创建一个Set
    // 所以这个实际函数是给RefImpl实例的dep赋值为Set,然后在传入trackEffects方法
  	trackEffects(ref.dep || (ref.dep = createDep()))
  }
}

export function trackEffects(dep: Dep,) {
  // 将当前activeEffect,也就是effect的fn,保存到当前RefImpl实例的dep中,effect成功被ref依赖收集到实例的dep中
 	dep.add(activeEffect)
}

通过以上源码,我们可以发现,他们都公用了activeEffect部分的逻辑,但是ref收集依赖的方式与reactive是存在一些差别的

  • reactive的依赖收集通过WeakMap完成,实现属性、变量与effect fn的绑定关系
  • ref则通过自身实例内部的dep变量来保存所有相关的effect fn

依赖触发(triggerRefValue)

若干时间后,name.value的值被修改,触发RefImplset value

set value(newVal) {
  // 判断传入值是否与原始值不一致
  if (hasChanged(newVal, this._rawValue)) {
    // 完成赋值
    this._value = toReactive(newVal)
    // 依赖触发
    triggerRefValue(this)
  }
}

export function triggerRefValue(ref: RefBase<any>) {
  if (ref.dep) { // dep为依赖收集阶段收集到的依赖,内部为effect的fn
    triggerEffects(ref.dep)
  }
}

export function triggerEffects(dep: Dep) {
  const effects = isArray(dep) ? dep : [...dep] // 转为数组
  for (const effect of effects) {
    	// 进入依赖触发函数
      triggerEffect(effect)
  }
}

function triggerEffect(effect: ReactiveEffect) {
  // 依次通过run触发被收集的effect的fn,至此完成依赖触发工作
  effect.run()
}

依赖触发的逻辑就非常简单了,set value的同时,获取当前refdep,并遍历dep中的依赖,依次执行,完成依赖触发。

小结

​ 到此为止,我们基础类型场景的ref源码解读就结束了,我们简单做一下总结,

​ 相比较于reactive,该场景下的逻辑要稍微简单一点,相关依赖**(effect fn)被实例本身的dep管理,没有构建复杂的WeakMap**对象。

refreactive的收集与触发的逻辑也不相同

  • ref实际上是一个class RefImpl的实例
  • 数据响应并不是通过proxy实现,而是通过classget set修饰符实现
  • 依赖收集、触发并不是通过WeakMap实现,而是通过RefImpl实例中的变量dep实现

复杂类型场景

​ 大家都知道ref不仅可以实现基础类型的响应式,还可以实现复杂类型的响应式,我们可以说refreactive的超集,那ref是如何实现既支持基础类型也支持复杂类型的呢?

​ 接下来就让我们看看复杂类型场景下的ref是如何完成响应式的吧。

案例

let { ref, effect } = Vue

const obj = ref({
  name: '卖鱼强'
})

effect(() => {
  document.querySelector('#app').innerText = obj.value.name
})

setTimeout(() => {
  obj.value.name = '狂飙强'
}, 4000)

Ref初始化

首先依旧是进入ref函数中,开始new RefImpl,前面流程完全一致,所以直接我们进入RefImpl内部

class RefImpl<T> {
  private _value: T // 被代理对象
  private _rawValue: T

  public dep?: Dep = undefined // Dep是reative阶段声明的Set,内部存放的是ReactiveEffect
  public readonly __v_isRef = true // 将RefImpl的实例全部置为true,下次isRef判断就会为true

  constructor(value: T, public readonly __v_isShallow: boolean) {
    this._rawValue = toRaw(value) // toRaw 获取原始数据
    this._value = toReactive(value) // 跳转到toReactive函数中 并且最终会获取到一个proxy对象
  }

  get value() {}

  set value(newVal) {}
}

export const toReactive = <T extends unknown>(value: T): T =>
  isObject(value) ? reactive(value) : value // value为object,进入reactive(value)逻辑 最终返回一个proxy的对象

​ 在constructor逻辑中,我们可以看到this._value = toReactive(value),而toReactive函数中,会首先识别value类型,如果不是object,原路返回,如果是object,将会被reactive函数处理,所以在该场景下,value将被reactive函数处理成proxy对象。

​ 也就是说,此时ref内部的_value实际上成了reactive类型。

读取

​ 初始化阶段,effect触发的时候,将会读取obj.value.name,,首先会访问量obj.value,触发refget方法。

obj.value获取完成后,继续去获取obj.value.name,而name已经在初始化阶段,被toReactive处理成了proxy,所以接下来,会再触发reactiveget,来获取name

​ 也就是说,读取阶段,实际上触发了2次get,一次是refget value,一次是proxyget,进而完成了变量的读取。

get value() {
  // trackRefValue(this) // 依赖收集,后面单独说
  return this._value // 获取到proxy类型的{name: '张三'},进而再次触发proxy的get方法
}

赋值

若干时间后,obj.value.name发生set行为,首先依旧会触发refget,获取obj.value,然后再触发reactiveset方法,完成name的赋值。

整个赋值过程,实际上分别触发了ref的get value,和proxy的set,进而完成变量的赋值

//ref 本身的set在value为object,并且没有直接修改ref.value的情况下,不会被触发
set value(newVal) {}

到此为止,我们了解了ref在处理复杂对象时候的读取与赋值的逻辑。

读取:先触发ref的get,再触发proxy的get

赋值:先触发ref的get,再触发proxy的set

依赖收集

依赖收集是在get阶段进行完成,而通过上面的分析我们可以了解到,refget实际上其内部是两次get事件,所以我们分开来看。

ref的依赖收集(trackRefValue)

effect初始化阶段执行的时候,会读取obj.value.name,首先会触发refget方法

get value() {
  // 依赖收集函数 将当前ref本身传入方法
  trackRefValue(this)
  return this._value
}

refget方法触发了trackRefValue,会在当前refdep中收集到effect,此处逻辑与ref为基础类型的逻辑一致。

proxy的依赖收集(track)

ref的的get完成后,紧接着触发了reactiveget,然后get内部通过WeakMap再次完成依赖收集(相关逻辑参考Vue3硬核源码解析系列(3) reactive + effect源码解析)。

​ 我们会发现,在该阶段,我们内部实际上触发了2次依赖收集effect fnref收集的同时,也被proxy收集了。

依赖触发

因为ref内部是一个对象,所以赋值也存在多种方式,这依赖触发存在多种方式

对象属性触发依赖

obj.value.name = '狂飙强'

这种不会破坏RefImpl初始化阶段其内部构建的proxy,仅修改已有proxy内部变量的值。

首先触发的是obj.valueget行为(此时没有effet在执行,不会发生依赖收集)。然后refget函数返回proxy对象 {name:'卖鱼强'} ,紧接着触发proxyset,并完成依赖触发(proxy的依赖触发请看这里Vue3硬核源码解析系列(3) reactive + effect源码解析)。

对象触发依赖

obj.value = {
  name: '狂飙强'
}

第二种方式首先触发obj.valueset行为,同时替换掉ref的值,注意这会破坏RefImpl初始化构建的_value的proxy,进而导致WeakMap中已有的依赖关系断裂

然后执行triggerRefValue,触发,ref本身在get阶段收集了相关effect fn,。

effect fn被触发后,再次触发ref的getproxy的get,并帮助proxy又重建了与effect fn之间的依赖关系。

这就是为什么存在依赖收集2次的原因。

到此为止,我们的ref核心源码分析就全部完毕了。

关于ref的一些问题

Q:为啥一定要.value,不能干掉吗?

A:非常遗憾,value是去不掉的,因为ref依赖class get set 进行实现,在当前实现的场景下,可以简写为v,但是无法去除

Q:我是不是可以完全使用ref,不用reactive?

A:是的,可以完全使用ref,因为ref会根据你传入的类型,自动识别内部是否需要使用reactive,但是读过源码的同学知道ref在处理响应式系统中,存在重复收集依赖的场景,如果你有极致的性能要求,建议复杂类型依旧使用reactive完成,业务开发场景则无所谓。

如果还有其他问题,请评论区提问~

总结

​ 通过对ref源码的阅读,我们可以察觉到,如果仅仅聚焦基础类型的ref,其实底层实现还是比较简单的,所以建议有兴趣的同学渐进式的阅读源码,先完成基础类型场景的源码解读,再进行复杂类型的源码解读,这样事半功倍~

​ 如果有任何问题,请评论区留言~

​ 下一个阶段,我将手摸手带大家完成mini版本vue3 ref API,帮助大家深入理解ref~

本文正在参加「金石计划」