Vue 响应式原理

Vue 如何知道数据变了,并只更新真正依赖这些数据的组件?

一、响应式系统解决什么问题

响应式系统的核心不是“监听所有数据变化”,而是建立这样的关系:

组件渲染 / 用户创建的副作用
        ↓ 读取响应式数据
track:记录当前 effect 依赖了哪些属性
        ↓ 数据发生变化
trigger:找到依赖该属性的 effect
        ↓ scheduler 调度执行
组件重新渲染 / 副作用重新执行

在 Vue 中,响应式系统负责发现依赖变化并重新执行副作用;组件渲染器负责将新的虚拟 DOM patch 到真实 DOM。响应式系统本身并不直接操作 DOM。

二、Vue 2:Object.defineProperty

Vue 2 在初始化时递归遍历对象,为每个属性定义 getter/setter:

function defineReactive(obj, key, value) {
  const dep = new Dep()

  // 真实实现还会递归 observe(value)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {
      dep.depend() // 当前 Watcher 依赖了这个属性
      return value
    },
    set(newValue) {
      if (Object.is(newValue, value)) return

      value = newValue
      // 真实实现还会对新值进行 observe
      dep.notify() // 通知相关 Watcher
    }
  })
}

真实链路可以概括为:

组件渲染 → 读取属性 → getter → Dep 收集 Watcher
数据变化 → setter → Dep 通知 Watcher → 组件更新

Vue 2 的限制

  1. 初始化时需要递归观测对象,属性多、层级深时初始化成本较高。
  2. 不能直接检测后添加的对象属性,需要使用 Vue.set / this.$set
  3. 不能直接检测数组索引赋值和数组长度变化,例如 arr[0] = value
  4. Vue 2 会重写 pushpopshiftunshiftsplicesortreverse 等数组方法,因此这些方法仍然可以触发更新。
  5. 数组索引可以通过 Vue.set(arr, index, value)arr.splice(index, 1, value) 更新。

三、Vue 3:Proxy

Vue 3 使用 Proxy 代理对象,在访问和修改时进行依赖收集与触发:

function reactive(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      const result = Reflect.get(target, key, receiver)
      track(target, key)

      // 真实实现会对嵌套对象进行惰性代理
      return result
    },

    set(target, key, value, receiver) {
      const oldValue = target[key]
      const hadKey = Object.prototype.hasOwnProperty.call(target, key)
      const result = Reflect.set(target, key, value, receiver)

      if (!result) return false

      if (!hadKey) {
        trigger(target, key, 'ADD')
      } else if (!Object.is(oldValue, value)) {
        trigger(target, key, 'SET')
      }

      return true
    },

    deleteProperty(target, key) {
      const hadKey = Object.prototype.hasOwnProperty.call(target, key)
      const result = Reflect.deleteProperty(target, key)

      if (result && hadKey) {
        trigger(target, key, 'DELETE')
      }

      return result
    },

    has(target, key) {
      track(target, key)
      return Reflect.has(target, key)
    },

    ownKeys(target) {
      // Object.keys、for...in 等操作也可以建立迭代依赖
      track(target, 'ITERATE_KEY')
      return Reflect.ownKeys(target)
    }
  })
}

Proxy 可以拦截读取、赋值、删除、属性判断、枚举等操作。Vue 3 不需要在创建代理时递归遍历所有嵌套对象,而是在访问嵌套对象时按需代理,因此通常能降低初始化成本;这不代表所有场景下都一定更快。

新增属性、修改属性和删除属性在 Vue 3 中是不同的触发类型。数组还需要额外处理索引、length 和迭代依赖;MapSet 也有专门的集合代理逻辑。

四、依赖收集:WeakMap → Map → Set

Vue 3 可以用下面的结构保存依赖关系:

targetMap: WeakMap<object, depsMap>
depsMap:   Map<key, dep>
dep:       Set<ReactiveEffect>

含义是:某个响应式对象的某个属性,被哪些 effect 依赖。

下面是去掉边界处理后的教学版实现:

const targetMap = new WeakMap()
let activeEffect

function effect(fn) {
  const reactiveEffect = () => {
    activeEffect = reactiveEffect
    fn()
    activeEffect = undefined
  }

  reactiveEffect()
  return reactiveEffect
}

function track(target, key) {
  if (!activeEffect) return

  let depsMap = targetMap.get(target)
  if (!depsMap) {
    depsMap = new Map()
    targetMap.set(target, depsMap)
  }

  let dep = depsMap.get(key)
  if (!dep) {
    dep = new Set()
    depsMap.set(key, dep)
  }

  dep.add(activeEffect)
}

function trigger(target, key) {
  const depsMap = targetMap.get(target)
  const dep = depsMap?.get(key)

  dep?.forEach(effect => effect())
}

真实 Vue 还需要处理 effect 栈、依赖清理、递归触发、重复执行以及 scheduler。上面的代码用于理解主流程,不是 Vue 源码的完整实现。

五、为什么 Vue 3 还需要 ref

Proxy 只能代理对象,不能直接代理数字、字符串、布尔值等基本类型。ref 因此把值包装成带有 .value 属性的对象:

function ref(rawValue) {
  let value = convert(rawValue)

  return {
    __v_isRef: true,

    get value() {
      track(this, 'value')
      return value
    },

    set value(newValue) {
      if (Object.is(newValue, value)) return

      value = convert(newValue)
      trigger(this, 'value')
    }
  }
}

function convert(value) {
  return value !== null && typeof value === 'object'
    ? reactive(value)
    : value
}

实际 Vue 还会处理原始值与代理对象的比较、shallowReftriggerRef 以及代理缓存等情况。

const count = ref(0)
count.value       // 读取,收集依赖
count.value = 1   // 修改,触发依赖

六、computed、watch 与 scheduler

computed

computed 通常具备三个特征:

  1. 惰性执行:只有被读取时才执行 getter。
  2. 缓存结果:依赖没有变化时,重复读取不会重新执行 getter。
  3. 依赖变化时标记为 dirty,并通知依赖它的 effect。
读取 computed → dirty 时执行 getter → 缓存结果
依赖变化 → scheduler 将 dirty 设为 true → 通知 computed 的使用者

watch 与 watchEffect

  • watch(source, callback) 监听明确的数据源,回调中可以获得新值和旧值。
  • watchEffect(callback) 会自动收集回调同步执行期间访问的响应式数据。
  • watch 默认不是深度监听,deep: true 会递归遍历数据,可能带来较大成本。
  • 两者都支持 immediate、清理函数和不同的刷新时机。

scheduler 与批量更新

响应式数据变化后,组件通常不会立即同步渲染,而是把更新任务放入队列并去重:

state.count++
state.count++
state.count++

Vue 会通过 scheduler 合并更新任务,在合适的时机批量执行。nextTick 用于等待这一轮异步更新队列刷新后再执行代码。

七、常见响应式边界

1. 解构可能丢失响应式

const state = reactive({ count: 0 })
const { count } = state // count 是普通值,不再经过 state.count 的代理

需要保持响应式时,可以使用:

const { count } = toRefs(state)

2. reactive 只能接收对象类型

基本类型需要使用 ref

const name = ref('Vue')

3. 原对象与代理对象不是同一个对象

const raw = {}
const proxy = reactive(raw)

raw !== proxy

Vue 通过缓存尽量保证同一个原对象对应同一个代理,也提供 toRawmarkRaw 处理特殊场景。

4. ref 的自动解包有边界

普通响应式对象属性、模板和部分组件场景会自动解包 ref,但数组和 Map 中的 ref 通常仍需要显式访问 .value

const list = reactive([ref(1)])
list[0].value

5. 集合类型有特殊依赖

Vue 3 对 MapSet、数组遍历以及 size 等操作做了专门处理。读取 map.get(key)、执行 map.set(key, value) 和遍历集合,并不是普通对象属性访问的简单等价形式。

八、从数据变化到 DOM 更新

组件首次渲染
render 函数在组件 effect 中执行
读取响应式数据,track 收集组件更新依赖
数据变化,trigger 找到组件 effect
scheduler 将更新任务放入队列并去重
组件重新执行 render,生成新的虚拟 DOM
renderer patch 新旧虚拟 DOM
更新真实 DOM

九、Vue 2 与 Vue 3 对比

对比项Vue 2Vue 3
核心机制Object.definePropertyProxy
依赖收集getter 中收集 WatcherProxy trap 中收集 ReactiveEffect
新增对象属性需要 Vue.set直接支持
删除对象属性需要 Vue.delete直接支持
数组索引直接赋值无法触发支持,但有数组索引和 length 联动逻辑
数组变异方法通过重写方法支持通过 Proxy 拦截并触发
深层对象处理初始化时递归观测访问时惰性代理
基本类型响应式不支持独立基本类型通过 ref 包装
集合类型支持有限MapSet 等提供专门处理

十、面试回答模板

如果被问“Vue 3 的响应式原理是什么”,可以按下面顺序回答:

  1. Vue 3 使用 Proxy 代理对象,get 时进行依赖收集,set/delete 时触发更新。
  2. 依赖关系由 WeakMap → Map → Set 保存,分别对应对象、属性和依赖该属性的 effect。
  3. 组件渲染本质上是一个 effect,读取响应式数据时会建立组件与数据之间的依赖关系。
  4. 数据变化后,trigger 找到相关 effect,由 scheduler 批量调度组件更新。
  5. ref 用对象包装基本类型,并通过 .value 的 getter/setter 实现响应式。
  6. computed 基于 lazy effect、缓存和 dirty 标记实现;watch 则是在响应式变化后执行用户回调。
  7. 需要注意解构丢失响应式、数组和集合类型、浅层 API、原始对象与代理对象身份不同等边界。