技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTVUE

深入响应式原理:从 Vue 2 的 getter/setter 到 Vue 3 的 effect

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/89-深入响应式原理
本文目录9 个章节
  1. 一、响应式解决了什么问题
  2. 二、响应式的基本模型:依赖与副作用
  3. 三、Vue 2.7:Observer、Dep 和 Watcher
  4. 四、Vue 3:Proxy、ReactiveEffect 和依赖图
  5. 五、Vue 2 与 Vue 3 响应式对比
  6. 六、常见响应式陷阱
  7. 七、如何调试响应式更新
  8. 总结
  9. 参考资料

深入响应式原理:从 Vue 2 的 getter/setter 到 Vue 3 的 effect

原文主要讨论“修改数据后,模板为什么会自动更新”。本文保留原文从数据渲染、用户交互、依赖收集到更新视图的主线,并补充 Vue 2.7、Vue 3.5 的实现差异。下面的源码是帮助理解的最小模型,不等同于 Vue 仓库中的完整生产代码。

一、响应式解决了什么问题

前面的渲染过程可以概括为:把数据交给模板,得到 DOM。实际应用还需要处理另一条链路:用户交互或其他业务逻辑修改数据后,视图也要同步变化。

考虑这个 Vue 2 示例:

<div id="app" @click="changeMsg">
  {{ message }}
</div>

<script>
new Vue({
  el: '#app',
  data: {
    message: 'Hello Vue!',
  },
  methods: {
    changeMsg() {
      this.message = 'Hello World!'
    },
  },
})
</script>

点击元素时只修改了 this.message,并没有手动查找插值对应的 DOM,也没有调用 textContent。Vue 会完成:

  1. 知道当前渲染过程读取了哪些响应式数据;
  2. 在这些数据变化时通知相关的渲染任务;
  3. 重新执行渲染函数,得到新的虚拟 DOM;
  4. 通过 patch/diff 将必要的变化应用到真实 DOM;
  5. 将同一轮事件中的多次变化合并,避免重复更新。

如果不用框架,我们当然也可以监听点击事件、修改数据、手动操作 DOM。但随着页面复杂度增加,就必须自己回答:要修改哪块 DOM?多个数据同时改变时是否重复更新?条件渲染和列表变化如何处理?Vue 的响应式系统和渲染器就是为这些问题服务的。

二、响应式的基本模型:依赖与副作用

可以用电子表格理解响应式:单元格 C1 = A1 + B1,当 A1B1 变化时,C1 会重新计算。

在 JavaScript 中,普通变量不会自动建立这种关系:

let a = 1
let b = 2
let result = a + b

b = 3
console.log(result) // 仍然是 3

如果把计算过程包在一个函数里,就可以把它称为一个 effect(副作用/响应式副作用):

let result

function update() {
  result = a + b
}

这里:

  • update 是需要重新执行的 effect;
  • ab 是 effect 使用的依赖;
  • 首次执行 update 时,需要记录“当前 effect 读过 a 和 b”;
  • 之后写入 ab 时,找到并触发相关 effect。

JavaScript 没有拦截普通局部变量读取的通用机制,但可以拦截对象属性的读取和写入。Vue 2 主要使用 getter/setter,Vue 3 的 reactive() 使用 Proxy,ref() 仍然通过 .value 的 getter/setter 建立追踪。

三、Vue 2.7:Observer、Dep 和 Watcher

1. 版本范围

Vue 2.0 到 Vue 2.7 的核心响应式思路都是 Object.definePropertyDepWatcher。Vue 2.7 增加了部分 Composition API,但没有把对象响应式改成 Vue 3 的 Proxy。

Vue 2 已经结束官方维护,下面内容主要用于维护旧项目和理解历史源码;新项目应优先使用 Vue 3。

2. defineReactive:为属性安装 getter/setter

简化后的实现如下:

function defineReactive(object, key, initialValue) {
  const dep = new Dep()
  let value = initialValue

  Object.defineProperty(object, key, {
    enumerable: true,
    configurable: true,

    get() {
      if (Dep.target) {
        dep.depend()
      }
      return value
    },

    set(nextValue) {
      if (Object.is(nextValue, value)) return
      value = nextValue
      dep.notify()
    },
  })
}

dep 被 getter/setter 的闭包保存,因此每个属性都有自己的依赖容器。真实 Vue 2 源码还会:

  • 保留原有 getter/setter;
  • 递归观测嵌套对象;
  • 收集数组和嵌套对象的依赖;
  • 处理只读、不可配置属性、SSR 等边界;
  • 在开发环境提供依赖调试信息。

3. Dep:依赖容器

let uid = 0

class Dep {
  static target = null

  constructor() {
    this.id = uid++
    this.subs = []
  }

  addSub(watcher) {
    this.subs.push(watcher)
  }

  depend() {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }

  notify() {
    // 复制一份,避免通知过程中订阅列表发生变化影响本轮遍历。
    const subs = this.subs.slice()
    for (const watcher of subs) {
      watcher.update()
    }
  }
}

原文把 Dep.target 描述为当前执行的观察者,这个方向是对的,但真实源码并不是只简单赋值一个变量:Vue 2 使用 target stack,通过 pushTarget()/popTarget() 支持渲染 watcher 嵌套计算属性 watcher 的场景。

4. Watcher:把函数包装成可更新任务

渲染函数、计算属性和用户 watch 都可以由 watcher 负责,但它们的选项不同:

  • 渲染 watcher:读取模板使用的数据,更新组件视图;
  • 计算属性 watcher:通常是 lazy 的,依赖变化时先标记 dirty,访问计算属性时再求值;
  • 用户 watcher:执行开发者提供的回调,可配置 deepimmediate 等;
  • 同步 watcher:立即执行,但大量使用可能绕过批处理,通常不作为默认选择。

最小模型:

class Watcher {
  constructor(getter, callback) {
    this.getter = getter
    this.callback = callback
    this.deps = new Set()
    this.value = this.get()
  }

  get() {
    const previous = Dep.target
    Dep.target = this

    try {
      return this.getter()
    } finally {
      Dep.target = previous
    }
  }

  addDep(dep) {
    if (this.deps.has(dep)) return
    this.deps.add(dep)
    dep.addSub(this)
  }

  update() {
    const oldValue = this.value
    const newValue = this.get()
    this.value = newValue
    this.callback?.(newValue, oldValue)
  }
}

真实 Vue 2 的 watcher 会维护 depsdepIdsnewDepsnewDepIds 两组集合:重新执行 getter 后,新增依赖会加入,已经不再访问的旧依赖会被清理。这样可以正确处理条件分支:

watchEffect(() => {
  // Vue 2 中可类比为 render/computed watcher 的 getter
  return enabled ? state.a : state.b
})

enabled 从 true 变为 false 后,watcher 应该停止订阅 state.a,转而订阅 state.b;依赖收集不是“一次绑定,永远不变”。

5. 初次渲染和更新流程

Vue 2 的典型流程可以抽象为:

observe(data)
  ↓
把对象属性转换为 getter/setter,创建 Dep
  ↓
创建组件渲染 Watcher
  ↓
执行 render
  ↓
getter 发现 Dep.target,收集依赖
  ↓
生成 vnode 并首次 patch

数据 setter 被调用
  ↓
dep.notify()
  ↓
watcher.update()
  ↓
进入 scheduler 队列并去重
  ↓
重新执行 render,生成新 vnode
  ↓
patch(oldVnode, newVnode)

notify() 不等于“此刻立刻完整地重绘 DOM”。默认情况下,Vue 2 会把 watcher 放进异步更新队列,同一事件循环内的重复触发会去重,之后统一刷新。需要在 DOM 更新完成后读取 DOM 时,应使用 $nextTick/Vue.nextTick

6. Vue 2 对数组的处理

Object.defineProperty 可以拦截 obj.key,但不能直接拦截 arr[index] = valuearr.length = value。Vue 2 因此改写了七个变异方法:

const methods = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse',
]

这些方法会先调用原生实现,再观测 pushunshiftsplice 新增的元素,最后调用数组 observer 上的 dep.notify()

因此 Vue 2 中下面两种写法不要混淆:

// Vue 2 中不能可靠触发响应式更新
vm.items[index] = nextValue
vm.items.length = 0

// 推荐
vm.$set(vm.items, index, nextValue)
vm.items.splice(index, 1, nextValue)
vm.items.splice(0)

对嵌套对象新增属性也需要:

this.$set(this.user, 'age', 18)
// 或 Vue.set(this.user, 'age', 18)

Vue 3 不需要 Vue.set/this.$set,Proxy 可以拦截新增和删除,但仍然应保持状态结构清晰。

四、Vue 3:Proxy、ReactiveEffect 和依赖图

1. reactive()ref()

Vue 3 的概念模型可以简化为:

function reactive(target) {
  return new Proxy(target, {
    get(object, key, receiver) {
      track(object, key)
      return Reflect.get(object, key, receiver)
    },
    set(object, key, value, receiver) {
      const oldValue = object[key]
      const result = Reflect.set(object, key, value, receiver)
      if (!Object.is(oldValue, value)) {
        trigger(object, key)
      }
      return result
    },
  })
}

function ref(initialValue) {
  const refObject = {
    get value() {
      track(refObject, 'value')
      return initialValue
    },
    set value(nextValue) {
      if (Object.is(nextValue, initialValue)) return
      initialValue = nextValue
      trigger(refObject, 'value')
    },
  }
  return refObject
}

这是概念代码,真实实现还要处理原型链、只读代理、浅层代理、数组、Map/Set、迭代器、ref 嵌套、调度器和开发调试事件。

2. tracktrigger

从概念上看,依赖关系类似:

WeakMap<原始对象, Map<属性 key, 依赖集合>>
let activeEffect
const targetMap = new WeakMap()

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)
  depsMap?.get(key)?.forEach(effect => effect.run())
}

function effect(fn) {
  const runner = {
    run() {
      activeEffect = runner
      try {
        return fn()
      } finally {
        activeEffect = undefined
      }
    },
  }
  runner.run()
  return runner
}

需要强调:这是便于理解的抽象,并不是 Vue 3.5 的逐行源码。Vue 3.5 的响应式内部进一步采用了版本计数和双向依赖链接等优化结构;官方文档用 WeakMap<target, Map<key, Set<effect>>> 解释语义,源码实现可以随版本变化。阅读源码时应区分“依赖模型”和“具体数据结构”。

触发类型也不只有普通 set

  • 读取属性通常是 get
  • in 操作需要追踪 has
  • Object.keys()、数组迭代、Map/Set 迭代需要追踪 iterate
  • 新增属性是 add
  • 删除属性是 delete
  • 清空 Map/Set 是 clear

3. watchEffectcomputed 和组件渲染

import { computed, ref, watchEffect } from 'vue'

const firstName = ref('Ada')
const lastName = ref('Lovelace')

const fullName = computed(() => {
  return `${firstName.value} ${lastName.value}`
})

watchEffect(() => {
  console.log(fullName.value)
})
  • watchEffect 立即执行一次,自动收集回调中同步读取的依赖;
  • computed 也使用响应式 effect,但默认惰性求值并缓存结果,依赖变化时先失效;
  • watch(source, callback) 更强调明确的 source 和变化回调,适合异步副作用、旧值/新值和深度监听;
  • 组件渲染本质上也由一个响应式渲染 effect 驱动,只是它会交给 renderer 做 vnode patch,而不是直接用 innerHTML

调度器会决定 effect 何时运行。watch/watchEffect 支持 flush: 'pre' | 'post' | 'sync' 等选项;nextTick() 适合等待 Vue 的 DOM 更新队列完成。不要把“依赖触发”误解为每次赋值都会同步执行完整组件渲染。

五、Vue 2 与 Vue 3 响应式对比

对比项Vue 2.7Vue 3.5
对象拦截Object.definePropertyProxy
基本依赖角色Dep + WatcherReactiveEffect/依赖链接
数组下标赋值不能直接可靠检测可由 Proxy 拦截
数组 length有限制可追踪并触发相关依赖
新增/删除属性$set/$delete 等辅助直接 obj.newKey = valuedelete obj.key
Map/Set不作为普通响应式集合完整支持有专门 collection handlers
refVue 2.7 提供兼容实现.value getter/setter
更新调度watcher schedulereffect/job scheduler
根数据结构需要提前声明根属性Proxy 可处理新增,但仍推荐明确 state schema

Proxy 并不是“所有地方都更快”或“自动深度监听一切”。它消除了 Vue 2 的一些检测限制,但仍然受 JavaScript 语义和使用方式影响。

六、常见响应式陷阱

1. 解构会丢失对象属性的响应式连接

const state = reactive({ count: 0 })
const { count } = state

// count 是普通局部变量,不再通过 state.count 的 Proxy getter 读取。

需要保留连接时,可以使用 toRef/toRefs

const count = toRef(state, 'count')

2. reactive 返回的是代理对象

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

console.log(proxy === raw) // false

一般应持续使用代理对象,不要在业务中混用原始对象和代理对象作为身份标识。需要取原始对象时可使用 toRaw,但不应把它当作日常修改入口。

3. 不是所有外部对象都适合深度代理

接入第三方状态库、不可变数据或大型外部对象时,可以考虑 shallowRefshallowReactivemarkRaw。使用浅层 API 后,内部变化通常需要替换 .value 或手动 triggerRef,不能再假定深层属性会自动触发更新。

4. 依赖是按访问路径收集的

如果 effect 没有读取某个属性,修改该属性不会因为“它属于同一个对象”而自动让这个 effect 更新。另一方面,读取整个对象、遍历 key、读取数组长度和读取 Map 的迭代器,可能会建立不同粒度的依赖。

七、如何调试响应式更新

Vue 3 在开发环境提供组件调试钩子:

<script setup>
import { onRenderTracked, onRenderTriggered } from 'vue'

onRenderTracked(event => {
  debugger
  console.log('render 追踪了:', event)
})

onRenderTriggered(event => {
  debugger
  console.log('render 被什么触发:', event)
})
</script>

computedwatch 也支持 onTrackonTrigger 调试选项。排查“为什么组件更新”时,可以先确认:

  1. effect 是否真的读取了该属性;
  2. 依赖是否在条件分支中被清理;
  3. 是否错误地解构或使用了 raw 对象;
  4. 是否是父组件 props、slot、key 或列表变化导致的更新;
  5. 数据更新后是否只是进入队列,尚未到 DOM 提交阶段。

总结

  • 响应式的核心不是“数据变化后扫描整个页面”,而是读取时建立依赖、写入时通知相关 effect;
  • Vue 2 使用 Object.definePropertyDepWatcher 和数组方法改写;
  • Vue 3 的 reactive 使用 Proxy,ref 通过 .value getter/setter 追踪,ReactiveEffect 负责执行和重新收集依赖;
  • Vue 2 的数组下标、length 和对象新增属性有检测限制,Vue 3 通过 Proxy 改善了这些场景;
  • notify/trigger 通常会进入调度队列,不代表同步完成 DOM 更新;
  • 官方文档展示的 Set 依赖图是概念模型,Vue 3.5 内部数据结构已经进一步优化;
  • 理解依赖收集后,再结合 renderer 的 vnode patch,才能完整解释“数据为什么会变成视图”。

参考资料

原文作者:一只小考拉。原文关于数据变更、观察者模式、DepWatcherObserver、数组方法和 defineReactive 的主线予以保留;抓取产生的无效代码围栏、重复宣传、过时的 Vue 2 绝对化表述和“Vue 3 只是 Set 结构”的说法已整理或修正。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS