Skip to content

接手过一个老项目:Vue 2 写的,往某个对象上加了几个新字段,页面死活不更新。老同事看一眼就说「用 this.$set() 啊」,加了就好。当时只觉得是个 API 记不牢,后来才明白——那是 Object.defineProperty 的天花板,也是 Vue 3 改用 Proxy 的根本原因。

这篇把「数据变了视图怎么知道的」讲透:从 Vue 2 的 defineProperty 讲到 Vue 3 的 Proxy,再拆开依赖收集与派发更新两个核心动作。文中所有行为结论都用 @vue/reactivity 真实源码实跑验证过(附验证清单)。

一、解决什么问题:数据变了,视图怎么知道?

Vue 的核心理念是「数据驱动视图」,但数据只是内存里的一个对象,它不会主动喊"我变了"。要让视图跟着变,必须有这么一条链路:

  1. 收集:谁(哪个渲染函数/计算属性)读过这个数据,先记下来;
  2. 通知:数据被修改时,把记下来的"读者"挨个叫醒,让它们重跑。

这就是经典的发布订阅模式:数据是发布者,读它的人是订阅者。Vue 要做的,就是拦截数据的读写——读的时候收集订阅者,写的时候通知订阅者。拦截手段,就是两代实现的分水岭。

二、Vue 2 的做法:Object.defineProperty

Vue 2 在初始化时遍历对象的所有属性,用 Object.defineProperty 把每个属性替换成带 get/set 的存取器:

javascript
function defineReactive(obj, key, val) {
  Object.defineProperty(obj, key, {
    get() {
      // 读取时:收集依赖(谁在读我)
      track(obj, key)
      return val
    },
    set(newVal) {
      if (newVal !== val) {
        val = newVal
        // 修改时:通知依赖(我变了,重跑)
        trigger(obj, key)
      }
    },
  })
}

思路是对的,但只能在初始化时就存在的属性上做文章,于是有了三个著名坑:

场景Vue 2 表现补救
给对象新增属性setter 不存在,不触发更新必须 this.$set(obj, key, val)
删除属性同样无感知无 API,只能整个对象替换
索引改数组元素arr[0] = x 不触发必须 this.$setsplice

这几个坑,本质都是「defineProperty 是静态的:属性存在时才有劫持,后来加进来的它管不着」。Vue 2 只能靠 $set、重写数组方法(push/splice 等)打补丁,补丁还补不全——arr[0] = x 就是漏网之鱼。

三、Vue 3 的做法:Proxy

Vue 3 换了个根本思路:不再逐个劫持属性,而是劫持整个对象new Proxy(target, handler) 返回一个代理对象,对原对象的所有操作(读、写、新增、删除、遍历……)都先经过 handler 里的拦截函数:

javascript
const state = new Proxy(
  { name: 'BinMaker', age: 30 },
  {
    get(target, key) {
      track(target, key)   // 读任何属性都能拦截到
      return target[key]
    },
    set(target, key, value) {
      target[key] = value
      trigger(target, key) // 写任何属性(包括新增)都能拦截到
      return true
    },
    deleteProperty(target, key) {
      delete target[key]
      trigger(target, key) // 删除也能拦截到
      return true
    },
  }
)

state.age = 31      // 命中 set → 触发更新
state.extra = '新'  // 新增属性也命中 set → 触发更新(Vue 2 的 $set 痛点没了)
delete state.age    // 命中 deleteProperty → 触发更新

Proxy 支持 13 种拦截操作,其中对响应式最关键的三个:

拦截操作触发时机对应能力
get读取任意属性收集依赖,且新增属性也能收集
set修改或新增属性触发更新,$set 成为历史
deleteProperty删除属性删除也能触发更新

两个细节值得注意:

  • 惰性深层reactive({ user: { name: 'x' } })user 并不会在初始化时深度递归代理,而是第一次读到 state.user才把它包成 reactive——对象再大也只代理用到的部分。
  • 数组天然支持push、按索引赋值、length 变化都走 set 拦截,Vue 2 那套数组方法重写补丁整个不需要了。

四、核心两动作:依赖收集 track 与派发更新 trigger

拦截只是"能看到读写",真正的机制是这两个函数,一读一写,配合成环:

Vue 响应式依赖收集与派发更新

  • track(收集)get 触发。当某个函数(effect,比如组件的渲染函数)执行时读到 state.key,就记录「effect 依赖 key」。同一个 effect 重复读同一个 key 只收集一次。
  • trigger(派发)set/deleteProperty 触发。把收集到的、依赖这个 key 的所有 effect 挨个重新执行,视图随之更新。

@vue/reactivityeffect 验证这条链路(已实跑,[PASS]):

javascript
import { reactive, effect } from '@vue/reactivity'

const state = reactive({ count: 0 })
let log = null

effect(() => {
  // 渲染函数执行:读到 state.count → track 收集「本 effect 依赖 count」
  log = `count=${state.count}`
})

state.count = 1  // set → trigger → 重跑上面的 effect
// log === 'count=1'  ✅ 首次执行 1 次 + 修改后再执行 1 次

五、ref 与 reactive:为什么基本类型要包一层

Proxy 只能代理对象,基本类型(字符串、数字、布尔)没有属性可拦截,Vue 3 就用 ref 包一层对象:

javascript
import { ref, reactive, isRef, isReactive } from '@vue/reactivity'

const count = ref(0)          // 内部是 { value: 0 },value 由 reactive 处理
count.value++                 // 访问/修改必须 .value

const user = reactive({ name: 'BinMaker' })
user.name = 'Alan'            // 对象直接点属性

// isRef(count) === true    isReactive(user) === true  ✅

模板里 {{ count }} 不用写 .value,是 Vue 编译模板时自动解包,JavaScript 逻辑里没这个待遇。一句话记法:基本类型用 ref,对象用 reactiveref 里存对象时会自动转成 reactive 行为。

六、computed:会缓存的 effect

computed 的本质是一个「带缓存的 effect」:只有它依赖的数据变了才重算,否则直接返回上次结果。实跑验证([PASS]):

javascript
import { ref, computed } from '@vue/reactivity'

const a = ref(1)
const b = ref(2)
const sum = computed(() => a.value + b.value)

sum.value // 3,首次计算
sum.value // 3,缓存命中,不重算
a.value = 10
sum.value // 12,依赖变了才重算

这个「依赖没变就不重算」的行为,是模板里用 computed 而不是直接在模板写表达式做复杂计算的原因——省性能,也更好测。

七、实跑验证清单

本篇所有行为断言都用真实 @vue/reactivity(Vue 3.5.x 源码)在 Node 里实跑,12 项全 PASS:

行为结果
effect 随依赖变化自动重跑✅ 改 2 次执行 3 次(首次+2 次修改)
嵌套对象深层属性响应state.user.name 改动生效
数组 push / 按索引赋值✅ 都触发
新增属性直接响应✅ 无需 $set(Vue 2 做不到)
delete 属性触发更新
ref.value 访问isRef 判定
computed 缓存✅ 依赖不变不重算
Map/Set 响应式✅ Vue 3 原生支持
同一 effect 重复读同一依赖✅ 只收集一次,不重复触发
defineProperty 无法拦截新增属性✅ Vue 2 痛点的根因复现

小结

维度Vue 2Vue 3
拦截手段Object.defineProperty 逐属性劫持Proxy 整体代理(13 种操作)
新增属性不响应,需 $set直接响应
删除属性不响应直接响应
数组索引赋值不响应,需补丁直接响应
深层代理初始化时递归惰性,读到才代理
基本类型data 直接挂ref 包装

想从用法层面先跑一遍响应式,看 Vue 快速入门computed/watch 在代码里怎么组织,看 Composition API 详解;Vue 2 和 Vue 3 的版本脉络、其他破坏性变更,看 Vue 版本演进:Vue 2 与 Vue 3 全对比