前言
本篇解析参阅 vue3源码、崔大的mini-vue、霍春阳大佬的《Vuejs设计与实现》尽可能记录我的Vue3源码阅读学习过程。我会结合自己的思考,提出问题,找到答案,附在每一篇的底部。希望大家能在我的文章中也能一起学习,一起进步,有 get 到东西的可以给作者一个小小的赞作为鼓励吗?谢谢大家!
手写简易vue3 Refs相关 & Computed相关 实现
Refs
我们都知道 Proxy 是使用来代理对象的,那对于原始值比如 (Boolean, Number, BigInt, String, Symbol, undefined 和 null 等类型的值) 我们没有办法将它变成响应式,那么这时候就有了 ref
注意:这里的 ref 和我们在 vue2 里常用的 ref 是有区别的
初步实现
对于 ref 的定义如下:
我们需要有一个 ref方法 ,可以接收一个值,这个值
- 是对象,要用 reactive 将它变成响应式对象
- 是原始值,那么返回一个响应式的 ref对象
这个 ref对象 有 value 属性,也就是说通过 ref.value
能拿到对应的值
我们来看测试用例:
it('happy path', () => {
// 这里用 const 是因为他是对象
// 创建一个 ref 对象 value 是 1
const a = ref(1)
// 访问 a.value 他应该得到 1
expect(a.value).toBe(1)
});
复制代码
条件应该是非常明确的了,我们可以直接创建一个 Ref 实例:
class RefImpl {
private _value: any
constructor(value) {
// 如果 value 是对象 需要处理成 reactive
this._value = convert(value)
}
// 访问 value 属性就直接返回这个 _value
get value() {
return this._value
}
}
// 判断新值是不是对象 是的话要 reactive 一下,否则直接给
function convert(value) {
// isObject 其实就是 return value !== null && typeof value === 'object'
return isObject(value) ? reactive(value) : value
}
export function ref(value) {
// 调用 ref 方法我们直接生成一个 实例
return new RefImpl(value)
}
复制代码
经过上边的实现以后我们只是实现了一个 .value 可以返回值的 ref对象
,但是这时候还不是响应式的
ref 对象实现响应式
接下来我们要实现 ref对象 响应式的功能,响应式肯定是我们触发 .value 的一个修改之后会有其他的影响
我们可以简单通过以下测试用例来具体化我们要实现的功能:
it('should be reactive', () => {
// 创建一个 ref 对象 value 是 1
const a = ref(1)
let dummy
let calls = 0
// 创建一个副作用函数,他会自己执行一次,这时候 dummy 应该等于 1,calls由于函数被调用也会 + 1
effect(() => {
calls++
dummy = a.value
})
expect(calls).toBe(1)
expect(dummy).toBe(1)
// 到这里 我们要操作 a.value 也即是触发 set value
a.value = 2
// 那 副作用函数就应该被 trigger 触发,两个变量都会更新
expect(calls).toBe(2)
expect(dummy).toBe(2)
// 优化:赋值和之前的一样 不应该触发响应式
a.value = 2
expect(calls).toBe(2)
expect(dummy).toBe(2)
});
复制代码
我们总结一下通过上面的测试用例我们需要注意哪些点?
- a.value = 2 应该触发 ref实例 的 set value
- set value 要检查是不是一个新的值
- set value 需要触发 trigger 来让副作用函数执行
- 有 trigger 那就肯定需要 track 来收集依赖
那么我们先对原有的 track 和 trigger 来针对 ref 做一下处理拿到以下这两个函数:
export function trackEffects(dep) {
// 已经在 dep 中就不用 add 了
if (dep.has(activeEffect)) return
dep.add(activeEffect) // 把对应的 effect 实例加入 set 里
activeEffect.deps.push(dep)
}
export function triggerEffects(dep) {
for (const effect of dep) {
if (effect.scheduler) {
effect.scheduler()
} else {
effect.run()
}
}
}
复制代码
细心的你可以发现这两个方法中的代码 其实就是原有 tarck 和 原有trigger 中的代码,一模一样没有任何变化,我们只是将这两段代码单独抽离出来复用。
我们两个新的方法只接收一个依赖 dep (set类型)
接下来我们修改刚才创建的 class RefImp,新增一个 trackRefValue 方法:
class RefImpl {
// 保留原有的 value,需要用来比较 set 的新的值,因为_value有可能是响应式的对象所以不能用 _value 来比较
private _rawValue: any
private _value: any
// 在 track 中收集依赖,在 trigger 中触发依赖
public dep
constructor(value) {
this._rawValue = value
// 如果 value 是对象 需要处理成 reactive
this._value = convert(value)
// 初始化 Set
this.dep = new Set()
}
get value() {
// 去收集依赖,但是在那之前要判断是否有 effect
trackRefValue(this)
return this._value
}
set value(newValue) {
// 语义化 Obejct.js 即没有变化 直接 return
if (!hasChanged(this._rawValue, newValue)) return
// 一定是先修改 value 再去 调用 trigger 才能保证_value准确
this._rawValue = newValue
this._value = convert(newValue)
triggerEffects(this.dep)
}
}
// ref get 依赖收集
function trackRefValue(ref) {
// isTraking 其实就是 shouldTrack && activeEffect !== undefined
if (isTracking()) trackEffects(ref.dep)
}
复制代码
通过上面的一个实现,我们也顺带实现了对于对象的响应式
,我们已经可以直接通过以下的测试用例了
it('should make nested properties reactive', () => {
const a = ref({
count: 1
})
let dummy
// 调用 副作用函数 给 dummy 赋值 注意是 a.value.count
effect(() => {
dummy = a.value.count
})
expect(dummy).toBe(1)
// 修改 a.value.count 也能触发响应式,因为这时候的 this._value 实际是通过 reactive() 创建的响应式对象
a.value.count = 2
expect(dummy).toBe(2)
});
复制代码
isRef 和 unRef(脱ref)
之前有 isReactive 、 isReadonly ,那现在要有 isRef 也不是很过分嘛!
isRef 的定义:判断当前的值是不是一个 ref 对象
实现方法非常简单:
class RefImpl {
// ...
// 定义一个 __v_isRef 属性即可
public __v_isRef = true
// constructor() ....
}
// 判断是不是一个 ref
export function isRef(ref) {
// 如果传入的不是一个 ref对象 那他是没有__v_isRef属性的,那就是 undefined ,所以需要 !! 将它转成一个 boolean 类型
return !!ref.__v_isRef
}
复制代码
unRef:也是我们常说的 脱ref,他的一个作用实际上就是当我们传入一个 ref对象 的时候直接返回他的value
,如果不是 ref对象 就直接返回传入的值。
unRef(脱ref)的应用场景是什么?为什么设计这个东西?
// 是 ref 返回 ref.value 否则 直接返回
export function unRef(ref) {
return isRef(ref) ? ref.value : ref
}
复制代码
可以看到这个 unRef 的实现实际上是非常的简单,就是利用上边热乎的 isRef 来实现的。
ProxyRefs (自动脱ref)
我们实现完了以上的内容后,我们发现每次都需要 .value 来拿到对应的值,真的很麻烦很麻烦,不可能我写 template 的时候也让我 .value 吧!!也太坑啦!!!然后呢我们就有了 ProxyRefs
我们先通过测试用例来看一下我们需要实现什么:
it('proxyRefs', () => {
// 创建一个用户 age字段是 ref对象
const user = {
age: ref(18),
name: "Ben"
}
// 通过一个 proxyRefs 方法创建 proxyUser
const proxyUser = proxyRefs(user)
// 不影响 user.age.value 的使用
expect(user.age.value).toBe(18)
// proxyUser 可以直接 .age 拿到数值而不用 .value 了
expect(proxyUser.age).toBe(18)
expect(proxyUser.name).toBe("Ben")
// 设置 proxyUser.age 的值变成为 非ref的原始值
proxyUser.age = 20
// user 的 age 仍然是ref对象,能够从 .value 获取到更新
expect(user.age.value).toBe(20)
// 依旧能正常访问更新后的 age
expect(proxyUser.age).toBe(20)
// 又给 proxyUser.age 设置成一个 ref对象
proxyUser.age = ref(25)
// user 的 age 仍然是ref对象,能够从 .value 获取到更新
expect(user.age.value).toBe(25)
// 依旧能正常访问更新后的 age
expect(proxyUser.age).toBe(25)
});
复制代码
通过上面的测试用例,我们不难发现,通过 ProxyRefs方法 获取到的对象不再需要通过 .value 来获取值,且修改对象的 ref对象 属性的类型也不会有影响。
我们来写一下 ProxyRefs:
export function proxyRefs(objectWithRefs) {
return new Proxy(objectWithRefs, {
// 触发 get 的时候我们帮它 脱ref
get(target, key) {
return unRef(Reflect.get(target, key))
},
set(target, key, newValue) {
// 如果当前已经是 ref 而传入的 值不是 ref,那么要给 .value 赋值
if (isRef(target[key]) && !isRef(newValue)) {
return target[key].value = newValue
} else {
// 否则直接设置成 newValue即可
return Reflect.set(target, key, newValue)
}
}
})
}
复制代码
实现其实也是借助我们已经实现了的 unRef 来做脱ref的功能,要注意的是 set 可能有些绕,大家结合测试用例消化一下吧
toRefs
思来想去,我们还想实现一个 传入对象所有的属性都变成 ref对象
,我们该怎么实现呢??
其实很简单,就是遍历对象的 key 然后挨个 ref 即可:
export function toRefs(obj) {
const ret = {}
// 用 for...in 遍历对象
for (const key of obj) {
ret[key] = ref(obj, key)
}
return ret
}
复制代码
这样就可以啦!!是不是很简单呢!!!
Computed
接下来到了我们比较好奇的 computed 的实现了!!我们知道 computed 他是具有缓存功能
的,且他应该是懒执行
的。
我们通过测试用例来看看我们需要做什么吧:
it('should compute lazily', () => {
// lazy 懒执行 (不调用 cValue.value 我就不触发)
const value = reactive({
foo: 1
})
const getter = jest.fn(() => {
return value.foo
})
// 创建 computed实例
const cValue = computed(getter)
// 懒执行 getter不应该被调用
expect(getter).not.toHaveBeenCalled()
// 访问 cValue.value 了要触发一次了
expect(cValue.value).toBe(1)
expect(getter).toHaveBeenCalledTimes(1)
// 访问这个属性不应该再次触发 getter 缓存!!
cValue.value
expect(getter).toHaveBeenCalledTimes(1)
// 修改属性 需要触发
value.foo = 2
expect(getter).toHaveBeenCalledTimes(1)
// 访问这个属性不应该再次触发 getter 还是缓存!!
cValue.value
expect(getter).toHaveBeenCalledTimes(2)
});
复制代码
我们看到这个测试用例就已经很好的诠释了 computed 的两大特点:
- 懒执行
- 缓存
接下来我们来实现 computed :
class ComputedRefImpl {
private _getter: any
// 缓存变量 如果是 true 的时候说明有修改 需要更新 value
private _dirty: boolean = true
private _value: any
// 保存 ReactiveEffect 实例
private _effect: any
constructor(getter) {
this._getter = getter
// 利用第二个参数 scheduler 的特性,执行一次 getter 后触发 trigger 的时候只执行 scheduler
this._effect = new ReactiveEffect(getter, () => {
// 当 dirty 是 false 的时候 改为 true 即表示已更新过了
if (!this._dirty) this._dirty = true
})
}
get value() {
// dirty 不是true 说明value没有改变 直接返回value
if (this._dirty) {
this._dirty = false
// effect.run() 实际就是触发了 传入的 getter
this._value = this._effect.run()
}
return this._value
}
}
// 返回一个 ComputedRefImpl 实例
export function computed(getter) {
return new ComputedRefImpl(getter)
}
复制代码
这样我们的 computed 就实现完成了,要注意两个细节:
- 我们是通过 dirty 这个 flag 来判断数据是否被更新了
- effect 的 scheduler 参数这时候就显得特别妙了,也就是说 computed 也是 scheduler 的重点应用场景
问题:Vue2 中的 ref 和 Vue3 中的 ref 是同一个东西吗?有什么区别?
实际上在 Vue2 中的 ref 是一个 attribute(属性)
,它的作用就是用来标记一个元素/子组件,让你可以在父组件中使用 $refs
来找到这个元素/组件,那么他是这么使用的:
<!-- `vm.$refs.p` 你就会得到 p 这个dom -->
<p ref="p">hello</p>
<!-- `vm.$refs.child` 你就会得到 chld-component 的实例 -->
<child-component ref="child"></child-component>
复制代码
而到了 Vue3 我们依然能够这样使用 ref
。(来作为一个属性,用 $refs 拿到引用)
但 Vue3 还有一个 ref。这个 ref 实际上是由一个叫做 Refs 的响应式 api 携带的方法
,通过 ref(xxx) 来让 xxx(原始值) 变成一个 ref对象
且具备响应式
的功能。(如果 xxx 是一个对象,那么还是会调用 reactive 来变成一个代理对象)
这个 ref对象
仅有一个 .value
property
我们是这样使用的:
const count = ref(0)
console.log(count.value) // 0
count.value++
console.log(count.value) // 1
复制代码
问题:unRef(脱ref)的应用场景是什么?为什么设计这个东西?自动脱ref又是啥?
因为 ref 对象只能通过 ref.value 来访问对应的值,这会给我们带来非常严重的心智负担
我们使用 reactive 生成的响应式对象的时候:
const obj = reactive({ foo: 1, bar: 2 })
obj.foo // 1
obj.bar // 2
复制代码
而当我们使用 ref 生成的响应式数据的时候:
const a = ref(1)
a.value // 1
const b = {...toRefs({ foo: 1, bar: 2 })}
b.value.foo // 1
b.value.bar // 2
复制代码
那一比较你就很明显感觉到有一股很沉重的压力啊,什么?压力还不够大??
那。。。
<!-- 正常的 -->
<p>{{ foo }} / {{ bar }}</p>
<!-- ref -->
<p>{{ foo.value }} / {{ bar.value }}</p>
复制代码
现在呢?!!
所以说脱ref的存在就是为了降低用户使用的一个心智负担。
这也是为什么有了 自动脱ref
有了自动脱ref的能力后,用户在模板中使用响应式数据的时候,就不需要关系一个值是否是 ref 了。