ref API 源码解析
写在最前面
看Vue的源码(或其他项目的源码)如果遇到看不懂的地方,例如说某个函数或某个细节看不懂,可以去找ChatGPT,直接问它“请给我解释一下Vue源码中的xxx函数”、“xxx函数中xx变量的意义是什么”,有些地方不仅比博客都说的清楚很多,还会做一些拓展的讲解,真滴棒,妈妈再也不用担心我看不懂源码了。
引入
ref和reactive是vue的响应式相关的两个核心API,它们最关键的不同之处在于:
reactive:
reactive可以返回一个对象(对象、数组和Map、Set等集合类型)的响应式代理,却对原始值(JavaScript中八大类型中除了object之外的其他类型,即string、number等类型)无能为力。
通过查看源码得知,在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:
相比于reactive,ref为原始值提供了响应性,同时由于在创建过程中用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的响应式是通过**getter和setter**实现的(类似于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的副作用存储到ref的dep集合中,同时把ref的dep存到副作用的deps数组中,该函数实际上能够让ref存储所有依赖它的副作用。如果能够理解这个函数和后面的triggerRefValue函数的真正作用,理解computed的源码也就不是难事了。
首先通过toRaw获得原始的ref对象(因为这里的ref有可能是通过readonly或reactive等代理包装过后的,见computed.ts的56行注释,但这不是重点),然后通过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函数笔者没有完全搞懂,shouldTrack和newTracked应该是用来解决副作用嵌套的情况,但是具体逻辑不清楚。
该函数主要做的事情就是把当前活跃的副作用(activeEffect,当副作用调用run方法时会将activeEffect设为自己)添加到ref的副作用集合中,并把dep添加到activeEffect的deps数组中(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!
)
)
}
}
}
总结来说,依赖收集这一步做了两件事:
- 把依赖
ref的副作用存储到ref的dep集合中,这是为了ref可以触发所有依赖它的副作用 - 把
ref的dep存储到副作用的deps数组中,这是为了进行清理依赖的工作(具体逻辑位于effect.cleanupEffect),从而保证当依赖发生改变时,副作用依然能够被正确触发
依赖触发
依赖触发的核心逻辑写在triggerRefValue函数中,这个函数不仅被ref所使用,在computed也被使用(computed依赖收集和触发的逻辑和ref的逻辑大致相同),这个函数的名字就告诉了我们它的作用,即触发所有依赖这个ref的副作用(为函数和变量起个易懂的名字真的很重要)。
依赖触发首先通过triggerRefValue函数获得ref的dep集合(也就是所有依赖这个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实际上就变成了一个响应式对象。
多说两句
说到这里,其实我们可以顺便介绍一下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)
当调用的API为shallowRef时,__v_isShallow的值为true,因此不再会调用toReactive,所以state.value不再是响应式代理,自然只对value的变化具有响应性,而对更深层次的变化不具有响应性。
依赖的触发
现在我们明白了ref是如何处理对象的,那么这种情况下的依赖的收集和触发,和原始值情况下,是一样的吗,又或者说,对于下面的代码,响应性工作的原理是相同的吗?
const obj = ref({ name: 'dragon' })
// 这两行代码都具有响应性,那原理一样吗?
obj.value = { name: 'other name' }
obj.value.name = 'other name'
实际上,答案是显然的,前者是通过ref的getter和setter实现的响应性,而后者则是通过reactive的响应性代理实现的响应性。
对于第一行代码,我们改变了obj.value的引用,这个操作会触发ref的setter,触发对应的依赖触发逻辑。
而对于第二行代码,我们没有改变obj.value的引用,但是我们改变了obj.value的name属性,上面已经提过,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函数,其核心是使用source和key创建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劫持了value的getter和setter,访问源对象的值。那么通过这种方式,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
}