Vue3响应式系统设计与实现

前言

本文的灵感来源于霍春阳老师的 《Vue.js 设计与实现》 。如果你对 Vue 的响应式系统感到好奇,想知道 refreactive背后究竟有何魔力,那么本文将非常适合你。我们将以教学的视角,从零开始,一步步带你亲手实现一个简易而完整的响应式系统,让你彻底掌握其核心原理。

响应式系统的原理

vue的响应式变量是指通过特定的API获得的变量具有"响应式",即在变量的值发生变更的时候,会自动执行指定的副作用函数,是一种订阅-发布的模式。

Vue2是通过拦截原生对象的读取和设置行为,即Object.defineProperty递归劫持对象属性来实现的。这种方式的局限性在于只能对普通的对象进行拦截,对于数组这种不是简单的get/set就能覆盖所有行为的特殊对象则需要重写数组的方法,侵入性比较强,对于 Map Set等对象则没有实现响应式。

Vue3因为ES6的ProxyAPI的全面流行选择了使用这个API来做响应式对象的订阅-发布底层实现。简单来说,就是当一个响应式对象被访问时,我们要记录下是"谁"(我们称之为副作用函数)读取了"哪个对象"的"哪个属性"(或者是其他行为,比如数组长度,比如判断对象是否存在某个属性)。这样,当这个属性被修改时,我们就能精准地找到所有依赖它的"谁",并通知它们重新执行。理解了这个"订阅-发布"的核心思想后,现在,让我们用代码将它变为现实。

手撸一个简易的响应式系统

订阅-发布模式

这是一个简单的代理对象操作:

javascript 复制代码
const obj = {
    num: 1
}
const proxyObj = new Proxy(obj, {
    get (target, key, reviver) {
        console.log(`get key:${key}`)
        return target[key]
    },
    set (target, key, value, reviver) {
        console.log(`set key ${key}:${value}`)
        target[key] = value
        return true
    }
})
console.log(proxyObj.num)
proxyObj.num++

运行起来可以看到有如下输出:

vbnet 复制代码
get key:num
1
get key:num
set key num:2

可以发现访问和改变都有被拦截到,接下来我们先实现访问的收集和通知

js 复制代码
const proxyObj = new Proxy(obj, {
    get (target, key, reviver) {
        console.log(`get key:${key}`)
        let proxyObjectKesMap = proxyObjectMap.get(target)
        if (!proxyObjectKesMap) {
            proxyObjectKesMap = new Map()
            proxyObjectKesMap.set(key, ["访问者"])
            proxyObjectMap.set(target, proxyObjectKesMap)
        }
        return Reflect.get(target, key, reviver)
    },
    set (target, key, value, reviver) {
        console.log(`set key ${key}:${value}`)
        target[key] = value
        const proxyObjectKesMap = proxyObjectMap.get(target)
        const visitorList = proxyObjectKesMap?.get(key) ?? []
        console.log("通知访问者", visitorList)
        return Reflect.set(target, key, value, reviver)
    }
})

通过上面的代码,我们成功拦截了 get 和 set 操作。现在关键问题来了:我们用一个硬编码的字符串 "访问者" 作为占位,但这个"访问者",也就是上文提到的"谁",在真实的 Vue 应用里究竟应该是什么呢?

watchEffect

仔细想想vue里的响应式场景,watch是注册回调函数,computed也是回调函数,而页面的渲染其实也是渲染函数,所以这里的访问者是一个函数,我们称他副作用函数我们的核心目标是:当副作用函数执行时,让它访问到的所有响应式数据都"记住"这个函数;当这些数据变更时,再把"记住"的函数重新执行一遍。

我们先把代理行为封装成一个函数 reactive

js 复制代码
// 用一个新的函数来创建响应式对象
function reactive (obj) {
    return new Proxy(obj, {
        get (target, key, reviver) {
            console.log(`get key:${key}`)
            // 如果Get的时候有在副作用函数内,就将其作为【访问者】记录访问关系
            if (currentEffectFn) {
                let proxyObjectKesMap = proxyObjectMap.get(target)
                if (!proxyObjectKesMap) {
                    proxyObjectKesMap = new Map()
                    proxyObjectMap.set(target, proxyObjectKesMap)
                }
                // 改用Set 防止重复注册回调
                let visitorSet = proxyObjectKesMap.get(key)
                if (!visitorSet) {
                    visitorSet = new Set()
                    proxyObjectKesMap.set(key, visitorSet)
                }
                visitorSet.add(currentEffectFn)
            }
            return Reflect.get(target, key, reviver)
        },
        set (target, key, value, reviver) {
            console.log(`set key ${key}:${value}`)
            target[key] = value
            const proxyObjectKesMap = proxyObjectMap.get(target)

            if (proxyObjectKesMap) {
                const visitorSet = proxyObjectKesMap.get(key)
                if (visitorSet) {
                    visitorSet.forEach(visitor => {
                        visitor?.()
                    });
                }
            }
            return Reflect.set(target, key, value, reviver)
        }
    })
}

然后定义一个注册副作用回调的函数 watchEffet

js 复制代码
function watchEffect (fn) {
    currentEffectFn = fn
    // 我们希望注册的时候直接执行
    fn()
    currentEffectFn = null
}

到这里我们就完成了一个watchEffect了。

基于此,我们可以实现一个简易的渲染函数,将页面上的某个元素绑定我们的响应式变量,从而实现响应式渲染:

js 复制代码
function render () {
    watchEffect(() => {
        document.querySelector("#app").textContent = proxyObj.num
    })
}
render()

这个时候我们改变 proxyObj.num的值,页面就会自动重新渲染对应的值。

清除过期的副作用

我们有如下的分支切换场景:

js 复制代码
const proxyObj = reactive({
    show: true,
    num: 1
})
function render () {
    watchEffect(() => {
        document.querySelector("#app").textContent = proxyObj.show ? proxyObj.num : '--'
    })
}

当我们手动更改 proxyObj.show=false 后再次变更proxyObj.num的值会发现渲染函数还是执行了,但其实这个时候渲染函数已经没有访问proxyObj.num了,这是过期的依赖了,我们应该及时清理。我们可以在执行副作用函数的时候,先把之前包含这个副作用的依赖集合里删掉这个副作用。

js 复制代码
function watchEffect (fn) {
    const effectFn = () => {
        currentEffectFn = effectFn
        cleanup(effectFn)
        fn()
    }

    effectFn()

    currentEffectFn = null
}
// 清理过期的副作用
function cleanup (fn) {
    if (Array.isArray(fn.deps)) {
        fn.deps.forEach(depSet => {
            depSet.delete(fn)
        })
    }
    fn.deps = []
}
// 然后在get拦截的时候给deps添加回调
// ...
get (target, key, reviver) {
            console.log(`get key:${key}`)
            // 如果Get的时候有在副作用函数内,就将其作为【访问者】记录访问关系
            if (currentEffectFn) {
                let proxyObjectKesMap = proxyObjectMap.get(target)
                if (!proxyObjectKesMap) {
                    proxyObjectKesMap = new Map()
                    proxyObjectMap.set(target, proxyObjectKesMap)
                }
                // 改用Set 防止重复注册回调
                let visitorSet = proxyObjectKesMap.get(key)
                if (!visitorSet) {
                    visitorSet = new Set()
                    proxyObjectKesMap.set(key, visitorSet)
                }
                visitorSet.add(currentEffectFn)
                currentEffectFn.deps.push(visitorSet)
            }
            return Reflect.get(target, key, reviver)
        },

这个时候我们已经解决副作用清理的问题了,但是我们运行起来却发现无限死循环了,问题在于set的时候我们在遍历:

js 复制代码
if (visitorSet) {
	visitorSet.forEach(visitor => {
		visitor?.()
	});
}

这里的 visitor即副作用函数,执行的时候又在清理回调,然后又在add

js 复制代码
// cleanup的时候对这个Set进行删除
depSet.delete(fn)
// get拦截的时候对这个Set进行添加
visitorSet.add(currentEffectFn)

问题就出在,我们正在遍历 visitorSet 的同时,又在内部通过 cleanup 函数修改了它(delete 操作),同时 get 拦截器又在向它 add。在一个循环中同时增删同一个 Set的元素,会导致迭代器混乱,从而产生无限循环。

Ecma规范的说明是:在调用foreach遍历Set集合的时候,如果一个值直接被访问过了,但是该值被删除并重新添加到集合,如果此时遍历还没有结束,那么该值就会重新被访问。

解决方法很简单:我们遍历它的一个副本,而不是它本身。

js 复制代码
const forEachSet = new Set(visitorSet)
forEachSet.forEach(visitor => {
	visitor?.()
});

为了让代码更清晰,也为了与社区和源码中的术语保持一致,我们对变量进行一次重命名,这不会改变任何逻辑,但能让代码的意图更专业、更明确:

  • proxyObjectKesMap 重命名为 bucket
  • bucket的取出的Map我们成为 depsMap
  • depsMap中的副作用回调函数我们从 visitorSet改为 effectSet
  • reactive函数的get/set拦截行为抽出来作为独立的函数 tracktrigger

现在完整的代码是:

js 复制代码
const bucket = new WeakMap()

let currentEffectFn

function watchEffect (fn) {
    currentEffectFn = fn
}

function track (target, key) {
    // 如果Get的时候有在副作用函数内,就将其作为【访问者】记录访问关系
    if (currentEffectFn) {
        let depsMap = bucket.get(target)
        if (!depsMap) {
            depsMap = new Map()
            bucket.set(target, depsMap)
        }
        // 改用Set 防止重复注册回调
        let effectSet = depsMap.get(key)
        if (!effectSet) {
            effectSet = new Set()
            depsMap.set(key, effectSet)
        }
        effectSet.add(currentEffectFn)
        currentEffectFn.deps.push(effectSet)
    }
}

function trigger (target, key) {
    const depsMap = bucket.get(target)

    if (depsMap) {
        const effectSet = depsMap.get(key)
        if (effectSet) {
            const forEachSet = new Set(effectSet)
            forEachSet.forEach(effect => {
                effect?.()
            });
        }
    }
}

// 用一个新的函数来创建响应式对象
function reactive (obj) {
    return new Proxy(obj, {
        get (target, key, reviver) {
            track(target, key)
            return Reflect.get(target, key, reviver)
        },
        set (target, key, value, reviver) {
            trigger(target, key)
            return Reflect.set(target, key, value, reviver)
        }
    })
}

// 注册一个副作用回调
// 在副作用函数内访问响应式对象
function watchEffect (fn) {
    const effectFn = () => {
        currentEffectFn = effectFn
        cleanup(effectFn)
        fn()
    }

    effectFn()

    currentEffectFn = null
}
// 清理过期的副作用
function cleanup (fn) {
    if (Array.isArray(fn.deps)) {
        fn.deps.forEach(depSet => {
            depSet.delete(fn)
        })
    }
    fn.deps = []
}

嵌套的副作用

接下来我们接着进行边界条件的处理,我们看如下的代码:

js 复制代码
const user = reactive({
    show: true,
    name: 'jack',
    age: 18
})


watchEffect(() => {
    console.log('外层effect执行');
    watchEffect(() => {
        console.log('内层effect执行');
        console.log(user.age);
    })
    console.log(user.name);

})

我们期望的是修改user.age会触发内层effect的执行,而修改user.name会触发外层effect的执行,从而触发内层effect的执行。

实际上修改user.age符合预期,但是修改user.name却一点反应都没有,原因就出在我们的watchEffect函数上:

js 复制代码
function watchEffect (fn) {
    const effectFn = () => {
        currentEffectFn = effectFn
        cleanup(effectFn)
        fn()
    }

    effectFn()
    // 内层的effect执行完毕后把 `currentEffectFn` 置空了
    currentEffectFn = null
}

我们希望执行内层effect的时候 currentEffectFn是内层,而当内层effect执行完毕,调用栈回到外层effect的时候 currentEffectFn是外层。显然,这是得用栈的数据结构解决:

js 复制代码
// 激活的副作用函数栈
const currentEffectFnList = []
let currentEffectFn

function watchEffect (fn) {
    const effectFn = () => {
        currentEffectFnList.push(effectFn)
        currentEffectFn = effectFn
        cleanup(effectFn)
        fn()
        currentEffectFnList.pop()
        currentEffectFn = currentEffectFnList.length > 0
            ? currentEffectFnList[currentEffectFnList.length - 1]
            : null
    }
    effectFn()
}

无限递归

假设我们在副作用函数内对值进行修改:

js 复制代码
watchEffect(() => {
    user.age++
    console.log(user.age)
})

一跑起来马上报错:Uncaught RangeError: Maximum call stack size exceeded 堆栈溢出。

这是因为我们在副作用里既访问了响应式变量又更改了响应式变量,流程大体是:在副作用里访问了 user.age,此时会收集当前的副作用到 age对应的依赖里,然后接下来修改 user.age的值,这个时候会马上触发当前的副作用,但是当前的副作用还没执行完毕,这样子会无限递归调用,从而导致堆栈溢出。解决思路是触发副作用函数的时候判断一下要执行的副作用函数是否为当前正在执行的副作用,如果是则不执行:

JS 复制代码
function tigger (target, key) {
    const depsMap = bucket.get(target)

    if (depsMap) {
        const effectSet = depsMap.get(key)
        if (effectSet) {
            const forEachSet = new Set(effectSet)
            forEachSet.forEach(effect => {
                if (effect !== currentEffectFn) {
                    effect?.()
                }
            });
        }
    }
}

调度执行

vue3的watch是可以通过传入一个配置对象指定副作用函数的执行时机的,我们也可以加入调度执行的机制。

所谓的调度,就是在 trigger 触发的时候不立刻执行对应的回调函数,而是在调度器内执行。比如说当同一个副作用函数被多次触发(例如,一个响应式对象的多个属性被连续修改),调度器可以将这些重复的执行请求进行合并,确保它在整个事件循环中只执行一次。并且,很多时候我们都希望在微任务队列执行。

首先是把配置挂载到副作用函数的属性上:

js 复制代码
function watchEffect (fn, options = {}) {
    const effectFn = () => {
        currentEffectFnList.push(effectFn)
        currentEffectFn = effectFn
        cleanup(effectFn)
        fn()
        currentEffectFnList.pop()
        currentEffectFn = currentEffectFnList.length > 0
            ? currentEffectFnList[currentEffectFnList.length - 1]
            : null
    }
    // 把配置挂载到副作用函数的属性上
    effectFn.options = options
    effectFn()
}

然后在调度的时候根据配置执行:

js 复制代码
function trigger (target, key, value, reviver) {
    console.log(`set key ${key}:${value}`)
    target[key] = value
    const depsMap = bucket.get(target)

    if (depsMap) {
        const effectSet = depsMap.get(key)
        if (effectSet) {
            const forEachSet = new Set(effectSet)
            forEachSet.forEach(effect => {
                if (effect !== currentEffectFn) {
                    // 如果配置有调度器,那么用调度器执行副作用
                    if (effect.options?.scheduler) {
                        effect.options?.scheduler?.(effect)
                    } else {
                        effect?.()
                    }
                }
            });
        }
    }
    return Reflect.set(target, key, value, reviver)
}

我们可以试试配置在微任务队列执行回调函数的执行:

js 复制代码
const user = reactive({
    show: true,
    name: 'jack',
    age: 18
})

watchEffect(() => {
    console.log('effect', user.age)
}, {
    scheduler: (fn) => {
        Promise.resolve().then(fn)
    }
})

user.age++
user.age++
console.log('ticket');

运行代码,应该会得到

复制代码
ticket
effect 20
effect 20

的输出,这就说明副作用是在微任务队列执行的,但是我们还需要进一步优化,合并重复的副作用:

js 复制代码
const jobQue = new Set()
let isRunning = false
function flushJob () {
    if (isRunning) {
        return
    }
    isRunning = true
    Promise.resolve().then(() => {
        jobQue.forEach(cb => cb?.())
    }).finally(() => {
        isRunning = false
    })
}

watchEffect(() => {
    console.log('effect', user.age)
}, {
    scheduler: (fn) => {
        jobQue.add(fn)
        flushJob()
    }
})

最后effect 只打印了一次,尽管我们连续两次修改了 user.age,但因为我们的调度器使用了 Set 来存储待执行的任务,重复的副作用函数被自动去重了。最终,在微任务阶段,它只被执行了一次,并且读取到的是最新的值20。这就是调度器在性能优化(合并更新)上的威力。

计算属性和lazy

计算属性指的是依赖其他响应式数据派生 出来的值,它会自动追依赖并在依赖变化时缓存并重新计算结果。

比如:

js 复制代码
const user = reactive({
    name: 'jack',
    age: 18
})

const userInfo = computed(() => {
    return `user name:${user.name},age:${user.age}`
})

在这个例子中,userInfo 就是一个计算属性。它的值依赖于 user.nameuser.age。计算属性具有懒加载 特性:在首次被访问前不会执行;同时具备缓存机制:只要依赖未发生变化,多次访问都会直接返回缓存的结果,避免重复计算。

要实现这几个特性,我们首先需要让副作用函数懒加载并返回值:

js 复制代码
function watchEffect (fn, options = {}) {
    const effectFn = () => {
        currentEffectFnList.push(effectFn)
        currentEffectFn = effectFn
        cleanup(effectFn)
        fn()
        currentEffectFnList.pop()
        currentEffectFn = currentEffectFnList.length > 0
            ? currentEffectFnList[currentEffectFnList.length - 1]
            : null
    }
    // 把配置挂载到副作用函数的属性上
    effectFn.options = options
    if (options.lazy) {
        return effectFn
    } else {
        effectFn()
    }
}

接着我们实现 computed

js 复制代码
function computed (getter) {
    const effectFn = watchEffect(getter, {
        lazy: true
    })
    const obj = {
        get value () {
            return effectFn()
        }
    }
    return obj
}

computed接收一个getter,返回一个包装的对象,访问这个对象的value的时候才执行副作用函数的执行最终返回getter的内容。我们尝试运行:

js 复制代码
const user = reactive({
    name: 'jack',
    age: 18
})
const userInfo = computed(() => {
    return `user name:${user.name},age:${user.age}`
})

watchEffect(() => {
    console.log(userInfo.value)
}, {
    scheduler: (fn) => {
        jobQue.add(fn)
        flushJob()
    }
})
user.age++

我们改变了 user.age的值,却发现副作用函数并没有重新执行。分析问题的原因,我们发现这是一个副作用嵌套的结构,计算属性内部有自己的副作用函数,响应式变量只收集了内部的副作用函数作为依赖,外部的副作用并没有被收集,解决这个问题其实也很简单,因为外部的副作用函数的读取了我们包装的一个对象,我们可以手动建立依赖关系:

但是,当我们运行代码,修改 user.age 后,会发现 console.log(userInfo.value) 并没有再次执行,这是为什么呢?

让我们来分析一下:

  1. watchEffect 依赖的是 userInfo.value
  2. userInfo.valueget 触发了 computed 内部的effectFn
  3. effectFn 依赖的是 user.nameuser.age
  4. user.age 变化时,它只会触发 computed 内部的 effectFn,而外部的 watchEffect 对此一无所知。依赖链在这里断开了。要修复它,我们就必须手动将内外两条依赖链连接起来。
js 复制代码
function computed (getter) {
    const effectFn = watchEffect(getter, {
        lazy: true,
        scheduler () {
            trigger(obj, 'value')
        }
    })
    const obj = {
        get value () {
            track(obj, 'value')
            return effectFn()
        }
    }
    return obj
}

我们在计算属性被访问的时候建立依赖,在内部副作用函数调度的时候触发依赖追踪,如此一来我们便实现了计算属性正确的依赖关系。目前的结构也很方便我们实现一个缓存,我们希望只要是依赖未发生变化就不要每次都执行:

js 复制代码
function computed (getter) {
    let value
    let dirty = true
    const effectFn = watchEffect(getter, {
        lazy: true,
        scheduler () {
            dirty = true
            trigger(obj, 'value')
        }
    })
    const obj = {
        get value () {
            track(obj, 'value')
            if (dirty) {
                value = effectFn()
                dirty = false
            }
            return value
        }
    }
    return obj
}

我们加入 value的变量用于缓存的值, dirty用于表示缓存是否失效,默认没有缓存,所以默认值是 true,之后执行过副作用会给 value赋值缓存并给dirty设置 false表示缓存生效,而当内部的副作用执行的时候就是缓存失效的时机,我们在scheduler调度函数内给他赋值了 true,这样子下次读取计算属性的时候就需要重新获取值。

总结

本文先是介绍了Vue的响应式系统原理是订阅发布的模式,接着对比了Vue3和旧版Vue的响应式系统的原理区别,阐述了Proxy相比于对象拦截的优点是代理范围更广。

跟随本文,大家已经可以亲手实现了一个功能虽简、但五脏俱全的响应式系统。我们从 Proxy 出发,实现了依赖收集(track)和派发更新(trigger),用栈解决了嵌套副作用问题,用调度器优化了更新时机,最后还实现了强大的 computed

希望通过这个过程,Vue 响应式对大家来说不再是一个黑盒。接下来,大家可以带着这份理解,去挑战阅读 Vue 的源码,或者尝试实现 watchreadonly 等更丰富的功能。

相关推荐
Jenna的海糖9 分钟前
Vue 项目首屏加载速度优化
前端·javascript·vue.js
前端梭哈攻城狮15 分钟前
js计算精度溢出,自定义加减乘除类
前端·javascript·算法
北辰alk18 分钟前
React JSX 内联条件渲染完全指南:四招让你的UI动态又灵活
前端
前端小巷子20 分钟前
最长递增子序列:从经典算法到 Vue3 运行时核心优化
前端·vue.js·面试
zayyo20 分钟前
深入解读 SourceMap:如何实现代码反解与调试
前端
龙在天23 分钟前
以为 Hooks 是银弹,结果是新坑
前端
wayhome在哪33 分钟前
前端高频考题(css)
前端·css·面试
wayhome在哪42 分钟前
前端高频考题(html)
前端·面试·html
冰糖雪梨dd1 小时前
vue在函数内部调用onMounted
前端·javascript·vue.js
CC__xy1 小时前
《ArkUI 记账本开发:状态管理与数据持久化实现》
java·前端·javascript