响应系统设计—Part 2

123 阅读1分钟

说明

【vue.js 设计与实现】 霍春阳 学习笔记

接上文响应系统设计—Part 1

实现

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);
}

Xnip2022-06-02_09-07-39.jpg 日志中我们可以看到,修改了price后是触发了calculateTotalCount,但这之前count已经是0了,price不管怎么修改,函数的结果都不会改变,因为此时的calculateTotalCount已经不应该是price的副作用函数了。
当响应式数据的某个属性值发生变化,代码执行的分支会发生变化,导致键对应副作用函数发生变化,这就是分支切换
现状:

  • count > 0
    count -> calculateTotalCount, price -> calculateTotalCount
  • count <= 0
    count -> calculateTotalCount, price -> calculateTotalCount 期望:
  • count > 0
    count -> calculateTotalCount, price -> calculateTotalCount
  • count <= 0
    count -> calculateTotalCount

那要如何做勒?捋一下,导致现状的原因是:

  1. 注册Effect(副作用函数)calculateTotalCount
    • activeEffect=calculateTotalCount等待触发get收集Effect
    • 执行calculateTotalCount
  2. 触发count读取,收集calculateTotalCount作为count的Effect
  3. count值大于0,触发price的读取 ,收集calculateTotalCount作为price的Effect。(也再次触发了count的读取)
  4. calculateTotalCount执行完毕
  5. 更新count=0,触发trigger,取出并执行calculateTotalCount,触发count读取,calculateTotalCount执行完毕
  6. 更新price=20,触发trigger,取出并执行calculateTotalCount,触发count读取,calculateTotalCount执行完毕
  7. 更新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);
}

运行结果:

Xnip2022-06-07_09-01-46.jpg日志中可以看到:

  1. count=0时已经没有触发price,达到目标
  2. 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();
    });
}

Xnip2022-06-07_09-15-49.jpg 这样看,问题就很清晰了,是trigger中一直在取出并执行Effect,为什么勒?
我们在执行副作用函数前先删除了Set中的Effect,然后又触发了getSet中又增加了这个Effect。且这个执行过程是在SetforEach中,就相当于:

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();
    });
}

Xnip2022-06-07_09-33-12.jpg

问题2: 无限循环导致栈溢出

在0.2版本代码上,我们增加一个副作用函数addPrefix

function addPrefix() {
    console.log('执行addPrefix')
    proxyData.name = '富途周边:' + proxyData.name;
    console.log('执行完毕');
}
window.onload = function() {
    effect(addPrefix);
}

Xnip2022-06-01_21-04-17.jpg

为什么会有这种错误?
从日志中可以看到,addPrefixgetset交替触发,直到栈溢出。我们期望是执行一次addPrefix、一次get name,一次set name,为什么会有这样的现象?
捋一下:

  1. 注册Effect addPrefix
    • activeEffect=addPrefix等待触发get收集Effect
    • 执行addPrefix
  2. addPrefix中先读取name,触发get
  3. get中触发track,将addPrefix存到了bucket
  4. addPrefix中设置name,触发trigger
  5. trigger中取出Effect,并执行addPrefix
  6. 循环 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');
        }
    });
}

Xnip2022-06-08_08-54-00.jpg 但是这样会影响正常的用例:

window.onload = function() {
    effect(calculateTotalCount);

    setTimeout(() => {
        console.log('\n\n更新count');
        proxyData.count = 0;
    }, 1000);
}

Effect应该被触发,但结果没有触发 Xnip2022-06-08_08-59-39.jpg 这是因为activeEffecteffect函数中赋值一次,只要没有新的副作用函数注册进来,就一直不变。我们可以在副作用函数执行后,就重置这个值

/** 
 * 注册副作用函数
 * @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的那行日志

Xnip2022-06-08_09-10-16.jpg

完整代码

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);
}