Vue3硬核源码解析系列(5)ref源码解析
前言
本文是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)
上述代码现象:
-
页面初始化的时候显示“卖鱼强”
-
2s之后,name发生改变,变成了“狂飙强”。
通过现象与我们之前分析reactive的经验,这个我们可以将ref的实现分为三大模块
- 初始化
- 读取(依赖收集)
- 赋值(依赖触发)
初始化
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
的时候,会触发RefImpl的get 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中完成依赖收集的,事实也是如此。
而第一次ref的get是何时触发的呢?
答案是初始化时期的effect,effect触发后,内部fn被保存到activeEffect中,并触发fn,fn访问了name.value
,触发了ref的get行为,所以接下来我们前往RefImpl的get中,看看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
的值被修改,触发RefImpl的set 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的同时,获取当前ref的dep,并遍历dep中的依赖,依次执行,完成依赖触发。
小结
到此为止,我们基础类型场景的ref源码解读就结束了,我们简单做一下总结,
相比较于reactive,该场景下的逻辑要稍微简单一点,相关依赖**(effect fn)被实例本身的dep管理,没有构建复杂的WeakMap**对象。
ref与reactive的收集与触发的逻辑也不相同
- ref实际上是一个class RefImpl的实例
- 数据响应并不是通过proxy实现,而是通过class 的get set修饰符实现
- 依赖收集、触发并不是通过WeakMap实现,而是通过RefImpl实例中的变量dep实现
复杂类型场景
大家都知道ref不仅可以实现基础类型的响应式,还可以实现复杂类型的响应式,我们可以说ref是reactive的超集,那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,触发ref的get方法。
obj.value获取完成后,继续去获取obj.value.name,而name已经在初始化阶段,被toReactive处理成了proxy,所以接下来,会再触发reactive的get,来获取name
也就是说,读取阶段,实际上触发了2次get,一次是ref的get value,一次是proxy的get,进而完成了变量的读取。
get value() {
// trackRefValue(this) // 依赖收集,后面单独说
return this._value // 获取到proxy类型的{name: '张三'},进而再次触发proxy的get方法
}
赋值
若干时间后,obj.value.name发生set行为,首先依旧会触发ref的get,获取obj.value
,然后再触发reactive的set方法,完成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阶段进行完成,而通过上面的分析我们可以了解到,ref的get实际上其内部是两次get事件,所以我们分开来看。
ref的依赖收集(trackRefValue)
effect初始化阶段执行的时候,会读取obj.value.name
,首先会触发ref的get方法
get value() {
// 依赖收集函数 将当前ref本身传入方法
trackRefValue(this)
return this._value
}
ref的get方法触发了trackRefValue,会在当前ref的dep中收集到effect,此处逻辑与ref为基础类型的逻辑一致。
proxy的依赖收集(track)
ref的的get完成后,紧接着触发了reactive的get,然后get内部通过WeakMap再次完成依赖收集(相关逻辑参考Vue3硬核源码解析系列(3) reactive + effect源码解析)。
我们会发现,在该阶段,我们内部实际上触发了2次依赖收集,effect fn被ref收集的同时,也被proxy收集了。
依赖触发
因为ref内部是一个对象,所以赋值也存在多种方式,这依赖触发存在多种方式
对象属性触发依赖
obj.value.name = '狂飙强'
这种不会破坏RefImpl初始化阶段其内部构建的proxy,仅修改已有proxy内部变量的值。
首先触发的是obj.value的get行为(此时没有effet在执行,不会发生依赖收集)。然后ref的get函数返回proxy对象 {name:'卖鱼强'}
,紧接着触发proxy的set,并完成依赖触发(proxy的依赖触发请看这里Vue3硬核源码解析系列(3) reactive + effect源码解析)。
对象触发依赖
obj.value = {
name: '狂飙强'
}
第二种方式首先触发obj.value的set行为,同时替换掉ref的值,注意这会破坏RefImpl初始化构建的_value的proxy,进而导致WeakMap中已有的依赖关系断裂
然后执行triggerRefValue,触发,ref本身在get阶段收集了相关effect fn,。
effect fn被触发后,再次触发ref的get,proxy的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~
转载自:https://juejin.cn/post/7212910997778350136