vue3源码解析 - 完整解决方案与实战教程

在大型 Vue 3 项目中,随着组件数量膨胀和响应式数据层级加深,很多开发者会遇到一个诡异的现象:页面首次渲染正常,但当某个深层嵌套的响应式对象属性发生变化时,视图要么不更新,要么更新了但副作用函数被重复执行,导致性能断崖式下跌。更让人头疼的是,当你想通过阅读 Vue 3 源码去定位问题时,会发现 reactivity 包中 effecttracktrigger 之间的调用链路被 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 中定义了 getset 拦截器。

当我们执行 state.user.profile.permissions[0] = 'write' 时,触发的是数组索引的 set 拦截。在源码中,set 拦截器会调用 trigger 函数,而 trigger 内部会根据 targetMap(一个 WeakMap)去查找依赖。关键点来了:

此外,Vue 3 源码中对于 refreactive 的嵌套处理也有微妙的差异。如果你在 watchEffect 中直接访问了 state.user.profile.permissions,那么 track 会递归收集 userprofilepermissions 三个属性的依赖。但修改数组索引时,trigger 只会触发 permissions 数组内部的依赖,而不会触发 userprofile 的依赖。这就是为什么视图没有更新的根本原因。

解决方案:源码级排坑与完整代码

针对上述问题,我们提供三种从源码层面出发的解决方案,按推荐程度排序。

方案一:使用 toRefscomputed 解构深层属性

最直接的方式是避免直接修改数组索引,而是通过 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 的断点

如果上述方案都无法解决,说明你的场景涉及更复杂的依赖关系。此时需要直接在源码中打断点。步骤如下:

  1. node_modules/@vue/reactivity/dist/reactivity.esm-browser.js 中找到 tracktrigger 函数。
  2. track 函数中,找到 targetMap.get(target) 这一行,打印 targetkey,确认依赖是否被正确收集。
  3. trigger 函数中,找到 depsMap.get(key) 这一行,打印 keydeps,确认触发时是否找到了对应的副作用函数。
  4. 如果发现 deps 为空,说明 track 阶段没有收集到该 key 的依赖,需要检查 watchEffect 中是否真正访问了该属性。
  5. 如果 deps 存在但未执行,检查 effectactive 标志位是否被置为 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% 的响应式问题都能迎刃而解。