Vue.js 3 渐进式实现之响应式系统——第七节:避免无限递归循环

往期回顾

  1. 系列开篇与响应式基本实现
  2. effect 函数注册副作用
  3. 建立副作用函数与被操作字段之间的联系
  4. 封装 track 和 trigger 函数
  5. 分支切换与 cleanup
  6. 嵌套的 effect 与 effect 栈

避免无限递归循环

上一节中我们让响应式系统支持了 effect 的嵌套调用。

本节我们来分析为什么会出现无限递归循环问题以及如何解决。

思路

为何会引起无限递归

js 复制代码
const data = { foo: 1 }
const obj = new Proxy(data, { /* ... */ })

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

运行上述代码会引起栈溢出:

js 复制代码
Uncaught RangeError: Maximum call stack size exceeded

问题的根本原因在于,这个副作用函数先读取了 obj.foo 的值,又设置了 obj.foo 的值。

如果一个副作用函数读取了一个依赖字段,那么当该字段被设置时,副作用函数重新执行。然而副作用函数又设置了该字段。

发现了吗,这种情况下,字段被设置会导致副作用函数执行,而执行副作用函数又会导致字段被设置。副作用函数执行 =》字段被设置 =》副作用函数执行 =》... =》如此无限递归造成栈溢出。

说白了就是,副作用函数执行到一半又要执行自己,这不纯纯无限递归。

至于为什么先写再读没事呢?因为副作用函数每次执行前首先会把它从所有与之关联的依赖集合中删除(见分支切换与 cleanup)。

副作用函数先设置字段,此时它并不在字段的依赖集合中(刚被剔除),不会触发更新,之后读取了才会被收集进依赖集合。整个过程中并未产生无限递归。

如何解决

方法也非常简单。

在 trigger 函数内,开始遍历执行依赖集合里所有副作用函数时,先获取一下 activeEffect。此时 activeEffect 存储的正是设置了这个字段,导致触发更新的副作用函数。因此一会不能再执行这个副作用函数了,不然就是自己 call 自己 无限递归。

说白了就是,一个函数执行到一半又要执行一些函数,那些函数里不能有它自己。自己 call 别人,call 很多别人都可以,绝不能 call 自己。

代码

js 复制代码
// 用一个全局变量存储被注册的副作用函数
let activeEffect

// 新增 effect 栈
const effectStack = []

// effect 函数用于注册副作用函数
function effect(fn) {
    // 包装真实副作用函数的函数,包含清除和再收集逻辑
    const effectFn = () => {
        // 调用 cleanup 完成清除工作
        cleanup(effectFn)
        
        // effectFn 每次执行都会重新进行一次依赖收集,并且被收集的副作用函数就是它自己
        activeEffect = effectFn

        // 调用真实副作用函数之前,先将当前 effect 压入栈中
        effectStack.push(effectFn)

        // 在 fn 这次执行中,effectFn 会作为副作用函数被收集到 fn 读取了的依赖的集合中
        fn()

        // 当前副作用函数执行完毕之后,弹出栈
        effectStack.pop()
        // 并把 activeEffect 还原为之前的值
        activeEffect = effectStack[effectStack.length - 1]
    }

    // effctFn.deps 数组用来储存该副作用函数的所有依赖集合
    effectFn.deps = []
    // 执行副作用函数
    effectFn()
}

function cleanup(effectFn) {
    // 遍历 deps 数组
    effectFn.deps.forEach( deps => {
        // 把副作用函数从集合中删除
        deps.delete(effectFn)
    })

    // 重置副作用函数的依赖集合,因为之后要再重新收集一次的
    effectFn.deps.length = 0
}

// 存储副作用函数的桶
const bucket = new WeakMap()

// 原始数据
const data = { text1: 'text1', text2: 'text2' }
// 对原始数据的代理
const obj = new Proxy(data, {
    // 拦截读取操作
    get(target, key) {
        // 把副作用函数收集到桶中
        track(target, key)
        // 返回属性值
        return target[key]
    },
    // 拦截设置操作
    set(target, key, newVal) {
        // 设置属性值
        target[key] = newVal
        // 把副作用函数从桶里取出并执行
        trigger(target, key)
        // 返回 true 代表设置操作成功
        return true
    }
})

// track 函数 在 get 拦截函数中被调用,用来追踪副作用函数
function track(target, key) {
    // 没有 activeEffect 直接 return
    if (!activeEffect) return target[key]

    // 根据 target 从"桶"中取得 depsMap,也是Map类型:key --> effects
    let depsMap = bucket.get(target)
    // 如果不存在 depsMap,就新建一个 Map 并与 target 关联
    if (!depsMap) {
        depsMap = new Map()
        bucket.set(target, depsMap)
    }

    // 再根据 key 从 depsMap 中取得 deps。
    // deps是一个 Set 类型,储存所有与当前 key 相关联的副作用函数
    let deps = depsMap.get(key)
    // 如果 deps 不存在,同样新建一个 Set 并与 key 关联
    if (!deps) {
        deps = new Set()
        depsMap.set(key, deps)
    }

    // 最后将当前激活的副作用函数添加到"桶"里
    deps.add(activeEffect)

    // 也将这个集合添加到副作用函数的 deps 数组中
    activeEffect.deps.push(deps)
}

// trigger 函数 在 set 拦截函数中被调用,用来触发更新
function trigger(target, key) {
    const depsMap = bucket.get(target)
    // 如果这个对象没有被追踪的依赖,没有需要重新运行的副作用函数,直接 return
    if (!depsMap) return

    const effects = depsMap.get(key)

    // 新建空集合存储本次触发更新要执行的副作用函数
    const effectsToRun = new Set()

    // 如果当前 activeEffect 在依赖集合里,本次触发更新不执行它
    effects?.forEach(effectFn => {
        if (effectFn !== activeEffect) {
            effectsToRun.add(effectFn)
        }
    })

    effectsToRun.forEach(effectFn => effectFn())
}

已实现

这一节中,我们在 trigger 函数遍历执行所有副作用函数前,增加一个判断条件,把当前的 activeEffect 从本次触发更新将要执行的副作用函数集合中筛出去,解决了当一个副作用函数先读再写同一个依赖字段时会产生的无限递归问题。

缺陷/待实现

调度执行指的是当响应式数据变化触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式。

下一节中我们来实现这一非常重要的功能。提前透露一下,computed 和 watch 都是基于响应式系统的可调度性才得以实现。

相关推荐
加减法原则25 分钟前
深入理解Vue 生命周期
vue.js
冴羽35 分钟前
SvelteKit 最新中文文档教程(19)—— 最佳实践之身份认证
前端·javascript·svelte
拉不动的猪37 分钟前
ES2024 新增的数组方法groupBy
前端·javascript·面试
前端极客探险家2 小时前
实现一个拖拽排序组件:Vue 3 + TypeScript + Tailwind CSS
css·vue.js·typescript·排序算法
奶球不是球2 小时前
vue3中pinia基本使用
vue.js·pinia
tryCbest2 小时前
Vue2-实现elementUI的select全选功能
前端·javascript·elementui
uhakadotcom2 小时前
Google Play SDK 接入指南:一步步轻松集成
javascript·面试·github
阿諪諪4 小时前
Vue知识点(5)-- 动画
前端·vue.js·nginx
Gazer_S4 小时前
【lodash的omit函数详解 - 从入门到精通】
javascript·状态模式
DarkLONGLOVE4 小时前
解锁 JavaScript 技能:全面掌握自定义属性的奥秘
前端·javascript