继上一篇reactive和ref的响应式之后,这一篇来参考Vue3源码,实现简易版本的computed和watch
computed(计算并带有缓存的响应式)
使用方法
Vue3中,我们一般针对响应式数据
生成computed计算属性,一般用一个带返回值的回调函数
表示
当对应的属性值变化的时候,计算属性被触发,并返回新的值
const { reactive, effect, computed } = Vue;
const obj = reactive({
name: "张三",
});
const computedObj = computed(() => {
return "姓名:" + obj.name;
});
effect(() => {
document.querySelector("#app").innerText = computedObj.value;
});
setTimeout(() => {
obj.name = "李四";
}, 2000);
分析
effect
和响应式reactive
在之前都已经实现了,现在剩下的就是三个问题
- computed在初次创建时候调用
- computed中涉及的响应式数据的key对应的值变化时,重新计算,调用副作用函数
前两点都和effect类似,目前看来可以参考effect的实现
至于computed怎么知道响应式的值变化,也是需要关注的一个点,即缓存性
源码简化版实现
创建&初次调用
computed的实现使用了ComputedRefImpl类
,并且在创建的时候保证传入的getter方法一定是函数
export function computed(getterOrOptions) {
let getter;
// 这里确保computed传入的参数一定是function
const onlyGetter = isFunction(getterOrOptions);
if (onlyGetter) {
getter = getterOrOptions;
}
const cRef = new ComputedRefImpl(getter);
return cRef;
}
computed的初次调用比较简单,直接可以参考effect的依赖收集,读取value
直接调用一次effect方法
即可
export class ComputedRefImpl<T> {
public dep?: Dep = undefined;
private _value!: T;
public readonly effect: ReactiveEffect<T>;
public readonly __v_isRef = true;
constructor(getter) {
this.effect = new ReactiveEffect(getter);
this.effect.computed = this;
}
get value() {
trackRefValue(this);
this._value = this.effect.run();
return this._value;
}
}
调度器&脏状态
实现computed的响应性,Vue3源码中使用了调度器,在ReactiveEffect
里添加了第二个参数sheduler
export class ReactiveEffect<T = any> {
computed?: ComputedRefImpl<T>;
constructor(
public fn: () => T,
public scheduler: EffectScheduler | null = null
) {}
......
}
调度器的意义在于触发依赖的时候会有一次判断,如果有调度器,则触发调度器,否则触发普通的依赖
export function triggerEffect(effect: ReactiveEffect) {
if (effect.scheduler) {
effect.scheduler();
} else {
effect.run();
}
}
除了调度器,computed还添加了一个脏状态,初始是true
触发get方法
的时候,因为脏状态为true,会调用一次effect,并将脏状态调整为false
,方便下次set
的时候调用调度器
export class ComputedRefImpl<T> {
......
public _dirty = true
constructor(getter) {
this.effect = new ReactiveEffect(getter, () => {
if (!this._dirty) {
this._dirty = true;
triggerRefValue(this);
}
});
......
}
get value() {
......
if (this._dirty) {
this._dirty = false;
this._value = this.effect.run();
}
return this._value;
}
}
缓存性
computed的缓存性意味着,如果有多次修改数据,effect应该将它们合并,只取最后一次的修改
但是就目前的代码实现,如果我们在effect中做两次DOM操作,则会陷入死循环
死循环的原因
出现这个死循环主要是因为triggerEffects
时,effect包括了计算属性
和非计算属性
的。
当我们执行计算属性的effect,会调用scheduler
并更改dirty的状态,而因为我们DOM操作使用了computed的value,在get
方法中又有一次effect的执行,这里又遍历执行了所有的effects,也包括计算属性
和非计算属性
的,也就是在这里出现了死循环
避免死循环
当我们知道了死循环是因为effect重复执行了计算属性,那我们要做的就是把计算属性的effect和非计算属性的effect分开执行,先计算属性,后非计算属性,即可成功处理
export function triggerEffects(dep: Dep) {
// 先执行计算属性的effect
Array.from(dep).forEach((effect) => {
effect.computed && triggerEffect(effect);
});
// 再执行非计算属性的effect
Array.from(dep).forEach((effect) => {
!effect.computed && triggerEffect(effect);
});
}
小结
computed的核心包括几个部分
- 调度器:依赖于
triggerEffect
里的第二个参数scheduler
和_dirty
脏数据状态 - 依次执行effect:先执行计算属性的effect,后执行非计算属性的,防止死循环
watch(数据监听)
使用方法
Vue3中,watch是用来动态监听数据变化
的,这一点和computed有点像
但是不一样的是,当变化的时候,我们可以拿到数据的新值
和老值
,同时做一些副作用
操作
此外,我们还可以根据自己的需要,配置是否深度监听
数据(例如针对对象/数组),或者是否在数据初始化
的时候就执行一次副作用
const { reactive, effect, watch } = Vue;
const obj = reactive({
name: "张三",
});
watch(
// 因为obj是响应式数据,所以不用函数返回
obj.name,
(value, oldValue) => {
console.log("watch run");
console.log(`value is ${JSON.stringify(value)}`);
},
{
immediate: true,
}
);
setTimeout(() => {
obj.name = "李四";
}, 2000);
分析
watch分为三个步骤
- 创建响应式数据
- 修改响应式数据
- 触发watch的监听副作用
这里主要关心的应该是如何触发,以及watch的一些配置项(深度监听、初次创建时候立即调用)
其中初次创建时候立即调用,computed
里面用的是_dirty脏数据
状态实现的,这里可以考虑下是否也一样
源码简化版实现
调度器scheduler
在阅读watch
源码后发现,和computed
对比发现,代码中也包含了调度器scheduler
调度器主要包括两个部分
- 懒执行
- 调度器本身
懒执行
懒执行很简单,只要添加一个lazy参数
就行,如果lazy则不立即执行effect副作用
export function effect<T = any>(fn: () => T, options?: ReactiveEffectOptions) {
const _effect = new ReactiveEffect(fn);
if (!options || !options.lazy) {
// 完成第一次run执行
_effect.run();
}
}
调度器
调度有点像交通里的红绿灯,起到控制执行顺序和逻辑的作用
调整执行顺序
在JS里,代码的执行是单线程的,按照从上往下的顺序依次执行,如果想改变执行的顺序,就得使用JS任务队列,将某些语句调整成微任务/宏任务
watch
的源码里的scheduler用了一个job方法
,job方法里的一个核心就是把原来的同步代码封装成了Promise.resolve
的微任务
例如,我们有一个响应式数据,写了如下代码
const { reactive, effect, queuePreFlushCb } = Vue;
const obj = reactive({ count: 1 });
effect(() => {
console.log(obj.count);
})
obj.count = 2;
console.log("代码结束");
按照effect的调用逻辑和js的单线程,运行结果应该是1、2、代码结束
如果希望按照1、代码结束、2这样的顺序执行(即triggerEffect
最后执行),则可以在effect中添加一个scheduler
并放入一个异步方法(例如setTimeout
)
因为执行effect的时候,如果有scheduler,则执行scheduler,否则才是执行effect本身
effect(
() => {
console.log(obj.count);
},
{
scheduler: () => {
setTimeout(() => {
console.log(obj.count);
}, 1000);
},
}
);
为了让我们自己传入的scheduler可以合并到effect中,我们需要做一个合并操作,把传入的options中的值和原先的合并
export function effect<T = any>(fn: () => T, options?: ReactiveEffectOptions) {
......
if (options) {
// extend本质是Object.assign方法
extend(_effect, options);
}
......
}
调整执行规则
这里的执行规则指的是合并多次effect副作用
例如,如果我们想多次改变响应式数据,我们其实只关心最开始的值和最后一次改变之后的值,至于中间的值我们并不在意
阅读源码可以发现,使用一个封装的queuePreFlushCb
方法,可以实现忽略中间的数值改变
effect(
() =>
console.log(obj.count);
},
{
scheduler: () => {
queuePreFlushCb(() => console.log("scheduler", obj.count));
},
}
);
看一下queuePreFlushCb
可以发现,这个方法是把传入的方法存入一个队列中,然后循环把方法用Promise.resolve.then
执行,即把同步方法转成微任务,放在所有同步方法之后
因为修改数据的方法是同步的,所以自然在执行的时候,已经是最后一次改变了数值之后的最新值了,中间的值不会输出
const resolvedPromise = Promise.resolve() as Promise<any>;
export function queuePreFlushCb(cb: Function) {
queueCb(cb, pendingPreFlushCbs);
}
function queueCb(cb: Function, pendingQueue: Function[]) {
pendingQueue.push(cb);
queueFlush();
}
function queueFlush() {
if (!isFlushPending) {
isFlushPending = true;
currentFlushPromise = resolvedPromise.then(flushJobs);
}
}
watch基本框架
watch基本的包括三部分
- 响应式数据
- 回调函数(参数包括老值和新值)
- 配置项(懒加载、深度监听等等)
参考Vue3源码,可以搭建这样一个框架流程
export interface WatchOptions<immediate = boolean> {
immediate?: immediate;
deep?: boolean;
}
// 源码中watch的本质就是doWatch方法
export function watch(source, cb: Function, options?: WatchOptions) {
return doWatch(source, cb, options);
}
function doWatch(
source,
cb: Function,
{ immediate, deep }: WatchOptions = EMPTY_OBJ
) {
let getter: () => any;
// 如果是响应式数据,getter返回的是响应式数据的值
if (isReactive(source)) {
getter = () => source;
deep = true;
} else {
getter = () => {};
}
if (cb && deep) {
// 这个地方理解成浅拷贝即可
const baseGetter = getter;
getter = () => baseGetter();
}
let oldValue = {};
// 本质上为了拿到newValue
const job = () => {
// 如果有回调方法,要触发该方法,并且通过执行一次effect拿到新的值,并把新值老值做更新
if (cb) {
const newValue = effect.run();
if (deep || hasChanged(newValue, oldValue)) {
cb(newValue, oldValue);
oldValue = newValue;
}
}
};
// 调度器
let scheduler = () => queuePreFlushCb(job);
const effect = new ReactiveEffect(getter, scheduler);
/*
* 如果有回调函数,需要立即执行,直接执行一次job操作回调,不然的话只更新老值
* 如果没有回调函数,只执行一次副作用
*/
if (cb) {
if (immediate) {
job();
} else {
oldValue = effect.run();
}
} else {
effect.run();
}
return () => {
effect.stop();
};
}
但是如果运行这个框架,还会有一个问题,就是当修改值的时候,没有触发watch的监听effect
没有触发的核心原因是:没有一个收集依赖的地方
watch的依赖收集
对于响应式数据,依赖收集其实本质上是依靠get方法触发的,所以在Vue源码中,可以看到一个叫traverse
的方法,这个方法不做任何处理,只是对传入的值以及其中的每一个属性做读取,从而达到收集依赖的目的
export function traverse(value: unknown) {
if (!isObject(value)) {
return value;
}
for (const key in value as object) {
traverse((value as object)[key]);
}
return value;
}
相应的,可以在watch的getter
中做一下traverse
function doWatch(
source,
cb: Function,
{ immediate, deep }: WatchOptions = EMPTY_OBJ
) {
let getter: () => any;
......
if (cb && deep) {
const baseGetter = getter;
getter = () => traverse(baseGetter());
}
......
}
这样watch就可以被响应式数据的更改触发了
小结
watch的核心包括
- 调度器:核心是
queuePreFlushCb
改变执行调度逻辑,并通过一些逻辑判断执行策略 - 依赖收集:通过遍历原先响应式数据的getter收集依赖