🚀 实现 computed 和 proxyRefs 功能 且利用 Jest 测试
山不在高,有仙则名。水不在深,有龙则灵。 —— 刘禹锡《陋室铭》
前言
上篇对ref实现的暂告一段落,还没看过上一篇的看这里🎉
接下来我们来实现一个简单的计算属性computed,并且利用 Jest 测试它的变化。
本篇文章内容包括:
- 讲解 isRef 的实现思路和代码实现
- 讲解 unref 的实现思路和代码实现
- 讲解 proxyRefs 的实现思路和代码实现
- 讲解 computed 的实现思路和代码实现
🤣 如有错漏,请多指教 ❤
实现 isRef
检查值是否为一个 ref 对象。
以下是测试用例:
test('isRef', () => {
expect(isRef(ref(1))).toBe(true)
expect(isRef(0)).toBe(false)
expect(isRef(1)).toBe(false)
// an object that looks like a ref isn't necessarily a ref
expect(isRef({ value: 0 })).toBe(false)
})
我们可以定义一个isRef函数,入参传入一个对象,内部通过有意的访问一个只有 ref 才有的公有属性,来判断这个对象是否是ref对象。
class RefImpl<T> {
...
public __v_isRef = true // 标识是ref对象
constructor(value: any) {
...
}
...
}
// 检查值是否为一个 ref 对象。
export function isRef(ref: any) {
return !!(ref && ref.__v_isRef)
}
(ref && ref.__v_isRef)有可能为undefined,所以我们加上!!让undefined转为boolean类型。
实现 unref
如果参数是一个 ref,则返回内部值,否则返回参数本身。这是 val = isRef(val) ? val.value : val 的语法糖函数。
以下是测试用例:
test('isRef', () => {
expect(isRef(ref(1))).toBe(true)
expect(isRef(0)).toBe(false)
expect(isRef(1)).toBe(false)
// an object that looks like a ref isn't necessarily a ref
expect(isRef({ value: 0 })).toBe(false)
})
基本的实现原理就是val = isRef(val) ? val.value : val
export function unref(ref: any) {
return isRef(ref) ? ref.value : ref
}
实现 proxyRefs
虽然 proxyRefs并没有在官方文档给出介绍和api用法,但是它的实际作用是在<template></template>模板里面解构出ref的value,这样我们在模板里面就不需要再书写.value来获取ref的值了。它同时兼容reactive对象的传入。
测试用例
test('proxyRefs', ()=>{
const user = {
age: ref(10),
name: 'ghx'
}
const original = {
k: 'v'
}
const r1 = reactive(original)
// 传入reactive对象
const p1 = proxyRefs(r1)
// objectWithRefs对象 (带ref 的object)
const proxyUser = proxyRefs(user)
expect(p1).toBe(r1)
expect(user.age.value).toBe(10)
expect(proxyUser.age).toBe(10)
expect(proxyUser.name).toBe('ghx')
proxyUser.age = 20
expect(proxyUser.age).toBe(20)
expect(user.age.value).toBe(20)
proxyUser.age = ref(10)
proxyUser.name = 'superman'
expect(proxyUser.age).toBe(10)
expect(proxyUser.name).toBe('superman')
expect(user.age.value).toBe(10)
})
特点
-
如果入参是reactive对象,那么直接返回reactive。
-
如果入参是子属性值带ref对象的普通对象,那么返回一个proxy对象,其中的属性值是ref对象的属性,通过调用
unref函数获取实际的值(.value)。
set操作我们也得区分两种情况:
赋值操作状态下
- 如果属性为ref对象,并且值为普通类型,那么需要把值赋给ref对象的.value属性。
const user = {
age: ref(10),
name: 'ghx'
}
const proxyUser = proxyRefs(user)
proxyUser.age = 20
expect(proxyUser.age).toBe(20)
expect(user.age.value).toBe(20)
- 否则其他情况下都应该把值直接赋值给属性。
proxyUser.age = ref(10)
proxyUser.name = 'superman'
expect(proxyUser.age).toBe(10)
expect(proxyUser.name).toBe('superman')
expect(user.age.value).toBe(10)
基本实现
export function proxyRefs<T extends object>(obj: T){
return isReactive(obj)
? obj
: new Proxy<any>(obj, {
get(target, key){
// unref已经处理了obj是否ref的情况所以我们不需要自己if处理,如果是,返回.value,如果不是,直接返回值
return unref(Reflect.get(target, key))
},
set(target, key, value){
// 因为value为普通值类型的情况特殊,要把value赋值给ref的.value
if (isRef(target[key]) && !isRef(value)) {
target[key].value = value
return true
} else {
return Reflect.set(target, key, value)
}
}
})
}
实现 computed
在<template></template>中放入过多的计算逻辑会让模板难以维护
<h1>My book: </h1>
<div>{{ghx.books.length > 10 ? '多' : '少'}}<div>
<div>{{ghx.books.length > 10 ? '多' : '少'}}<div>
<div>{{ghx.books.length > 10 ? '多' : '少'}}<div>
<div>{{ghx.books.length > 10 ? '多' : '少'}}<div>
<div>{{ghx.books.length > 10 ? '多' : '少'}}<div>
<div>{{ghx.books.length > 10 ? '多' : '少'}}<div>
这时候的template就不单单是简单的,它包含了一些逻辑,让整体上的template看起来更加复杂,所以我们为了解决这个问题,我们需要使用computed。
特点
- 具有对数据的响应依赖关系的缓存功能。computed的依赖没有更新的情况下,再次发生ref对象的get操作并不会导致数据的重新计算。
- 懒计算。computed的依赖被更新,也不会立即重新计算结果,而是当computed返回的ref对象发生get操作的时候才会计算结果。
基本用法
// 第一种:
const count = ref(1)
const plusOne = computed(() => count.value + 1)
console.log(plusOne.value) // 2
plusOne.value++ // 错误
// 第二种:
const count = ref(1)
const plusOne = computed({
get: () => count.value + 1,
set: val => {
count.value = val - 1
}
})
plusOne.value = 1
console.log(count.value) // 0
computed可以接收一个具有get和set方法的对象作为参数,也可以接收一个get方法作为参数,都返回一个ref对象。
这就要用到函数重载了。
- 当computed只接受一个get方法的时候,那么该computed为只读,写入数据将会得到一个错误
- 当computed接受具有get和set方法的对象的时候,那么该computed为可读写
我们以通关的形式来实现computed,总共有两关,第一关简单实现computed类,第二关实现computed的懒计算和缓存功能
- 第一关的测试用例
it('should return updated value', () => {
const value = reactive({ foo: 1 })
const cValue = computed(() => value.foo)
expect(cValue.value).toBe(1)
})
创建一个computed函数,顺便定义get和set的类型,对不同函数签名进行重载
type ComputedGetter<T> = (...args: any[])=> T
// v 是 要带赋值的值
type ComputedSetter<T> = (v: T) => void
interface WritableComputedOptions<T> {
get: ComputedGetter<T>;
set: ComputedSetter<T>;
}
export function computed<T>(option: WritableComputedOptions<T>): any
export function computed<T>(getter: ComputedGetter<T>): ComputedRefImpl<T>
// 实现函数签名
export function computed<T>(getterOrOption: ComputedGetter<T> | WritableComputedOptions<T>){
let getter: ComputedGetter<T>
let setter: ComputedSetter<T>
if (isFunction(getterOrOption)) {
getter = getterOrOption
setter = () => console.error('错误, 因为是getter只读, 不能赋值')
} else {
getter = getterOrOption.get
setter = getterOrOption.set
}
return new ComputedRefImpl(getter, setter)
}
接着我们来实现ComputedRefImpl类。
我们希望的computed,它是可以使用.value来访问值的,并能控制value的读写操作,所以我们在内部定义一个私有属性_value,然后通过取值函数getter和存值函数setter控制我们的value,当用户触发get操作的时候,我们会传入的getter函数,把返回值传回给_value,当用户触发set操作的时候,set value(newValue)的newValue会传给用户传进来的setter函数。
export class ComputedRefImpl<T> {
private _value!: T
public _getter: ComputedGetter<T>
constructor(
getter: ComputedGetter<T>,
private setter: ComputedSetter<T>
) {
this._getter = getter
}
get value() {
this._value = this.getter()
return this._value
}
set value(newValue: T) {
this.setter(newValue)
}
}
到此上面的测试用例就能跑通了。
- 第二关的测试用例
看起来有点复杂,我们通过注释讲解一下每一步吧。
it('should compute lazily', () => {
const value = reactive({ foo: 1 }) // 创建一个reactive对象
const getter = jest.fn(() => value.foo) // 通过jest.fn()创建一个模拟函数,后续会检测被调用该函数次数
const cValue = computed(getter) // 创建一个computed对象,并传入getter函数
// lazy功能
expect(getter).not.toHaveBeenCalled() // 因为还没触发cValue的get操作,所以getter是不会被调用的。
expect(cValue.value).toBe(1) // cValue的get操作被触发,getter执行
expect(getter).toHaveBeenCalledTimes(1) // getter被调用一次
// 缓存功能
// should not compute again
cValue.value // cValue的get操作被触发,又因为value.foo并没有发生改变
expect(getter).toHaveBeenCalledTimes(1) // 这里的getter还是被调用了一次
// should not compute until needed
value.foo = 2 // 这里的value.foo发生了改变,但是cValue的get操作还没被触发
expect(getter).toHaveBeenCalledTimes(1) // 所以这里getter仍然只会被调用一次
// now it should compute
expect(cValue.value).toBe(2) // 这里的cValue的get操作被触发,getter执行
expect(getter).toHaveBeenCalledTimes(2) // 这里getter被调用了两次
// should not compute again
cValue.value // cValue的get操作被触发,又因为value.foo并没有发生改变
expect(getter).toHaveBeenCalledTimes(2) // 这里的getter还是被调用了两次
})
要实现缓存功能,依赖没有发生改变不能让computed重新计算,我们就需要在get操作的时候,添加一把锁,_dirty,dirty有肮脏的意思,当内部依赖的数据发生改变的时候,就说明肮脏了,_dirty等于true,这时候才能重新计算。
我们声明_dirty为类的私有成员变量。
export class ComputedRefImpl<T> {
private _value!: T
private _dirty = true // 避免已经不是第一次执行get操作的时候再次调用compute
public _getter: ComputedGetter<T>
constructor(
getter: ComputedGetter<T>,
private setter: ComputedSetter<T>
) {
this._getter = getter
}
get value() {
// 如何给dirty重新赋值为true, 触发依赖,调用effect的scheduler()
if(this._dirty){
this._dirty = false
this._value = this.getter()
}
return this._value
}
set value(newValue: T) {
this.setter(newValue)
}
}
后来,为了收集computed的回调函数到依赖集中去,我们为ComputedRefImpl添加_effect,用于存储ReactiveEffect对象(ReactiveEffect到底是什么,在这里有讲到)。底层做了让track可以收集computed的回调函数这件事。
export class ComputedRefImpl<T> {
private _value!: T
private _dirty = true // 避免已经不是第一次执行get操作的时候再次调用compute
private _effect: ReactiveEffect // 进行依赖收集
constructor(
getter: ComputedGetter<T>,
private setter: ComputedSetter<T>
) {
this._effect = new ReactiveEffect(getter)
}
get value() {
if(this._dirty){
this._dirty = false
this._value = this._effect.run()
}
return this._value
}
set value(newValue: T) {
this.setter(newValue)
}
}
value.foo = 2的时候,会执行trigger,触发依赖,调用用户的getter函数。
export function triggerEffect(dep: Dep){
for (let effect of dep) {
if (effect.scheduler) {
effect.scheduler()
} else {
effect.run()
}
}
}
但是在这里我们并不希望用户的getter的函数被触发,我们要实现懒计算,就是让用户访问computed返回的ref的时候重新计算,所以为了value.foo = 2的时候不让trigger导致用户的getter函数被触发,并且希望_dirty重新变为true,我们通过scheduler()来把_dirty重新赋值为true。(重新变为true,在下一次computed的返回值ref函数触发get操作的时候才能调用effect.run()重新计算)
export class ComputedRefImpl<T> {
private _value!: T
private _dirty = true // 避免已经不是第一次执行get操作的时候再次调用compute
private _effect: ReactiveEffect // 进行依赖收集
constructor(
getter: ComputedGetter<T>,
private setter: ComputedSetter<T>
) {
this._effect = new ReactiveEffect(getter, ()=>{
// 把dirty重新赋值为true
if(!this._dirty){
this._dirty = true
}
})
}
get value() {
// 如何给dirty重新赋值为true, 触发依赖,调用effect的scheduler()
if(this._dirty){
this._dirty = false
this._value = this._effect.run()
}
return this._value
}
set value(newValue: T) {
this.setter(newValue)
}
}
到这里,测试用例就跑通了。
总结
最难最绕的就是computed的实现思路
整体来看computed的实现也是一个发布-订阅者模式。
最后@感谢阅读
完整代码
// In computed.ts
import { isFunction } from "../shared"
import { ReactiveEffect } from "./effect"
export class ComputedRefImpl<T> {
private _value!: T
private _dirty = true // 避免已经不是第一次执行get操作的时候再次调用compute
private _effect: ReactiveEffect // 进行依赖收集
constructor(
getter: ComputedGetter<T>,
private setter: ComputedSetter<T>
) {
this._effect = new ReactiveEffect(getter, ()=>{
// 把dirty重新赋值为true
if(!this._dirty){
this._dirty = true
}
})
}
get value() {
// 如何给dirty重新赋值为true, 触发依赖,调用effect的scheduler()
if(this._dirty){
this._dirty = false
this._value = this._effect.run()
}
return this._value
}
set value(newValue: T) {
this.setter(newValue)
}
}
type ComputedGetter<T> = (...args: any[])=> T
// v 是 赋值 = 右边的值
type ComputedSetter<T> = (v: T) => void
interface WritableComputedOptions<T> {
get: ComputedGetter<T>;
set: ComputedSetter<T>;
}
// 函数重载
export function computed<T>(option: WritableComputedOptions<T>): any
export function computed<T>(getter: ComputedGetter<T>): ComputedRefImpl<T>
// 实现签名
export function computed<T>(getterOrOption: ComputedGetter<T> | WritableComputedOptions<T>) {
let getter: ComputedGetter<T>
let setter: ComputedSetter<T>
// 区分入参是getter还是option
if(isFunction(getterOrOption)){
getter = getterOrOption
setter = () => console.error('错误, 因为是getter只读, 不能赋值')
}else{
getter = getterOrOption.get
setter = getterOrOption.set
}
return new ComputedRefImpl(getter, setter)
}
// In ref.ts
// 检查值是否为一个 ref 对象。
export function isRef(ref: any) {
return !!(ref && ref.__v_isRef)
}
// 如果参数是一个 ref,则返回内部值,否则返回参数本身。这是 val = isRef(val) ? val.value : val 的语法糖函数。
export function unref(ref: any) {
return isRef(ref) ? ref.value : ref
}
// 通常用在vue3 template里面ref取值,在template里面不需要.value就可以拿到ref的值
export function proxyRefs<T extends object>(obj: T){
return isReactive(obj)
? obj
: new Proxy<any>(obj, {
get(target, key){
// unref已经处理了是否ref的情况所以我们不需要自己if处理,如果是,返回.value,如果不是,直接返回值
return unref(Reflect.get(target, key))
},
set(target, key, value){
// 因为value为普通值类型的情况特殊,要把value赋值给ref的.value
if (isRef(target[key]) && !isRef(value)) {
target[key].value = value
return true
} else {
return Reflect.set(target, key, value)
}
}
})
}