vue3源码解析 - 完整解决方案与实战教程
在大型 Vue 3 项目中,随着组件数量膨胀和响应式数据层级加深,很多开发者会遇到一个诡异的现象:页面首次渲染正常,但当某个深层嵌套的响应式对象属性发生变化时,视图要么不更新,要么更新了但副作用函数被重复执行,导致性能断崖式下跌。更让人头疼的是,当你想通过阅读 Vue 3 源码去定位问题时,会发现 reactivity 包中 effect、track、trigger 之间的调用链路被 WeakMap 层层包裹,加上 Proxy 的拦截器,初学者很容易在源码迷宫里迷失方向。本文将从真实业务场景出发,带你逐层扒开 Vue 3 响应式系统的源码实现,并给出可落地的排坑方案。
问题现象:数据变了,视图却没动
先看一个典型的业务代码片段。假设我们在做一个后台管理系统的权限配置页,数据结构如下:
// 业务代码:一个嵌套三层的响应式对象
import { reactive, watchEffect } from 'vue'
const state = reactive({
user: {
profile: {
permissions: ['read']
}
}
})
// 副作用:监听权限变化
watchEffect(() => {
console.log('权限变更:', state.user.profile.permissions)
})
// 直接修改数组索引
state.user.profile.permissions[0] = 'write' // 控制台没有输出!
你会发现,watchEffect 并没有重新执行。核心痛点在于:Vue 3 的响应式系统对数组索引赋值和对象新增属性的处理,与 Vue 2 的 Object.defineProperty 时代有本质区别,但很多开发者仍然用旧思维去操作数据。
原因分析:从源码看 track 与 trigger 的盲区
要理解这个问题,必须深入 @vue/reactivity 包的源码。Vue 3 使用 Proxy 代理整个对象,在 baseHandlers.ts 中定义了 get 和 set 拦截器。
当我们执行 state.user.profile.permissions[0] = 'write' 时,触发的是数组索引的 set 拦截。在源码中,set 拦截器会调用 trigger 函数,而 trigger 内部会根据 targetMap(一个 WeakMap)去查找依赖。关键点来了:
track阶段:在get拦截器中,Vue 3 会对数组的length属性和索引进行依赖收集,但收集的 key 是字符串化的索引(如'0')。trigger阶段:当通过索引赋值时,trigger会触发对应 key 的依赖,同时还会触发length的依赖(如果长度变化)。- 但问题在于:如果
permissions数组是在reactive对象内部被访问的,而watchEffect中访问的是state.user.profile.permissions,那么track收集的是permissions这个属性本身的依赖,而不是数组内部索引的依赖。修改索引时,trigger只会触发数组内部索引的依赖,不会触发父级属性的依赖。
此外,Vue 3 源码中对于 ref 和 reactive 的嵌套处理也有微妙的差异。如果你在 watchEffect 中直接访问了 state.user.profile.permissions,那么 track 会递归收集 user、profile、permissions 三个属性的依赖。但修改数组索引时,trigger 只会触发 permissions 数组内部的依赖,而不会触发 user 和 profile 的依赖。这就是为什么视图没有更新的根本原因。
解决方案:源码级排坑与完整代码
针对上述问题,我们提供三种从源码层面出发的解决方案,按推荐程度排序。
方案一:使用 toRefs 或 computed 解构深层属性
最直接的方式是避免直接修改数组索引,而是通过 splice 或重新赋值整个数组来触发父级依赖。但更优雅的做法是在 watchEffect 中显式访问数组的 length 或使用 toRefs 解构。
import { reactive, watchEffect, toRefs } from 'vue'
const state = reactive({
user: {
profile: {
permissions: ['read']
}
}
})
// 方案一:使用 toRefs 解构深层属性,确保依赖被正确收集
const { user } = toRefs(state)
watchEffect(() => {
// 这里通过 .value 访问,会触发 user 的 get 拦截
// 同时访问 permissions 数组,会收集数组索引和 length 的依赖
console.log('权限变更:', user.value.profile.permissions)
})
// 使用 splice 修改数组,会触发 length 和索引的 trigger
user.value.profile.permissions.splice(0, 1, 'write') // 控制台输出:权限变更: ['write']
方案二:使用 shallowReactive 配合手动触发
如果业务场景中数组非常大,且不需要深层响应式,可以使用 shallowReactive 减少 track 的递归深度,然后通过 triggerRef 手动触发更新。
import { shallowReactive, triggerRef, watchEffect } from 'vue'
// 使用 shallowReactive 只代理第一层
const state = shallowReactive({
user: {
profile: {
permissions: ['read']
}
}
})
watchEffect(() => {
// 这里只会收集 state.user 的依赖
console.log('权限变更:', state.user.profile.permissions)
})
// 修改深层属性后,手动触发
state.user.profile.permissions[0] = 'write'
triggerRef(state) // 强制触发 state 的依赖更新
方案三:源码级调试,定位 track 与 trigger 的断点
如果上述方案都无法解决,说明你的场景涉及更复杂的依赖关系。此时需要直接在源码中打断点。步骤如下:
- 在
node_modules/@vue/reactivity/dist/reactivity.esm-browser.js中找到track和trigger函数。 - 在
track函数中,找到targetMap.get(target)这一行,打印target和key,确认依赖是否被正确收集。 - 在
trigger函数中,找到depsMap.get(key)这一行,打印key和deps,确认触发时是否找到了对应的副作用函数。 - 如果发现
deps为空,说明track阶段没有收集到该 key 的依赖,需要检查watchEffect中是否真正访问了该属性。 - 如果
deps存在但未执行,检查effect的active标志位是否被置为false(常见于组件卸载后未清理的副作用)。
最后,附上一个完整的、经过源码验证的实战代码,解决嵌套数组响应式丢失问题:
import { reactive, watchEffect, nextTick } from 'vue'
// 正确做法:始终通过 reactive 对象访问深层属性
const store = reactive({
modules: {
permission: {
list: ['read']
}
}
})
// 在 watchEffect 中,显式访问 list 的 length 和索引
watchEffect(() => {
const list = store.modules.permission.list
// 触发 length 的 track
console.log('当前权限数量:', list.length)
// 触发每个索引的 track
list.forEach((item, index) => {
console.log(`索引 ${index}:`, item)
})
})
// 修改时使用 splice 或直接赋值整个数组
store.modules.permission.list.splice(0, 1, 'write')
// 等待 DOM 更新
nextTick(() => {
console.log('更新完成')
})
总结:Vue 3 源码解析不是玄学,核心就是 track 收集依赖、trigger 触发依赖。遇到响应式丢失,先检查 watchEffect 中是否真正访问了目标属性,再检查修改方式是否触发了正确的 trigger。掌握这两个断点,90% 的响应式问题都能迎刃而解。