说明
【vue.js 设计与实现】 霍春阳 学习笔记
实现
Version 0.2.1 — bug修复版
问题1:分支切换导致不必要的更新
function calculateTotalCount() {
console.log('执行副作用函数');
let text = '采购信息:';
if(proxyData.count > 0){
let money = proxyData.count * proxyData.price;
text += `总金额 = ${money}`;
}else{
text += '无';
}
console.log('text: ' + text);
document.body.innerHTML = text;
}
window.onload = function() {
effect(calculateTotalCount);
setTimeout(() => {
console.log('\n\n更新count');
proxyData.count = 0;
}, 1000);
setTimeout(() => {
console.log('\n\n更新price');
proxyData.price = 20;
}, 1500);
}
日志中我们可以看到,修改了
price后是触发了calculateTotalCount,但这之前count已经是0了,price不管怎么修改,函数的结果都不会改变,因为此时的calculateTotalCount已经不应该是price的副作用函数了。
当响应式数据的某个属性值发生变化,代码执行的分支会发生变化,导致键对应副作用函数发生变化,这就是分支切换。
现状:
count > 0
count->calculateTotalCount,price->calculateTotalCountcount <= 0
count->calculateTotalCount,price->calculateTotalCount期望:count > 0
count->calculateTotalCount,price->calculateTotalCountcount <= 0
count->calculateTotalCount
那要如何做勒?捋一下,导致现状的原因是:
- 注册Effect(副作用函数)
calculateTotalCountactiveEffect=calculateTotalCount等待触发get收集Effect- 执行
calculateTotalCount
- 触发
count读取,收集calculateTotalCount作为count的Effect count值大于0,触发price的读取 ,收集calculateTotalCount作为price的Effect。(也再次触发了count的读取)calculateTotalCount执行完毕- 更新
count=0,触发trigger,取出并执行calculateTotalCount,触发count读取,calculateTotalCount执行完毕 - 更新
price=20,触发trigger,取出并执行calculateTotalCount,触发count读取,calculateTotalCount执行完毕 - 更新
price=40,触发trigger,取出并执行calculateTotalCount,触发count读取,calculateTotalCount执行完毕
要想更新price的副作用函数,就需要在合适的时机删除price的副作用函数,再次读取price的时候再添加副作用函数,什么是合适的时机?
- Effect执行前,在与之相关的集合中删除这个Effect
- Effect执行后,重新建立和属性的联系(读取属性),但在新的联系中不会包含遗留的Effect 要将一个Effect从与之关联的集合中删除,需要知道哪些集合包含这个Effect,这样我们需要改变一下Effect的结构,增加deps属性(数组),用来存储包含这个Effect的集合。
/**
* 注册副作用函数
* @param {Function} effectFn
*/
function effect(effectFn) {
/* 修改effectFn对象,增加deps属性 */
const formatEffectFn = () => {
/* 新增 start-清理关联的set */
for(let i = 0; i < formatEffectFn.deps.length; i++){
const effectSet = formatEffectFn.deps[i];
effectSet.delete(formatEffectFn);
}
// 重置数组
formatEffectFn.deps.length = 0;
console.log('删除副作用函数的关联set完毕');
/* end */
// 一定要放在清理set之后,因为执行effectFn会重新建立关联
activeEffect = formatEffectFn;
effectFn();
};
formatEffectFn.deps = [];
formatEffectFn();
}
/**
* 收集target对象key的副作用函数
* @param {Object} target
* @param {String|Symbol} key
* @return {void}
*/
function track(target, key){
if(!activeEffect) return;
let targetMap = bucket.get(target);
if(!targetMap){
targetMap = new Map();
bucket.set(target, targetMap);
}
let effectSet = targetMap.get(key);
if(!effectSet){
effectSet = new Set();
targetMap.set(key, effectSet);
}
effectSet.add(activeEffect);
/* 新增:effect中关联和它相关的set */
activeEffect.deps.push(effectSet);
}
运行结果:
日志中可以看到:
- count=0时已经没有触发price,达到目标
- bug:无限循环的在执行副作用函数,什么地方在触发?在运行Effect前(trigger中)打日志看看
function trigger(target, key) {
console.log('触发trigger');
const targetMap = bucket.get(target);
if(!targetMap) return;
const effectSet = targetMap.get(key);
if(!effectSet) return;
effectSet.forEach(effectFn => {
console.log('取出effectFn');
effectFn();
});
}
这样看,问题就很清晰了,是
trigger中一直在取出并执行Effect,为什么勒?
我们在执行副作用函数前先删除了Set中的Effect,然后又触发了get,Set中又增加了这个Effect。且这个执行过程是在Set的forEach中,就相当于:
SetObj.forEach(item => {
Setobj.delete(item);
SetObj.add(item);
})
语言规范中:forEach遍历Set时,如果一个值已经被访问了,但该值被删除并重新添加到集合中,如果此时forEach没有结束,则改值会被重新访问。
所以,才会一直执行取出effectFn。解决办法就是复制一个集合来进行遍历:
function trigger(target, key) {
console.log('触发trigger');
const targetMap = bucket.get(target);
if(!targetMap) return;
const effectSet = targetMap.get(key);
if(!effectSet) return;
/* 新增effectsToRun */
const effectsToRun = new Set(effectSet);
effectsToRun.forEach(effectFn => {
console.log('取出effectFn');
effectFn();
});
}
问题2: 无限循环导致栈溢出
在0.2版本代码上,我们增加一个副作用函数addPrefix
function addPrefix() {
console.log('执行addPrefix')
proxyData.name = '富途周边:' + proxyData.name;
console.log('执行完毕');
}
window.onload = function() {
effect(addPrefix);
}
为什么会有这种错误?
从日志中可以看到,addPrefix、get、set交替触发,直到栈溢出。我们期望是执行一次addPrefix、一次get name,一次set name,为什么会有这样的现象?
捋一下:
- 注册Effect
addPrefixactiveEffect=addPrefix等待触发get收集Effect- 执行
addPrefix
addPrefix中先读取name,触发getget中触发track,将addPrefix存到了bucket中addPrefix中设置name,触发triggertrigger中取出Effect,并执行addPrefix- 循环 2~5
set中会触发副作用函数,副作用函数中又会触发set,所以就这样一直循环下去了。
问题找到了,如何解决?
问题的关键在第5步,trigger中取出并执行addPrefix,它开启了又一轮循环,我们怎么能在此时终止勒?
在trigger中增加守卫条件,如果取出来的effectFn不是当前正在执行的activeEffect,才能执行effectFn
function trigger(target, key) {
const targetMap = bucket.get(target);
if(!targetMap) return;
const effectSet = targetMap.get(key);
if(!effectSet) return;
effectSet.forEach(effectFn => {
/* 新增守卫条件 */
if(effectFn !== activeEffect){
effectFn();
}else{
console.log('不触发effectFn');
}
});
}
但是这样会影响正常的用例:
window.onload = function() {
effect(calculateTotalCount);
setTimeout(() => {
console.log('\n\n更新count');
proxyData.count = 0;
}, 1000);
}
Effect应该被触发,但结果没有触发
这是因为
activeEffect在effect函数中赋值一次,只要没有新的副作用函数注册进来,就一直不变。我们可以在副作用函数执行后,就重置这个值
/**
* 注册副作用函数
* @param {Function} effectFn
*/
function effect(effectFn) {
/* effectFn对象,增加deps属性 */
const formatEffectFn = () => {
/* start-清理关联的set */
for(let i = 0; i < formatEffectFn.deps.length; i++){
const effectSet = formatEffectFn.deps[i];
effectSet.delete(formatEffectFn);
}
// 重置数组
formatEffectFn.deps.length = 0;
/* end */
console.log('删除副作用函数的关联set完毕');
// 一定要放在清理set之后,因为执行effectFn会重新建立关联
activeEffect = formatEffectFn;
effectFn();
/* 新增:effectFn执行后,重置 */
activeEffect = null;
};
formatEffectFn.deps = [];
formatEffectFn();
}
重新运行所有用例
window.onload = function() {
effect(addPrefix);
console.log('\n\n');
effect(calculateTotalCount);
setTimeout(() => {
console.log('\n\n更新count');
proxyData.count = 0;
}, 1000);
setTimeout(() => {
console.log('\n\n更新price');
proxyData.price = 20;
}, 1500);
}
去掉了effect中清理Set的那行日志
完整代码
let activeEffect = null;
// 执行副作用函数
const effectStack = [];
/**
* 注册副作用函数
* @param {Function} effectFn
*/
function effect(effectFn) {
/* effectFn对象,增加deps属性 */
const formatEffectFn = () => {
/* start-清理关联的set */
for(let i = 0; i < formatEffectFn.deps.length; i++){
const effectSet = formatEffectFn.deps[i];
effectSet.delete(formatEffectFn);
}
// 重置数组
formatEffectFn.deps.length = 0;
/* end */
// 一定要放在清理set之后,因为执行effectFn会重新建立关联
activeEffect = formatEffectFn;
effectFn();
/* effectFn执行后,重置 */
activeEffect = null;
};
formatEffectFn.deps = [];
formatEffectFn();
}
// 存储副作用函数
const bucket = new WeakMap();
/**
* 收集target对象key的副作用函数
* @param {Object} target
* @param {String|Symbol} key
* @return {void}
*/
function track(target, key){
if(!activeEffect) return;
let targetMap = bucket.get(target);
if(!targetMap){
targetMap = new Map();
bucket.set(target, targetMap);
}
let effectSet = targetMap.get(key);
if(!effectSet){
effectSet = new Set();
targetMap.set(key, effectSet);
}
effectSet.add(activeEffect);
/* effect中关联和它相关的set */
activeEffect.deps.push(effectSet);
}
/**
* 触发target对象key的副作用函数执行
* @param {Object} target
* @param {String|Symbol} key
* @return {void}
*/
function trigger(target, key) {
console.log('触发trigger');
const targetMap = bucket.get(target);
if(!targetMap) return;
const effectSet = targetMap.get(key);
if(!effectSet) return;
const effectsToRun = new Set(effectSet);
effectsToRun.forEach(effectFn => {
/* 守卫条件 */
if(effectFn !== activeEffect){
effectFn();
}else{
console.log('不触发effectFn');
}
});
}
/**
* 创建响应式对象
* @param {*} obj
* @returns
*/
function reactive(obj){
return new Proxy(obj, {
get(target, key) {
console.log(`触发get,key = ${key}`);
/* 存入副作用函数 */
track(target, key);
return target[key];
},
set(target, key, newVal) {
console.log(`触发set,key = ${key}`);
target[key] = newVal;
// 取出并执行副作用函数
trigger(target, key);
return true;
}
});
}
/**
* 以下是演示用例
*/
const proxyData = reactive({
name: '牛牛手办',
price: 10,
count: 1,
});
function calculateTotalCount() {
console.log('执行calculateTotalCount');
let text = '采购信息:';
if(proxyData.count > 0){
let money = proxyData.count * proxyData.price;
text += `总金额 = ${money}`;
}else{
text += '无';
}
console.log('text: ' + text);
document.body.innerHTML = text;
}
function addPrefix() {
console.log('执行addPrefix')
proxyData.name = '富途周边:' + proxyData.name;
console.log('执行完毕');
}
window.onload = function() {
effect(addPrefix);
console.log('\n\n');
effect(calculateTotalCount);
setTimeout(() => {
console.log('\n\n更新count');
proxyData.count = 0;
}, 1000);
setTimeout(() => {
console.log('\n\n更新price');
proxyData.price = 20;
}, 1500);
}