computed,watch,watchEffect
今天来实现一下 vue 里的 computed,watch 和 watchEffect 函数。
先把前面的代码简单捞出来,做一些修改。
使得页面上有个按钮,点击按钮,count 增加。(万物始于计数器)
let active;
const watchEffect = (cb) => {
active = cb;
active();
active = null;
};
let nextTick = (cb) => Promise.resolve().then(cb);
let queue = [];
let queueJob = (job) => {
if (!queue.includes(job)) {
queue.push(job);
nextTick(flushJobs);
}
};
let flushJobs = () => {
let job;
while ((job = queue.shift()) !== undefined) {
job();
}
};
class Dep {
deps = new Set();
depend() {
if (active) {
this.deps.add(active);
}
}
notify() {
this.deps.forEach((dep) => queueJob(dep));
}
}
const ref = (value) => {
let initValue = value;
let deps = new Dep();
return Object.defineProperty({}, "value", {
get() {
deps.depend();
return initValue;
},
set(newVal) {
initValue = newVal;
deps.notify();
},
});
};
let count = ref(0);
document.getElementById("add").addEventListener("click", () => {
count.value++;
});
watchEffect(() => {
let str = `count is: ${count.value}`;
document.getElementById("app").innerText = str;
});
watchEffect
watchEffect 是一个函数,接受一个函数。函数内部依赖的值变了,函数就会立即执行。就跟我们之前的 watch 一模一样。所以,直接给他改个名就行了。
但是还要返回一个 stop,用来停止监听,这个最后再说。
computed
computed 接受一个函数,内部依赖某个值,并返回一个新的值。内部还有一个缓存。当依赖的值变化的时候才会重新求值。
简单实现
简单实现一个 computed 函数:
let computed = (fn) => {
// 使用闭包来保存一个value
let value;
return {
get value() {
// 在get中响应式监听,直接在get中执行一次fn即可获取到计算后的值。
value = fn();
return value;
},
};
};
上述代码已经可以简单的实现一个计算属性。
缓存功能
但是我们的 computed 是有缓存的,而且如果多处调用计算属性,那么将会进行多次计算。为了减少计算。我们也要使用缓存。
通过下面的代码可以发现问题:
let active;
const watchEffect = (cb) => {
active = cb;
active();
active = null;
};
let nextTick = (cb) => Promise.resolve().then(cb);
let queue = [];
let queueJob = (job) => {
if (!queue.includes(job)) {
queue.push(job);
nextTick(flushJobs);
}
};
let flushJobs = () => {
let job;
while ((job = queue.shift()) !== undefined) {
job();
}
};
class Dep {
deps = new Set();
depend() {
if (active) {
this.deps.add(active);
}
}
notify() {
this.deps.forEach((dep) => queueJob(dep));
}
}
const ref = (value) => {
let initValue = value;
let deps = new Dep();
return Object.defineProperty({}, "value", {
get() {
deps.depend();
return initValue;
},
set(newVal) {
initValue = newVal;
deps.notify();
},
});
};
let computedCount = 0;
let computed = (fn) => {
// 使用闭包来保存一个value
let value;
return {
get value() {
// 在get中响应式监听,直接在get中执行一次fn即可。
value = fn();
computedCount += 1;
return value;
},
};
};
let count = ref(0);
let computeCount = computed(() => count.value + 1);
document.getElementById("add").addEventListener("click", () => {
count.value++;
});
watchEffect(() => {
let str = `count is: ${count.value} \n computeCount is: ${computeCount.value} \n computedCount: ${computedCount}`;
document.getElementById("app1").innerText = str;
});
watchEffect(() => {
document.getElementById(
"app2"
).innerText = `computeCount 2 is : ${computeCount.value} \n computedCount: ${computedCount}`;
});
我们在 computed 中增加一个 flag 来控制是否进行计算。
let computed = (fn) => {
// 使用闭包来保存一个value
let value;
let dirty = true;
return {
get value() {
// 如果值没有发生变化,返回缓存中的值。
// 也可以保证多处依赖的时候不会重复计算。
if (dirty) {
// 在get中响应式监听,直接在get中执行一次fn即可。
value = fn();
computedCount += 1;
// 置为false
dirty = false;
}
return value;
},
};
};
那么问题来了,既然置为了 false,那么何时何地再置为 true 呢。
显然,在我们触发了 set 操作。在赋值过后的广播行为里,将所有 computed 属性里的 flag 都置为 true。
对此需要进行一些改造。
此刻之前的代码,ref 中,
initialValue和value的定义不符合语义化,已更改。
let active;
let effect = (fn, options = {}) => {
// 定义一个effectInner来对active进行赋值和执行的操作。
let effectInner = (...args) => {
try {
// 常规执行,将自身赋值给active,以在收集依赖的时候调用。
active = effectInner;
// 直接执行fn,防止递归调用。然后返回。
// return active(...args);
return fn(...args);
} finally {
// 为了能最终将active值为空。使用try catch finally的特性。
active = null;
}
};
// 给effectInner上绑定一个options对象。以在广播的时候调用。
effectInner.options = options;
return effectInner;
};
const watchEffect = (cb) => {
// 替换为effect
let runner = effect(cb);
runner();
};
let nextTick = (cb) => Promise.resolve().then(cb);
let queue = [];
let queueJob = (job) => {
if (!queue.includes(job)) {
queue.push(job);
nextTick(flushJobs);
}
};
let flushJobs = () => {
let job;
while ((job = queue.shift()) !== undefined) {
job();
}
};
class Dep {
deps = new Set();
depend() {
if (active) {
this.deps.add(active);
}
}
notify() {
this.deps.forEach((dep) => {
queueJob(dep);
dep.options && dep.options.schedular && dep.options.schedular();
});
// 放上面好像也没啥事情,丢里面也能跑。不知道为啥课上写外面,也没说为啥。
// this.deps.forEach(dep => {
// dep.options && dep.options.schedular && dep.options.schedular();
// })
}
}
const ref = (initValue) => {
let value = initValue;
let deps = new Dep();
return Object.defineProperty({}, "value", {
get() {
deps.depend();
return value;
},
set(newVal) {
value = newVal;
deps.notify();
},
});
};
let computedCount = 0;
let computed = (fn) => {
// 使用闭包来保存一个value
let value;
let dirty = true;
// 使用effect包裹fn,增加option,以保证在执行完之后。能在广播中调用options里的schedular。
let runner = effect(fn, {
//
schedular: () => {
// 执行这步的时候说明所依赖的值已经更新过了。所以需要置为true保证下面能计算。
!dirty && (dirty = true);
},
});
return {
get value() {
// 如果值没有发生变化,返回缓存中的值。
// 也可以保证多处依赖的时候不会重复计算。
if (dirty) {
// 在get中响应式监听,直接在get中执行一次fn即可。
value = runner();
computedCount += 1;
dirty = false;
}
return value;
},
};
};
let count = ref(0);
let computeCount = computed(() => count.value + 1);
document.getElementById("add").addEventListener("click", () => {
count.value++;
});
watchEffect(() => {
let str = `count is: ${count.value} \n computeCount is: ${computeCount.value} \n computedCount: ${computedCount}`;
document.getElementById("app1").innerText = str;
});
watchEffect(() => {
document.getElementById(
"app2"
).innerText = `computeCount 2 is : ${computeCount.value} \n computedCount: ${computedCount}`;
});
细心的小伙伴发现了。点击 add 的时候下面的id=app2的dom不刷新了。这是为啥呢。
我们知道watchEffect会立即执行一遍。所以一开始的时候。我们的值是computeCount.value是 1,没毛病。并且在设置缓存之后,computedCount也是 1
了。并没有重复计算。因为在执行app2的dom的渲染的时候,count的值并没有刷新,所以直接从缓存中取的值。所以上述代码的缓存功能是实现了。
至于为啥不更新。因为app2并不依赖于 count
的值,所以在依赖收集的时候,并没有被增加到deps里去。在页面渲染完毕之后,deps里只有两个dep,都是app1在渲染的时候产生的。一个是直接调用get cout.value的时候产生的依赖,还有一个是调用computed里的count.value + 1的时候使用的get产生的依赖。所以count更新的时候只会执行这两个。并不会触发app2的更新。
没搞懂的。理清一下逻辑。在notify的时候打个断点。在depend的时候打一个断点。就跑懂啦。
watch
监听变化,并在监听回调函数中返回数据变更前后的两个值,常用于在数据变化之后执行的异步操作或者开销交大的操作。
那么,app2的不渲染问题也可以在这里解决。(不直接依赖count,但是需要根据count的变化而变化)
那么,核心思想就是,触发count的get方法把执行的回调函数增加到deps里。
我们这里 watch 的第一个参数只给到function,其他类型的不讨论。
所以有一个 getter:
// source是一个函数,里面有所依赖(要监听)的值
let getter = () => {
return source();
};
然后我们需要一个增加依赖的地方,所以用到了 effect,然后还需要给回调函数准备newVal和oldVal:
let oldVal;
const runner = effect(getter, {
schedular: () => {
// 重复触发依赖收集
let newVal = runner();
// 只有当newVal不等于oldVal的时候才触发,即有变化之后才触发
if (newVal !== oldVal) {
cb(newVal, oldVal);
// 重新赋值oldVal
oldVal = newVal;
}
},
});
// 初始化赋值oldVal
oldVal = runner();
然后我们去使用 watch,并且在 callback 里不直接使用cout.value,完整代码如下:
let active;
let effect = (fn, options = {}) => {
let effectInner = (...args) => {
try {
active = effectInner;
return fn(...args);
} finally {
active = null;
}
};
effectInner.options = options;
return effectInner;
};
const watchEffect = (cb) => {
let runner = effect(cb);
runner();
};
let nextTick = (cb) => Promise.resolve().then(cb);
let queue = [];
let queueJob = (job) => {
if (!queue.includes(job)) {
queue.push(job);
nextTick(flushJobs);
}
};
let flushJobs = () => {
let job;
while ((job = queue.shift()) !== undefined) {
job();
}
};
class Dep {
deps = new Set();
depend() {
if (active) {
this.deps.add(active);
}
}
notify() {
this.deps.forEach((dep) => {
queueJob(dep);
dep.options && dep.options.schedular && dep.options.schedular();
});
// this.deps.forEach(dep => {
// dep.options && dep.options.schedular && dep.options.schedular();
// })
}
}
const ref = (initValue) => {
let value = initValue;
let deps = new Dep();
return Object.defineProperty({}, "value", {
get() {
deps.depend();
return value;
},
set(newVal) {
value = newVal;
deps.notify();
},
});
};
let computed = (fn) => {
// 使用闭包来保存一个value
let value;
let dirty = true;
let runner = effect(fn, {
schedular: () => {
!dirty && (dirty = true);
},
});
return {
get value() {
// 如果值没有发生变化,返回缓存中的值。
// 也可以保证多处依赖的时候不会重复计算。
if (dirty) {
// 在get中响应式监听,直接在get中执行一次fn即可。
value = runner();
dirty = false;
}
return value;
},
};
};
let watch = (source, cb) => {
let getter = () => {
return source();
};
let oldVal;
const runner = effect(getter, {
schedular: () => {
// 重复触发依赖收集
let newVal = runner();
if (newVal !== oldVal) {
cb(newVal, oldVal);
oldVal = newVal;
}
},
});
oldVal = runner();
};
let count = ref(0);
let computeCount = computed(() => count.value + 1);
watch(
() => count.value,
(newVal, preVal) => {
console.log(newVal, preVal);
document.getElementById("app2").innerText = `watchCount is : ${newVal}`;
}
);
document.getElementById("add").addEventListener("click", () => {
count.value++;
});
watchEffect(() => {
let str = `count is: ${count.value} \n computeCount is: ${computeCount.value}`;
document.getElementById("app1").innerText = str;
});
// watchEffect(() => {
// document.getElementById('app2').innerText = `computeCount 2 is : ${computeCount.value}`
// })
可以看到app2已经被更新了。
但是首次渲染的时候没有显示。当然咯。首次渲染的时候count.value又没变化咯。
要是我就是要初始化的时候也渲染呢?
那么就到了 watch 的 options 里,有个叫 immediate 的参数。用来判断是否需要立即执行一次。
对 watch 进行一下小修改:
let watch = (source, cb, options = { immediate: false }) => {
const { immediate } = options;
let getter = () => {
return source();
};
let oldVal;
const applyCb = () => {
// 重复触发依赖收集
let newVal = runner();
if (newVal !== oldVal) {
cb(newVal, oldVal);
oldVal = newVal;
}
};
const runner = effect(getter, {
// schedular: () => applyCb()
schedular: applyCb,
});
if (immediate) {
applyCb();
} else {
oldVal = runner();
}
};
这样的话第一次进来就可以看到啦。