这一次来搞定Ref,然后是Computed,先思考几个问题:
- 为什么Ref要用.value。
- 在setup中使用ref需要.value,但是在template中不需要,是如何实现的。
- computed的缓存机制是什么。
Ref
reactive通过proxy实现,依靠proxy的handlers来实现依赖收集和依赖触发,依赖收集到全局变量targetMap中。
对于ref,我们创建类RefImpl,impl是implement的缩写。类中保存该ref的value值,以及dep。通过get value和set value的方式实现依赖收集和依赖触发。
之前实现的track函数中,同时包含了“从targetMap中取出dep”和“将activeEffect加入dep”两部分逻辑。为了在ref这里复用track,需要进一步封装。
export function track(target, key) {
if (!isTracking()) return;
// target -> key -> dep
let depsMap = targetMap.get(target);
if (!depsMap) {
depsMap = new Map();
targetMap.set(target, depsMap);
}
let dep = depsMap.get(key);
if (!dep) {
dep = new Set();
depsMap.set(key, dep);
}
// 封装
trackEffects(dep);
}
export function trackEffects(dep) {
if (dep.has(activeEffect)) return;
dep.add(activeEffect);
activeEffect.deps.push(dep);
}
对于trigger,之前同时包含了“获取dep”和“遍历dep”两部分逻辑,也需要拆开。
export function trigger(target, key) {
let depsMap = targetMap.get(target);
let dep = depsMap.get(key);
// 封装
triggerEffects(dep);
}
export function triggerEffects(dep) {
for (const effect of dep) {
if (effect.scheduler) {
effect.scheduler();
} else {
effect.run();
}
}
}
这样就可以在RefImpl类中调用trackEffects和triggerEffects了。
还有其他的要点:
- 一开始传入ref的参数是对象时,需要转换成reactive。
- setter中如果新值等于旧值,即把value设置成和原先一样的值,无需修改值,也不会触发trigger。
- 在hasChanged(对Object.is()的封装,返回值取反)中比较两个对象时,即使两个对象是同一个,proxy和原值也不会相等,所以需要保存_rawValue,通过原始值进行比较。
function trackRefValue(ref) {
if (isTracking()) {
trackEffects(ref.dep);
}
}
// 如果value是对象,需要转换成reactive
function convert(value) {
return isObject(value) ? reactive(value) : value;
}
class RefImpl {
private _value: any;
public dep;
private _rawValue: any;
public __v_isRef = true; // 用于isRef()
constructor(value) {
this._rawValue = value;
this._value = convert(value);
this.dep = new Set();
}
get value() {
// 做了进一步封装
trackRefValue(this);
return this._value;
}
set value(newValue) {
if (hasChanged(newValue, this._rawValue)) {
this._rawValue = newValue;
this._value = convert(newValue);
triggerEffects(this.dep);
}
}
}
isRef、unRef
可以看到,RefImpl类中保存了属性__v_isRef,通过该属性就可以判断是不是ref。如果是reactive或原始值,该属性为undefined,通过!!转为false。
export function isRef(ref) {
return !!ref.__v_isRef;
}
export function unRef(ref) {
return isRef(ref) ? ref.value : ref;
}
proxyRefs
先看单元测试:
it("proxyRefs", () => {
const user = {
age: ref(10),
name: "xiaohong",
};
const proxyUser = proxyRefs(user);
expect(user.age.value).toBe(10);
// 无需使用.value
expect(proxyUser.age).toBe(10);
expect(proxyUser.name).toBe("xiaohong");
proxyUser.age = 20;
expect(proxyUser.age).toBe(20);
// 修改proxy也改变原值
expect(user.age.value).toBe(20);
proxyUser.age = ref(10);
expect(proxyUser.age).toBe(10);
expect(user.age.value).toBe(10);
});
可见,模板中省略掉的.value,靠的就是proxyRefs。setup()会返回一个对象obj,该对象里可能包含了一些ref,那么就和上面的user是同样的objectWithRefs结构,然后objProxy = proxyRefs(obj),以后objProxy里的ref不用.value就能访问到,再通过某些方式省略掉外层的对象,直接在模板中使用属性。
要实现proxyRefs,返回proxy是肯定的,然后就是提供handlers。因为无需.value也能访问属性,和访问原始对象是一样的,所以需要使用unRef。
在setter中有一种情况,就是某个属性以前是ref,新赋值为一个非ref,那么需要修改原来ref的value,而不是直接赋值。
export function proxyRefs(objectWithRefs) {
return new Proxy(objectWithRefs, {
get(target, key) {
return unRef(Reflect.get(target, key));
},
set(target, key, value) {
if (isRef(target[key]) && !isRef(value)) {
return (target[key].value = value);
} else {
return Reflect.set(target, key, value);
}
},
});
}
Computed
观察以下测试代码,可以得到Computed的特点:
- computed传入回调函数,回调函数不是在修改响应对象(reactive、ref)属性值的时候执行,而是在获取计算属性的.value时。
- computed有缓存功能,由上一条知,计算属性执行传入回调的时机在getter中,但是也不是每一次都会重新计算,如果依赖的值没变,就用之前的缓存。
describe('computed', () => {
it('happy path', () => {
const user = reactive({
age: 1
});
const age = computed(() => {
return user.age;
});
// computed用.value,类似于ref
expect(age.value).toBe(1);
});
it('should compute lazily', () => {
const value = reactive({
foo: 1
});
const getter = jest.fn(() => {
return value.foo;
});
const cValue = computed(getter);
// 只是向computed传入回调函数,并不会立刻执行它
expect(getter).not.toHaveBeenCalled();
expect(cValue.value).toBe(1);
// 在获取计算属性的value时,执行回调
expect(getter).toHaveBeenCalledTimes(1);
// 再次获取计算属性的value,但是由于值未发生改变,并不会再次执行回调
cValue.value;
expect(getter).toHaveBeenCalledTimes(1);
// 修改了原对象的属性,此时还是不立刻执行回调
value.foo = 2;
expect(getter).toHaveBeenCalledTimes(1);
expect(cValue.value).toBe(2);
// 下一次获取计算属性的value时,才去执行回调
expect(getter).toHaveBeenCalledTimes(2);
cValue.value;
expect(getter).toHaveBeenCalledTimes(2);
});
});
computed有点类似于effect,但是不会立刻执行传入的回调函数。ComputedImpl类中要保存_effect,即ReactiveEffect的实例对象,把回调函数传入,之后通过this._effect.run()来执行回调。
此外,为了实现缓存,_value用于保存缓存值,_dirty用于判断computed依赖的值是否发生变化,如果变了,下次就要重新调用run方法获取新的值,并且保存到_value。
注意到回调函数中访问到了响应式对象的属性,通过this._effect.run()执行回调时,activeEffect也被设置成computed内部的_effect,然后它会被track。以后修改响应式对象的属性时,_effect会被trigger,computed值会立刻改变。
但是上面分析过,此时不应该立刻计算,而是等到下一次访问computed的.value时,在getter中计算。因此用到Scheduler。在Scheduler中修改_dirty,然后在getter中判断_dirty。Scheduler应用在这种不想在trigger时执行原先回调函数的场景。
class ComputedRefImpl {
private _value: any;
private _effect: any;
private _dirty = true;
constructor(getter) {
// 当依赖的属性改变时,不执行getter,而是执行scheduler
this._effect = new ReactiveEffect(getter, () => {
this._dirty = true;
});
}
get value() {
// 下次get时再去计算目前的值,并保存到_value
if (this._dirty) {
this._dirty = false;
this._value = this._effect.run();
}
return this._value;
}
}
export function computed(getter) {
return new ComputedRefImpl(getter);
}
思考:目前的缓存仅仅是“如果被依赖值没有被修改过,就不需要重新计算”。如果被依赖值修改为和原来相同的值,脏标记依然设置为true,下次还要重新计算。如何优化这个点?