Vue 源码解析(一):ref

521 阅读8分钟

ref API 源码解析

写在最前面

Vue的源码(或其他项目的源码)如果遇到看不懂的地方,例如说某个函数或某个细节看不懂,可以去找ChatGPT,直接问它“请给我解释一下Vue源码中的xxx函数”、“xxx函数中xx变量的意义是什么”,有些地方不仅比博客都说的清楚很多,还会做一些拓展的讲解,真滴棒,妈妈再也不用担心我看不懂源码了。

引入

refreactivevue的响应式相关的两个核心API,它们最关键的不同之处在于:

reactive

reactive可以返回一个对象(对象、数组和MapSet等集合类型)的响应式代理,却对原始值JavaScript中八大类型中除了object之外的其他类型,即stringnumber等类型)无能为力。

通过查看源码得知,在reactive API中,会通过调用createReactiveObject函数创建对象的响应式代理,而在该函数起始处便会检查target,若不是对象则在控制台抛出警告,并直接返回。

function createReactiveObject(
  target: Target,
  isReadonly: boolean,
  baseHandlers: ProxyHandler<any>,
  collectionHandlers: ProxyHandler<any>,
  proxyMap: WeakMap<Target, any>
) {
  // 发现 target 不是对象,直接警告并返回 target 本身
  if (!isObject(target)) {
    if (__DEV__) {
      console.warn(`value cannot be made reactive: ${String(target)}`)
    }
    return target
  }
   
  // 省略一些特殊情况处理代码
  /*
  some code here.
  */
    
  // 为对象创建 proxy 并返回
  const proxy = new Proxy(
    target,
    targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers
  )
  proxyMap.set(target, proxy)
  return proxy
}

ref

相比于reactiveref原始值提供了响应性,同时由于在创建过程中用reactive对对象进行了处理,使得ref也可以对对象进行代理:

const num = ref(0)
num.value = 1 // 这是具有响应性的替换

const obj = ref({ name: 'dragon' })
obj.value = { name: 'other name' } // 这是具有响应性的替换
obj.value.name = 'other name' // 这也是具有响应性的替换

所以理论上来说,在能承受.value的心智负担的前提下,对于原始值和对象的情况,我们都可以使用ref进行处理。

那么,ref这个API的响应式是如何实现的呢,其原理是什么呢?

ref 的依赖收集和触发原理

要解释ref的响应式原理,本质就是解释当参数为原始值时,ref是如何进行依赖收集和触发的。对于参数为对象的情况,其响应式原理本质是reactive的响应式原理(见下一部分解释)。

ref源码中,会调用createRef函数,而该函数则会返回RefImpl类的实例作为响应式ref对象,通过查看这部分源码,我们可以发现,ref的响应式是通过**gettersetter**实现的(类似于Vue2响应式的原理Object.defineProperty):

class RefImpl<T> {
  // 省略成员变量
	// 省略构造函数(一会我们会见到的)
	
  // getter:进行依赖收集
  get value() {
    trackRefValue(this) // 依赖收集
    return this._value
  }
	
  // setter:进行依赖触发
  set value(newVal) {
    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) // 依赖触发
    }
  }
}

依赖收集

依赖收集所依赖的核心函数是trackRefValue,该函数的作用是:把依赖这个ref的副作用存储到refdep集合中,同时把refdep存到副作用的deps数组中,该函数实际上能够ref存储所有依赖它的副作用。如果能够理解这个函数和后面的triggerRefValue函数的真正作用,理解computed的源码也就不是难事了。

首先通过toRaw获得原始的ref对象(因为这里的ref有可能是通过readonlyreactive等代理包装过后的,见computed.ts56行注释,但这不是重点),然后通过trackEffects进行ref的依赖收集。

export function trackRefValue(ref: RefBase<any>) {
  if (shouldTrack && activeEffect) {
    ref = toRaw(ref)
    if (__DEV__) {
      trackEffects(ref.dep || (ref.dep = createDep()), {
        target: ref,
        type: TrackOpTypes.GET,
        key: 'value'
      })
    } else {
      trackEffects(ref.dep || (ref.dep = createDep()))
    }
  }
}

trackEffects函数笔者没有完全搞懂,shouldTracknewTracked应该是用来解决副作用嵌套的情况,但是具体逻辑不清楚。

该函数主要做的事情就是把当前活跃的副作用(activeEffect,当副作用调用run方法时会将activeEffect设为自己)添加到ref的副作用集合中,并把dep添加到activeEffectdeps数组中(deps记录这个副作用所有的依赖)。

export function trackEffects(
  dep: Dep,
  debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
  let shouldTrack = false
  if (effectTrackDepth <= maxMarkerBits) {
    if (!newTracked(dep)) {
      dep.n |= trackOpBit // set newly tracked
      shouldTrack = !wasTracked(dep)
    }
  } else {
    // Full cleanup mode.
    shouldTrack = !dep.has(activeEffect!)
  }

  if (shouldTrack) {
    dep.add(activeEffect!)
    activeEffect!.deps.push(dep)
    if (__DEV__ && activeEffect!.onTrack) {
      activeEffect!.onTrack(
        extend(
          {
            effect: activeEffect!
          },
          debuggerEventExtraInfo!
        )
      )
    }
  }
}

总结来说,依赖收集这一步做了两件事:

  1. 把依赖ref的副作用存储到refdep集合中,这是为了ref可以触发所有依赖它的副作用
  2. refdep存储到副作用的deps数组中,这是为了进行清理依赖的工作(具体逻辑位于effect.cleanupEffect),从而保证当依赖发生改变时,副作用依然能够被正确触发

依赖触发

依赖触发的核心逻辑写在triggerRefValue函数中,这个函数不仅被ref所使用,在computed也被使用(computed依赖收集和触发的逻辑和ref的逻辑大致相同),这个函数的名字就告诉了我们它的作用,即触发所有依赖这个ref的副作用(为函数和变量起个易懂的名字真的很重要)。

依赖触发首先通过triggerRefValue函数获得refdep集合(也就是所有依赖这个ref的副作用),并通过triggerEffects函数进行依赖的触发。

export function triggerRefValue(ref: RefBase<any>, newVal?: any) {
  ref = toRaw(ref)
  const dep = ref.dep
  if (dep) {
    if (__DEV__) {
      triggerEffects(dep, {
        target: ref,
        type: TriggerOpTypes.SET,
        key: 'value',
        newValue: newVal
      })
    } else {
      triggerEffects(dep)
    }
  }
}

对于每一个元素都会调用triggerEffect函数,重新调用effect

之所以要使用两个for loop,是因为副作用函数可能依赖计算属性,所以在触发副作用函数前,需要先触发计算属性的执行,以保证计算属性的值已经得到更新。因此我们使用第一个循环处理所有的计算属性,第二个循环处理所有的副作用函数,以确保它们的执行顺序是正确的。

export function triggerEffects(
  dep: Dep | ReactiveEffect[],
  debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
  // spread into array for stabilization
  const effects = isArray(dep) ? dep : [...dep]
  // 处理计算属性
  for (const effect of effects) {
    if (effect.computed) {
      triggerEffect(effect, debuggerEventExtraInfo)
    }
  }
  // 处理非计算属性的副作用函数
  for (const effect of effects) {
    if (!effect.computed) {
      triggerEffect(effect, debuggerEventExtraInfo)
    }
  }
}

triggerEffect首先判断副作用不是当前活跃副作用,或是允许递归,然后使用effect的调度器或run方法进行执行,从而触发副作用。

function triggerEffect(
  effect: ReactiveEffect,
  debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
  if (effect !== activeEffect || effect.allowRecurse) {
    if (__DEV__ && effect.onTrigger) {
      effect.onTrigger(extend({ effect }, debuggerEventExtraInfo))
    }
    if (effect.scheduler) {
      effect.scheduler()
    } else {
      effect.run()
    }
  }
}

ref 是如何处理对象的?

在官方文档中,对于这一点是这样解释的:如果将一个对象赋值给 ref,那么这个对象将通过 reactive() 转为具有深层次响应式的对象。

这个解释言简意赅,ref源码处理对象的核心正在于使用reactive对对象进行转化:

class RefImpl<T> {
  // 省略成员变量
	
  // 刚才省略的构造函数
  constructor(value: T, public readonly __v_isShallow: boolean) {
    this._rawValue = __v_isShallow ? value : toRaw(value)
    this._value = __v_isShallow ? value : toReactive(value)
  }

  // 省略 getter 和 setter
}

export const toReactive = <T extends unknown>(value: T): T =>
  isObject(value) ? reactive(value) : value

在上面RefImpl类的构造函数中我们可以看到,对于入参value,会调用toReactive函数把value进行一次转换,对于是object类型的value,会使用reactive对值进行包裹,从而得到一个响应式对象。

如图所示,入参value{ name: 1 },而this._value则是经过reactive包裹的proxy对象,经过这样的处理,ref对象的value实际上就变成了一个响应式对象。

image-20230414212818423

多说两句

说到这里,其实我们可以顺便介绍一下shallowRef API的原理(虽然这个API开发中也并不常见)。

所谓shallowRef,即是ref的浅层形式,这个浅层仅针对对象的情况,当参数为原始值时,两个API效果是一样的。

// 官方文档的例子:
const state = shallowRef({ count: 1 })

// 不会触发更改,不再是响应式更改
state.value.count = 2

// 会触发更改
state.value = { count: 2 }

这个API的实现原理我们可以从上面RefImpl的构造函数中找到:

this._value = __v_isShallow ? value : toReactive(value)

当调用的APIshallowRef时,__v_isShallow的值为true,因此不再会调用toReactive,所以state.value不再是响应式代理,自然只对value的变化具有响应性,而对更深层次的变化不具有响应性。

依赖的触发

现在我们明白了ref是如何处理对象的,那么这种情况下的依赖的收集和触发,和原始值情况下,是一样的吗,又或者说,对于下面的代码,响应性工作的原理是相同的吗?

const obj = ref({ name: 'dragon' })
// 这两行代码都具有响应性,那原理一样吗?
obj.value = { name: 'other name' }
obj.value.name = 'other name'

实际上,答案是显然的,前者是通过refgettersetter实现的响应性,而后者则是通过reactive的响应性代理实现的响应性。

对于第一行代码,我们改变了obj.value的引用,这个操作会触发refsetter,触发对应的依赖触发逻辑。

而对于第二行代码,我们没有改变obj.value的引用,但是我们改变了obj.valuename属性,上面已经提过,obj.value实际上是一个响应式代理,因此更改name属性,会触发响应式代理的依赖的相关逻辑。

toRef

toRef的主要作用是基于响应式对象的属性,创建一个ref,同时ref与响应式对象之间保持同步。

通过查看源码,我们发现这个API除了支持source参数为响应式对象之外,还支持是函数,但是由于不常用因此不做介绍。

export function toRef(
  source: Record<string, any> | MaybeRef,
  key?: string,
  defaultValue?: unknown
): Ref {
  if (isRef(source)) {
    return source
  } else if (isFunction(source)) {
    return new GetterRefImpl(source) as any
  } else if (isObject(source) && arguments.length > 1) {
    return propertyToRef(source, key!, defaultValue) // 我们关注的情况
  } else {
    return ref(source)
  }
}

对于参数是对象的情况,源码调用了propertyToRef函数,其核心是使用sourcekey创建ObjectRefImpl类对象

function propertyToRef(source: object, key: string, defaultValue?: unknown) {
  const val = (source as any)[key]
  return isRef(val)
    ? val
    : (new ObjectRefImpl(
        source as Record<string, any>,
        key,
        defaultValue
      ) as any)
}

通过查看ObjectRefImpl类的实现,我们可以发现Vue劫持了valuegettersetter,访问源对象的值。那么通过这种方式,Vue是如何实现toRef对象和源对象的同步的呢?

考虑副作用使用到了这个ref对象的情况:副作用由于使用到了ref.value,因此会访问到源对象的属性,从而触发依赖的收集。此时假如响应式对象对应属性更改,则会触发依赖,从而重新执行该副作用;反之如果修改了ref.value,则会修改源对象的属性,因此也会触发源对象的依赖。所以也就实现了同步。

class ObjectRefImpl<T extends object, K extends keyof T> {
  public readonly __v_isRef = true

  constructor(
    private readonly _object: T,
    private readonly _key: K,
    private readonly _defaultValue?: T[K]
  ) {}

  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
  }

  get dep(): Dep | undefined {
    return getDepFromReactive(toRaw(this._object), this._key)
  }
}

toRefs

toRefs的原理和toRef差不多,本质就是对每个key都调用propertyToRef,相当于多个toRef调用。

export function toRefs<T extends object>(object: T): ToRefs<T> {
  if (__DEV__ && !isProxy(object)) {
    console.warn(`toRefs() expects a reactive object but received a plain one.`)
  }
  const ret: any = isArray(object) ? new Array(object.length) : {}
  for (const key in object) {
    ret[key] = propertyToRef(object, key) // 这一步相当于 toRef
  }
  return ret
}