响应式数据系统 二

代码分支切换导致的遗留的副作用函数

接着上回说到,我们已经完成了一个简单的响应式数据系统。

这回我们来发现一些问题,并提出解决方法。

来看下面的用例:

<script src="./index.js"></script>
<script>
    const span = document.querySelector('span')
    const p = document.querySelector('p')

    const weather = reactive({
        isRaining: true,
        raincoat: "雨衣",
        sunscreen: "防晒霜"
    })
    
    effect(()=>{
        p.textContent = weather.isRaining ? weather.raincoat : weather.sunscreen
        console.log('当前激活的代码分支:' + (weather.isRaining ? '下雨天':'晴天'))
    })

    setTimeout(() => {
        weather.isRaining = false
    }, 2000);


    setTimeout(() => {
        weather.raincoat = '雨伞'
    }, 4000);
</script>

weather定义了一个响应式数据,有三个属性:isRaining,raincoat、sunscreen。

并且注册了一个副作用函数,用于根据isRaining的值显示p元素的内容。

可以发现,由于三元表达式的缘故,track函数只能为特定代码分支下的响应式数据收集到副作用函数:

当 isRaining 为 true 时,为 weather.isRaining 和 weather.raincoat 收集此副作用函数;

当 isRaining 为 false 时,为 weather.isRaining 和 weather.sunscreen 收集此副作用函数。

比如说,当 isRaining 起初为 true ,虽然effect里使用了weather.sunscreen,但是代码分支中,没有执行到那行代码,所以没有被track收集到。
当 weather.sunscreen 变化时 ,不会触发此副作用函数。这是没有问题的,因为副作用函数不会用到这个数据。

而且,当 isRaining 发生改变后,也就是代码分支发生变化后,track也总能为应有的响应式数据收集到副作用函数,这似乎没有问题。

但是问题出现在,当 isRaining 发生变化后,也就是代码分支发生变化后,起初被收集副作用函数的某些响应式数据,可能在新的代码分支不再需要,而它和副作用函数的关联依然存在,我们称之为“遗留的副作用函数”。

这就导致,当这些不被依赖的响应式数据再次发生变化时,依然会触发副作用函数,而这种触发是不必要的,因为这个副作用函数不依赖此响应式数据,调用是没有效果的。这不是我们希望发生的。

虽然这不会造成实质性的错误,但是可能会造成性能问题,尤其是在代码量大和响应式变量频繁改变的情况下。

我们希望,副作用函数的每次调用都是必要的。

解决方法是,在每次调用副作用函数之前,也就是为响应式数据收集副作用函数之前,先为响应式数据清理掉所有有关此副作用函数的依赖,再让这个副作用函数被应有的响应式数据收集。

这样每次响应式数据每次触发这个副作用函数,都是上次以已经重新收集的全新的依赖集合。

关键在于清理cleanup函数,我们可以我在每次effectFunction中,先执行cleanup函数清理掉以前的依赖,再让activeFunctin = effectFunction,最后调用副作用函数,触发依赖收集。

问题在于,由于bucket的数据结构,我们可以很方便的找到,某个响应式数据关联的副作用函数,而不容易找到某个副作用函数所有依赖这个函数的响应式数据。

我们可以给effectFunction添加一个depsList数组,用于保存与此副作用函数相关的响应式数据的deps集合。

在于在收集依赖时,先往副作用函数中推入deps集合。然后在cleanup函数中,对于depsList里的每一个deps集合,清理掉其中的自己!

这样问题看似就解决了。但是trigger函数中还有一个问题。每次为某个响应式数据触发deps中的每一个副作用函数时,会先导致“清理函数清理deps的副作用函数、触发收集添加deps的副作用函数、再次被Set遍历到,再次清理函数”的无限循环。

解决办法很简单,在执行deps的副作用函数之前,先将其保存另一个集合,对这个集合进行遍历执行。

这样就避免了直接操作原deps导致的无限循环问题。

下面是完整的代码:

const bucket = new WeakMap()

let activeEffect = null

function reactive(obj){
    return new Proxy(obj, {
        get:(targe, key) => {
            track(targe, key)
            return targe[key]
        },
        set:(targe, key, newValue) => {
            targe[key] = newValue
            trigger(targe, key)
        }
    })
}

function track(targe, key){
    if(!activeEffect) return
    let depsMap = bucket.get(targe)
    if(!depsMap){
        depsMap = new Map()
        bucket.set(targe, depsMap)
    }
    let deps = depsMap.get(key)
    if(!deps){
        deps = new Set()
        depsMap.set(key, deps)
    }
    deps.add(activeEffect)

    activeEffect.depsList.push(deps) //在副作用函数的 deps属性中保存 与之依赖 的对象属性
    console.log(key+'的副作用函数收集完毕')
}

function trigger(target, key){
    let depsMap = bucket.get(target)
    if(!depsMap) return
    let deps = depsMap.get(key)
    if(deps.size === 0) return
    const depsToRun = new Set(deps) //新建一个Set集合,避免直接操作原集合 ‘清除、收集’ 无限循环。
    console.log(depsToRun)
    depsToRun.forEach(fn =>fn());
    console.log(key+'的副作用函数执行完毕')
}

function cleanup(effectFunction){
    if(effectFunction.depsList){
        //对于depsList里的每一个deps(Map),清理掉其中的 自己!
        effectFunction.depsList.forEach(deps => {
            deps.delete(effectFunction)
            deps.delete(effectFunction)
        })
        effectFunction.depsList.length = 0
        console.log('副作用函数的所有依赖清理成功')
    }
}

function effect(fn){
    function effectFunction() {
        cleanup(effectFunction) //放在 effectFunction 内,确保每次 执行副作用函数都能清除
        activeEffect = effectFunction
        fn()
    }
    effectFunction.depsList = [] //为effectFunction添加一个数组 保存 相关的响应式数据
    effectFunction()
}

嵌套的effect函数

当我们在一个effect函数内部创建另一个effect函数时,就形成了嵌套的effect函数。

这种情况时可能的,因为组件是嵌套的,所以为这些组件创建的响应式数据也是变化的。

主要问题是,当执行到内部的副作用函数时,会将 activeEffect 修改为内部的副作用函数。当继续执行到外部副作用函数的后续代码时,会导致后面的响应式数据错误的收集内部的副作用函数。

effect(() => {
    console.log('Outer effect running');
    effect(() => {
      console.log('Inner effect running');
      console.log('value:', data.value);
    });
    data.count++; //data.count错误地收集内部effect
  });

解决办法十分巧妙,就是使用一个栈来维护正在执行的副作用函数,称为effect栈。我们可以用数组的push和pop方法实现栈的行为。

在副作用函数执行时,先将当前副作用函数压入栈中,待副作用函数执行完毕后,将其在栈中弹出,始终让activeEffect 函数指向栈顶的函数。

let activeEffect;

const effectStack = [];//effect栈

function effect(fn){
    function effectFunction() {
        cleanup(effectFunction)
        activeEffect = effectFunction
        effectStack.push(effectFunction) //在副作用函数执行前 将副作用压入栈中
        fn()
        effectStack.pop() // 弹出栈顶的副作用函数
        activeEffect = effectStack[effectStack.length - 1] //恢复activeEffect的上一个指向
    }
    effectFunction.depsList = []
    effectFunction()
}

这样就能让activeEffect一直指向正在执行的副作用了,栈顶保存的是内层副作用函数,栈底保存的是外层副作用函数。这样响应式数据就只会收集直接读取其值的副作用函数,从而避免发生错乱。

触发的副作用函数和执行中的副作用函数相同导致的无限递归循环

举一个简单的例子说明,obj是一个响应式对象,注册一个副作用函数:

effect(()=>obj.foo++)

这个操作会引起栈溢出!

obj.foo++等价于obj.foo = obj.foo + 1,这实际上会先后触发obj.foo的读取和设置。

当读取时,会将这个()=>obj.foo++这个函数收集到obj.foo的依赖集合中,接着触发设置操作,又会立即执行obj.foo的依赖集合中的此函数,如此这个函数还没有结束就会再次执行,导致无限递归,最终产生栈溢出。

“清理依赖,activeEffect = effectFunction,执行函数,’读取操作’触发obj.foo收集此副作用函数,’设置操作‘触发执行此函数,清理依赖...”无限循环。

解决方法很简单,不难可以发现,当触发的副作用函数和执行中的副作用函数相同时,应该跳过这次tigger函数中这个副作用函数的执行。

只需要在tigger函数开头添加一个判断,

function trigger(target, key){
    let depsMap = bucket.get(target)
    if(!depsMap) return
    let deps = depsMap.get(key)
    if(deps.size === 0) return
    const depsToRun = new Set(deps) 
    console.log(depsToRun)
    depsToRun.forEach(fn =>{
        if(fn === effectFunction) return// 跳过当前正在执行的副作用函数
        fn()
    });
}

这样我们就发现并解决了可能存在的三个问题,让我们的响应式数据系统更加健康茁壮!
最后,补充一下数据结构的示意图:
2025-03-15T05:55:45.png

添加新评论