Vue.js 3 渐进式实现之响应式系统——第五节:分支切换与 cleanup

往期回顾

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

分支切换与 cleanup

上一节中我们把依赖收集和触发的逻辑分别封装到 track 和 trigger 函数中,提升可读性和灵活性。

这一节我们来解决分支切换可能会产生遗留副作用函数的问题。

思路

分支切换

js 复制代码
const data = { ok: true, text: 'hello world' }
const obj = new Proxy(data, { /* ... */})

effect(function effectFn() {
    document.body.innerText = obj.ok ? obj.text : 'not'
})

在 effectFn 函数内部存在一个三元表达式,根据字段 obj.ok 值的不同会执行不同的代码分支,这就是所谓的分支切换。

遗留的副作用函数

拿上面这段代码来说,obj.ok 初始值为 true 时, effectFn 函数会 读取 obj.ok 和 obj.text 两个值,此时 effectFn 与响应式数据之间建立的联系如下:

然而当 obj.ok 的值变为 false, effectFn 内发生分支切换,此时 effectFn 函数只会读取 obj.ok,理想情况下 effectFn 不该被 obj.text 字段所对应的依赖集合收集:

通俗一点解释就是,当 obj.ok 为 false 时,effectFn 已经不读取 obj.text 了, obj.text 怎么变都跟 effectFn 没关系。然而我们现在实现的响应式系统,副作用函数一旦被某个字段的依赖集合收集就没法删除,因此哪怕现在 effectFn 和 obj.text 已经没关系了,当 obj.text 的值变化时, effectFn 还是会重新运行一次,这显然没必要。

cleanup

解决这个问题的思路很简单:每当副作用函数执行时,先把它从所有与之关联的依赖集合中删除,在执行的过程中重新进行一次依赖收集,把副作用函数重新添加到与之关联的依赖集合中。

解释

副作用函数执行一次之后读取了哪些依赖,就是这个副作用函数当前状态(分支)下真正需要的依赖。

还是上面那个例子,当 obj.ok 从 true 切换为 false,会触发 effectFn 重新执行一次。此次执行过程中 effectFn 只读取了 obj.ok 而没有读取 obj.text,那就说明当前状态下的 effectFn 只依赖了 obj.ok,只有 obj.ok 变化时才需要重新执行,至于 obj.text 爱怎么变怎么变跟它没关系。

代码实现层面需要解决的问题
  1. 目前我们的响应式系统直接收集副作用函数本身,依赖触发也是直接执行副作用函数。而现在在依赖触发时,我们需要先把副作用函数从相关联的依赖集合中删除,然后再运行并进行一次依赖收集。因此收集到集合中的不能再是副作用函数本身,而是需要再包装一个函数,包含上述依赖删除和再收集的逻辑。
  2. 副作用函数本身需要能知道哪些依赖集合收集了它,因此副作用函数也需要收集依赖集合。

代码

js 复制代码
// 用一个全局变量存储被注册的副作用函数
let activeEffect
// effect 函数用于注册副作用函数
function effect(fn) {
    // 包装真实副作用函数的函数,包含清除和再收集逻辑
    const effectFn = () => {
        // 调用 cleanup 完成清除工作
        cleanup(effectFn)
    
        // 在 effectFn 里,activeEffect 被赋值为它自己。
        // 而 activeEffect 只要不为空,就会执行依赖收集(见 track 函数第一句 if)。
        // 因此 effectFn 每次执行都会重新进行一次依赖收集,并且被收集的副作用函数就是它自己
        activeEffect = effectFn
        // 在 fn 这次执行中,effectFn 会作为副作用函数被收集到 fn 读取了的依赖的集合中
        fn()
    }

    // 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 = { text: 'hellow world' }
// 对原始数据的代理
const obj = new Proxy(data, {
    // 拦截读取操作
    get(target, key) {
        // 把副作用函数收集到桶中
        track(traget, 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 terget[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)
    // 如果这个对象的这个key没有被追踪的依赖,没有需要重新运行的副作用函数,啥也不干
    // 否则就把 effects 中的函数依次执行
    const effectsToRun = new Set(effects)
    // 为了避免无限循环
    effectsToRun.forEach(effectFn => effectFn())
}

已实现

通过副作用函数每次执行前先把它从所有与之关联的依赖集合中删除,然后在执行过程中重新进行一次依赖收集,我们解决了副作用函数内有代码分支时分支切换可能会产生遗留的副作用函数的问题。

缺陷/待实现

effect 是可能发生嵌套的,而我们现在的响应式系统还不支持这一点。

下一节我们将详细阐述什么情况下 effect 会发生嵌套调用,以及如何支持这一功能。

相关推荐
吴永琦(桂林电子科技大学)39 分钟前
HTML5
前端·html·html5
爱因斯坦乐42 分钟前
【HTML】纯前端网页小游戏-戳破彩泡
前端·javascript·html
恋猫de小郭1 小时前
注意,暂时不要升级 MacOS ,Flutter/RN 等构建 ipa 可能会因 「ITMS-90048」This bundle is invalid 被拒绝
android·前端·flutter
大莲芒5 小时前
react 15-16-17-18各版本的核心区别、底层原理及演进逻辑的深度解析--react17
前端·react.js·前端框架
木木黄木木7 小时前
html5炫酷3D文字效果项目开发实践
前端·3d·html5
Li_Ning217 小时前
【接口重复请求】axios通过AbortController解决页面切换过快,接口重复请求问题
前端
胡八一8 小时前
Window调试 ios 的 Safari 浏览器
前端·ios·safari
Dontla8 小时前
前端页面鼠标移动监控(鼠标运动、鼠标监控)鼠标节流处理、throttle、限制触发频率(setTimeout、clearInterval)
前端·javascript
再学一点就睡8 小时前
深拷贝与浅拷贝:代码世界里的永恒与瞬间
前端·javascript
CrimsonHu8 小时前
B站首页的 Banner 这么好看,我用原生 JS + 三大框架统统给你复刻一遍!
前端·javascript·css