前端 Vue 专栏 05:从 Object.defineProperty 到 Proxy,彻底理解 Vue 响应式原理

前端 Vue 专栏 05:从 Object.defineProperty 到 Proxy,彻底理解 Vue 响应式原理

前言

上一篇文章介绍了 Composition API,并使用了很多响应式 API:

js 复制代码
const count = ref(0)
const user = reactive({ name: '张三' })
const doubleCount = computed(() => count.value * 2)

watch(count, newValue => {
  console.log('count 变化了:', newValue)
})

这些 API 看起来各不相同,但它们背后都离不开同一套响应式系统。

当我们执行:

js 复制代码
count.value++

Vue 为什么知道哪些地方使用了 count

为什么它只更新相关组件,而不是重新渲染整个页面?

computed 为什么能够缓存结果?

watch 为什么能在数据变化后执行回调?

Vue 2 为什么需要 Vue.set,Vue 3 为什么不再需要?

这一篇将沿着一条完整的主线回答这些问题:

text 复制代码
状态被读取
    ↓
收集依赖 track
    ↓
状态被修改
    ↓
通知依赖 trigger
    ↓
重新执行组件渲染、computed 或 watch

本文会先建立响应式的核心模型,再分别讲解 Vue 2 的 Object.defineProperty 和 Vue 3 的 Proxy,最后手写一套简化版响应式系统。

需要提前说明:本文的手写代码用于解释原理,并不是 Vue 源码的逐行复制。真实 Vue Core 还处理了数组、集合类型、迭代依赖、调度批处理、版本号优化、只读代理、浅层代理、作用域清理等大量边界情况。

一、什么是响应式

先看一个普通 JavaScript 例子:

html 复制代码
<span id="count"></span>

<script>
  let count = 0
  const countElement = document.querySelector('#count')

  countElement.textContent = count

  count++
</script>

执行 count++ 后,变量已经变成 1,但页面仍然显示 0

原因很简单:

text 复制代码
JavaScript 变量变化,不会自动修改 DOM。

我们必须再次手动执行:

js 复制代码
countElement.textContent = count

而在 Vue 中:

vue 复制代码
<script setup>
import { ref } from 'vue'

const count = ref(0)
</script>

<template>
  <button @click="count++">
    {{ count }}
  </button>
</template>

修改 count 后,页面会自动更新。

所谓响应式,可以先理解为:

当状态发生变化时,所有依赖该状态的计算和界面能够自动得到更新。

这里有两个关键词:

  • 状态 :例如 countuser.name、商品列表;
  • 依赖:读取并使用这些状态的组件渲染、计算属性或侦听器。

响应式并不是"数据自己修改 DOM",而是 Vue 建立了状态与副作用之间的联系。

二、什么是副作用 effect

下面这个函数只是进行纯计算:

js 复制代码
function add(a, b) {
  return a + b
}

它只根据输入返回结果,不修改函数外部的内容,通常称为纯函数。

再看这个函数:

js 复制代码
function render() {
  document.querySelector('#count').textContent = state.count
}

它读取 state.count,并修改了外部 DOM。

类似这种会影响函数外部状态的操作,可以称为副作用。

在响应式系统中,effect 可以理解为:

一个会读取响应式状态,并且需要在这些状态变化后重新执行的函数。

常见 effect 包括:

text 复制代码
组件渲染函数;
watchEffect 中的回调;
watch 所使用的响应式取值函数;
需要根据响应式数据重新执行的用户副作用。

例如:

js 复制代码
effect(() => {
  document.body.textContent = state.count
})

我们希望响应式系统完成两件事:

  1. 第一次执行函数时,记录它读取了 state.count
  2. state.count 变化时,再次执行这个函数。

这就是整套系统最核心的任务。

三、响应式的核心不是 Proxy,而是依赖关系

很多人在面试中会回答:

text 复制代码
Vue 2 使用 Object.defineProperty;
Vue 3 使用 Proxy。

这句话没错,但还没有解释响应式原理。

Object.definePropertyProxy 主要解决的是:

text 复制代码
Vue 怎样感知属性被读取和被修改。

真正让页面自动更新的,是读取和修改时建立的依赖关系:

text 复制代码
读取属性 → track 收集当前 effect
修改属性 → trigger 找到并通知相关 effect

可以把它想成一张订阅表:

text 复制代码
state.count
├── 组件 A 的渲染函数
├── doubleCount 的计算逻辑
└── 某个 watch 回调

state.name
└── 组件 B 的渲染函数

state.count 变化时,只需要通知订阅了 count 的 effect,不必把 state.name 的使用者一起重新执行。

所以面试中更完整的回答应该是:

Vue 通过 getter/setter 或 Proxy 拦截响应式数据的读取和修改。读取时通过 track 收集当前正在执行的副作用,修改时通过 trigger 找到该属性对应的副作用并通知它们重新执行或进入调度队列。

四、为什么普通局部变量不能直接响应

下面是普通变量:

js 复制代码
let count = 0

count++

JavaScript 没有提供一种通用机制,让 Vue 拦截任意局部变量的读取和赋值:

js 复制代码
console.log(count) // Vue 无法在这里自动插入 track
count = 1         // Vue 无法在这里自动插入 trigger

所以 Vue 需要把状态放进一个能够拦截访问的容器中。

Vue 3 常见的两种容器是:

js 复制代码
const count = ref(0)
const state = reactive({ count: 0 })

它们分别通过下面的方式拦截访问:

text 复制代码
ref:拦截 value 属性的 getter 和 setter;
reactive:使用 Proxy 拦截对象属性操作。

这也解释了 ref 为什么需要 .value

js 复制代码
count.value++

Vue 不是无缘无故增加一层写法,而是需要一个属性访问边界,才能感知读取和修改。

在模板中,Vue 会自动解包符合条件的 ref,所以可以写:

vue 复制代码
<p>{{ count }}</p>

但在普通 JavaScript 中,仍然需要:

js 复制代码
console.log(count.value)

五、先用 getter 和 setter 理解最小响应式

暂时不考虑多个对象和多个属性,只实现一个响应式值:

js 复制代码
let activeEffect = null
const effects = new Set()

function effect(fn) {
  activeEffect = fn
  fn()
  activeEffect = null
}

function ref(rawValue) {
  let value = rawValue

  return {
    get value() {
      if (activeEffect) {
        effects.add(activeEffect)
      }

      return value
    },

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

      value = newValue
      effects.forEach(effectFn => effectFn())
    }
  }
}

使用它:

js 复制代码
const count = ref(0)

effect(() => {
  console.log('当前 count:', count.value)
})

count.value++

第一次调用 effect 时:

text 复制代码
activeEffect = 当前函数
执行当前函数
读取 count.value
getter 把 activeEffect 放进 effects

修改 count.value 时:

text 复制代码
setter 保存新值
遍历 effects
重新执行依赖 count 的函数

输出结果是:

text 复制代码
当前 count:0
当前 count:1

这段代码已经包含响应式最重要的两个动作:

text 复制代码
get 时收集依赖;
set 时触发依赖。

但是它还存在明显问题:所有 ref 共用同一个 effects,无法区分不同对象和属性;也没有处理嵌套 effect、分支切换和调度。

后面会逐步补全。

六、Vue 2 为什么使用 Object.defineProperty

Vue 2 的响应式系统主要通过 Object.defineProperty() 把对象已有属性转换为 getter 和 setter。

基本用法如下:

js 复制代码
const user = {}
let internalName = '张三'

Object.defineProperty(user, 'name', {
  enumerable: true,
  configurable: true,

  get() {
    console.log('读取了 name')
    return internalName
  },

  set(newValue) {
    console.log('修改了 name:', newValue)
    internalName = newValue
  }
})

执行:

js 复制代码
console.log(user.name)
user.name = '李四'

就会进入对应的 getter 和 setter。

因此 Vue 2 可以在 getter 中收集依赖,在 setter 中通知依赖。

需要注意,Object.defineProperty 本身并不是 Vue 专用 API,也不是只能监听对象变化。它的作用是定义或修改对象某一个属性的描述符,Vue 2 利用其中的访问器能力构建响应式系统。

七、Vue 2 怎样递归处理 data

假设 Vue 2 组件中有:

js 复制代码
data() {
  return {
    user: {
      name: '张三',
      address: {
        city: '上海'
      }
    }
  }
}

Vue 2 初始化时会遍历对象已有属性,把它们转换成 getter 和 setter。

简化实现如下:

js 复制代码
function observe(value) {
  if (typeof value !== 'object' || value === null) {
    return
  }

  Object.keys(value).forEach(key => {
    defineReactive(value, key, value[key])
  })
}

function defineReactive(target, key, value) {
  observe(value)

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

    get() {
      console.log(`读取 ${key}`)
      return value
    },

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

      value = newValue
      observe(newValue)
      console.log(`触发 ${key} 的更新`)
    }
  })
}

执行:

js 复制代码
const state = {
  user: {
    name: '张三'
  }
}

observe(state)

console.log(state.user.name)
state.user.name = '李四'

访问 state.user.name 时,会先读取 user,再读取 name

这里有一个关键点:

text 复制代码
Vue 2 需要在初始化时递归遍历已有对象属性。

如果 setter 接收到一个新对象,也要继续观察新对象,否则新对象内部的属性无法响应。

八、Vue 2 中的 Dep 和 Watcher

只有 getter 和 setter 还不够。

Vue 还需要知道:

text 复制代码
谁读取了这个属性?
属性变化后应该通知谁?

在 Vue 2 的经典实现模型中,可以使用 Dep 管理一个属性的订阅者,使用 Watcher 表示需要响应数据变化的观察者。

1. Dep:依赖管理器

简化后可以这样理解:

js 复制代码
class Dep {
  constructor() {
    this.subscribers = new Set()
  }

  depend() {
    if (Dep.target) {
      this.subscribers.add(Dep.target)
    }
  }

  notify() {
    this.subscribers.forEach(watcher => {
      watcher.update()
    })
  }
}

Dep.target = null

每一个响应式属性可以拥有自己的 Dep

2. Watcher:使用响应式数据的人

js 复制代码
class Watcher {
  constructor(getter) {
    this.getter = getter
    this.get()
  }

  get() {
    Dep.target = this
    this.getter()
    Dep.target = null
  }

  update() {
    this.get()
  }
}

把它们放入 defineReactive

js 复制代码
function defineReactive(target, key, value) {
  const dep = new Dep()

  Object.defineProperty(target, key, {
    get() {
      dep.depend()
      return value
    },

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

      value = newValue
      dep.notify()
    }
  })
}

使用过程:

js 复制代码
const state = {}
defineReactive(state, 'count', 0)

new Watcher(() => {
  console.log('渲染 count:', state.count)
})

state.count++

可以画成下面的流程:

text 复制代码
创建 Watcher
    ↓
Dep.target 指向当前 Watcher
    ↓
执行 getter 函数并读取 state.count
    ↓
属性 getter 调用 dep.depend()
    ↓
Dep 记住当前 Watcher
    ↓
修改 state.count
    ↓
属性 setter 调用 dep.notify()
    ↓
Watcher.update()

真实 Vue 2 的 Watcher 和依赖清理更加复杂,但这个模型已经能解释其核心工作方式。

九、Vue 2 为什么检测不到新增和删除属性

Object.defineProperty 处理的是某一个已经存在或明确指定的属性:

js 复制代码
Object.defineProperty(user, 'name', descriptor)

假设初始化时只有:

js 复制代码
const user = {
  name: '张三'
}

Vue 2 可以把 name 转成 getter 和 setter。

后来直接新增:

js 复制代码
user.age = 18

这个赋值没有经过 name 的 setter,也不会自动为 age 创建 setter。因此 Vue 2 无法检测这次新增。

删除属性也有类似问题:

js 复制代码
delete user.name

删除操作不是给 name 赋值,不会进入它的 setter。

所以 Vue 2 提供了:

js 复制代码
Vue.set(user, 'age', 18)
Vue.delete(user, 'name')

组件中也可以使用:

js 复制代码
this.$set(this.user, 'age', 18)
this.$delete(this.user, 'name')

Vue.set 的核心思路不是让 Object.defineProperty 突然获得监听新增属性的能力,而是:

text 复制代码
由 Vue 主动给新属性创建响应式访问器,并通知相关依赖更新。

因此,在 Vue 2 中如果一开始就知道对象可能有哪些字段,最好提前声明:

js 复制代码
data() {
  return {
    user: {
      name: '',
      age: null
    }
  }
}

十、Vue 2 怎样处理数组

数组也是对象,但直接给每个索引设置 getter 和 setter 会带来复杂度和性能问题,而且数组长度会频繁变化。

Vue 2 主要通过改写数组的七个变更方法来检测数组操作:

text 复制代码
push
pop
shift
unshift
splice
sort
reverse

概念上可以理解为:

js 复制代码
const originalPush = Array.prototype.push

arrayMethods.push = function (...items) {
  const result = originalPush.apply(this, items)

  // 观察新增元素
  observeArray(items)

  // 通知依赖数组的 Watcher
  this.__ob__.dep.notify()

  return result
}

因此下面的写法可以触发更新:

js 复制代码
this.users.push(newUser)
this.users.splice(index, 1)

但 Vue 2 无法可靠检测下面两种直接操作:

js 复制代码
this.users[0] = newUser
this.users.length = 0

通常改为:

js 复制代码
this.$set(this.users, 0, newUser)
this.users.splice(0)

注意,非变更方法本身不会修改原数组,例如:

js 复制代码
filter()
map()
slice()
concat()

使用这些方法时,一般把返回的新数组重新赋值:

js 复制代码
this.users = this.users.filter(user => user.active)

十一、Vue 3 为什么改用 Proxy

Proxy 可以为整个对象创建代理,而不是逐个改造已有属性。

基本用法如下:

js 复制代码
const user = {
  name: '张三'
}

const proxyUser = new Proxy(user, {
  get(target, key, receiver) {
    console.log('读取属性:', key)
    return Reflect.get(target, key, receiver)
  },

  set(target, key, value, receiver) {
    console.log('设置属性:', key, value)
    return Reflect.set(target, key, value, receiver)
  },

  deleteProperty(target, key) {
    console.log('删除属性:', key)
    return Reflect.deleteProperty(target, key)
  }
})

即使原对象中没有 age,下面的操作仍然可以被代理捕获:

js 复制代码
proxyUser.age = 18
delete proxyUser.name

Proxy 可以拦截的操作比属性 getter/setter 更完整,例如:

text 复制代码
读取属性;
设置属性;
删除属性;
判断属性是否存在;
遍历对象;
获取属性描述符;
设置原型等。

这让 Vue 3 更自然地处理:

  • 对象属性新增和删除;
  • 数组索引和 length 变化;
  • MapSetWeakMapWeakSet 等集合类型;
  • 与遍历、in 操作符相关的依赖。

因此,Vue 3 中通常不再需要 Vue.set

js 复制代码
const user = reactive({ name: '张三' })

user.age = 18
delete user.name

这些操作可以直接被 Proxy 拦截。

十二、Proxy 与 Object.defineProperty 的本质区别

可以用一张表概括:

对比项 Vue 2:Object.defineProperty Vue 3:Proxy
代理对象 针对对象的具体属性 针对整个对象
初始化 需要遍历已有属性 创建对象代理
新增属性 无法自动检测,需要 Vue.set 可以通过 set 拦截
删除属性 无法自动检测,需要 Vue.delete 可以通过 deleteProperty 拦截
数组索引 不能可靠检测直接索引修改 可以拦截索引设置
数组 length 不能可靠检测直接修改 可以拦截
Map、Set 不适合直接处理 可以通过集合相关封装处理
兼容性 支持较老浏览器 Proxy 无法被完整 polyfill

但不要把它理解成:

text 复制代码
Object.defineProperty 完全不能做响应式,Proxy 才能做。

Vue 2 已经证明 Object.defineProperty 可以构建成熟的响应式框架。Vue 3 改用 Proxy,是为了获得更完整的拦截能力、更自然的数据操作方式以及更好的系统设计空间。

十三、为什么 Proxy 中经常使用 Reflect

我们当然可以写:

js 复制代码
get(target, key) {
  return target[key]
}

但 Vue 一类的实现通常使用:

js 复制代码
get(target, key, receiver) {
  return Reflect.get(target, key, receiver)
}

主要原因包括:

1. 与 Proxy 陷阱一一对应

Proxy 中有 getsetdeleteProperty,Reflect 中也有:

js 复制代码
Reflect.get()
Reflect.set()
Reflect.deleteProperty()

代码语义更加统一。

2. 返回操作是否成功

例如:

js 复制代码
const success = Reflect.set(target, key, value, receiver)
return success

这符合 Proxy set 陷阱应返回布尔值的要求。

十四、Vue 3 的依赖表长什么样

一个应用中可能存在:

text 复制代码
多个响应式对象;
每个对象有多个属性;
每个属性被多个 effect 使用。

因此不能像最初示例一样,把所有 effect 放进同一个 Set。

我们需要三级映射:

text 复制代码
目标对象 target
    ↓
属性 key
    ↓
依赖集合 Set<effect>

常见学习模型的数据结构是:

js 复制代码
const targetMap = new WeakMap()

它的完整形状可以写成:

text 复制代码
WeakMap<target, Map<key, Set<ReactiveEffect>>>

假设有:

js 复制代码
const state = reactive({
  count: 0,
  name: '张三'
})

依赖表可能类似:

text 复制代码
targetMap
└── 原始 state 对象
    └── depsMap
        ├── count
        │   └── Set [组件渲染 effect, watchEffect A]
        └── name
            └── Set [组件渲染 effect B]

为什么最外层使用 WeakMap

targetMap 的键是原始对象。

WeakMap 对键保持弱引用:当这个原始对象在应用中已经没有其他引用时,WeakMap 不会仅仅因为自己保存了它,就阻止垃圾回收。

此外,WeakMap 的键必须是对象,正好符合响应式对象的存储需求。

为什么第二层使用 Map

属性键可能是字符串或 Symbol,需要从 key 查到对应依赖,因此使用 Map。

为什么最后使用 Set

同一个 effect 在一次执行中可能多次读取同一个属性:

js 复制代码
effect(() => {
  console.log(state.count, state.count)
})

Set 可以自动去重,避免一个属性变化后重复执行同一个 effect。

十五、实现 effect:记录当前正在执行的副作用

先实现基础版 effect

js 复制代码
let activeEffect

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

  effectFn()
  return effectFn
}

调用:

js 复制代码
effect(() => {
  console.log(state.count)
})

执行函数之前,让全局变量 activeEffect 指向当前 effect。

这样 state.count 的 getter 被触发时,就能知道应该收集谁。

这里不是说 Vue 只能同时存在一个 effect,而是 JavaScript 在当前同步调用位置上,需要有一个上下文表示"现在是谁在读取响应式数据"。

十六、实现 track:读取时收集依赖

js 复制代码
const targetMap = new WeakMap()
let activeEffect

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)
}

流程可以拆成:

text 复制代码
1. 没有 activeEffect,说明当前读取不需要建立依赖;
2. 根据 target 找到这个对象的 depsMap;
3. 根据 key 找到这个属性的 dep;
4. 把 activeEffect 放入 dep。

例如普通日志读取:

js 复制代码
console.log(state.count)

如果它不在 effect 中执行,此时没有 activeEffect,就不需要收集依赖。

否则每次随便读取一个值都建立订阅,会造成没有意义的内存和更新开销。

十七、实现 trigger:修改时通知依赖

js 复制代码
function trigger(target, key) {
  const depsMap = targetMap.get(target)

  if (!depsMap) {
    return
  }

  const dep = depsMap.get(key)

  if (!dep) {
    return
  }

  const effectsToRun = new Set(dep)

  effectsToRun.forEach(effectFn => {
    if (effectFn !== activeEffect) {
      effectFn()
    }
  })
}

这里先复制出一个新的 Set:

js 复制代码
const effectsToRun = new Set(dep)

原因是 effect 重新执行时,后面可能会清理并重新收集依赖。如果一边遍历原 Set,一边删除和重新添加元素,可能造成重复执行甚至无限循环。

同时跳过当前正在执行的 effect:

js 复制代码
if (effectFn !== activeEffect)

用于降低 effect 在执行过程中修改自己依赖的数据而立即递归触发自己的风险。

真实框架还会有更完整的递归控制和调度规则,不能只靠这一句处理所有情况。

十八、实现 reactive

tracktrigger 放进 Proxy:

js 复制代码
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 success = Reflect.set(target, key, value, receiver)

      if (!Object.is(oldValue, value)) {
        trigger(target, key)
      }

      return success
    }
  })
}

测试:

js 复制代码
const state = reactive({
  count: 0,
  name: '张三'
})

effect(() => {
  console.log('count:', state.count)
})

state.count++
state.name = '李四'

输出:

text 复制代码
count:0
count:1

修改 name 不会重新执行只依赖 count 的 effect。

这证明依赖是按照:

text 复制代码
target + key

精确保存的,而不是对象中任意属性变化都触发所有逻辑。

十九、为什么 effect 需要依赖清理

看一个带条件分支的例子:

js 复制代码
const state = reactive({
  ok: true,
  name: '张三'
})

effect(() => {
  console.log(state.ok ? state.name : '暂不显示')
})

第一次执行时:

text 复制代码
读取 state.ok;
因为 ok 为 true,又读取 state.name。

所以这个 effect 同时被收集到 okname 的依赖集合中。

接着执行:

js 复制代码
state.ok = false

effect 重新执行,但这一次只读取 state.ok,不再读取 state.name

此时从逻辑上说:

text 复制代码
name 已经不再是当前 effect 的依赖。

如果不清理旧依赖,再修改:

js 复制代码
state.name = '李四'

effect 仍然会被错误触发。

这类依赖叫作失效的分支依赖。

解决思路是:

text 复制代码
effect 每次重新执行前,先从过去订阅的所有 dep 中移除自己;
本次执行期间,再根据真实读取情况重新收集。

实现清理:

js 复制代码
function cleanup(effectFn) {
  effectFn.deps.forEach(dep => {
    dep.delete(effectFn)
  })

  effectFn.deps.length = 0
}

track 收集时需要反向记录:

js 复制代码
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)
  }

  if (!dep.has(activeEffect)) {
    dep.add(activeEffect)
    activeEffect.deps.push(dep)
  }
}

现在形成双向关系:

text 复制代码
dep 知道有哪些 effect 依赖自己;
effect 也知道自己加入过哪些 dep。

双向记录的目的不是重复保存信息,而是让 effect 能够快速从旧依赖中退出。

二十、为什么 effect 需要栈

effect 可能发生嵌套:

js 复制代码
effect(() => {
  console.log('外层:', state.foo)

  effect(() => {
    console.log('内层:', state.bar)
  })

  console.log('外层继续:', state.baz)
})

如果只使用一个 activeEffect

text 复制代码
执行外层 → activeEffect 是外层;
执行内层 → activeEffect 被改成内层;
内层结束 → activeEffect 变成空;
继续读取 baz → 无法收集到外层。

因此需要栈保存执行上下文:

js 复制代码
let activeEffect
const effectStack = []

function effect(fn, options = {}) {
  const effectFn = () => {
    cleanup(effectFn)

    activeEffect = effectFn
    effectStack.push(effectFn)

    try {
      return fn()
    } finally {
      effectStack.pop()
      activeEffect = effectStack.at(-1)
    }
  }

  effectFn.deps = []
  effectFn.scheduler = options.scheduler

  if (!options.lazy) {
    effectFn()
  }

  return effectFn
}

内层执行结束后,栈顶重新回到外层 effect,因此后续读取仍能正确归属于外层。

try...finally 也很重要:即使用户函数执行时抛出异常,也应该恢复 effect 上下文,避免污染后续依赖收集。

二十一、为什么需要 scheduler 调度器

目前 trigger 会立刻执行 effect:

js 复制代码
effectFn()

但不同 effect 对"怎样更新"的需求并不相同。

例如:

text 复制代码
组件渲染:通常放入更新队列,批量执行并去重;
computed:依赖变化时先标记为过期,不一定立即重新计算;
watch:需要在合适时机调用用户回调;
自定义 effect:可能希望延迟执行或自行控制。

因此 effect 可以接受调度器:

js 复制代码
effect(() => {
  console.log(state.count)
}, {
  scheduler(effectFn) {
    queueMicrotask(effectFn)
  }
})

修改 trigger:

js 复制代码
function trigger(target, key) {
  const depsMap = targetMap.get(target)
  const dep = depsMap?.get(key)

  if (!dep) {
    return
  }

  const effectsToRun = new Set(dep)

  effectsToRun.forEach(effectFn => {
    if (effectFn === activeEffect) {
      return
    }

    if (effectFn.scheduler) {
      effectFn.scheduler(effectFn)
    } else {
      effectFn()
    }
  })
}

调度器把"依赖应该更新"与"依赖何时、以什么方式执行"分开了。

这是连接响应式系统与 Vue 异步更新队列的关键接口。

二十二、嵌套对象为什么也能响应

假设:

js 复制代码
const state = reactive({
  user: {
    profile: {
      name: '张三'
    }
  }
})

只给最外层对象创建 Proxy,并不会自动让内部所有原始对象也变成 Proxy。

因此 get 中需要处理对象结果:

js 复制代码
get(target, key, receiver) {
  const result = Reflect.get(target, key, receiver)

  track(target, key)

  return isObject(result)
    ? reactive(result)
    : result
}

读取:

js 复制代码
state.user.profile.name

过程可以理解为:

text 复制代码
读取 state.user → 得到 user 的代理;
读取 user.profile → 得到 profile 的代理;
读取 profile.name → 收集 name 的依赖。

这通常是一种按访问需要进行的深层转换,而不是创建最外层 reactive 时就立即递归代理所有嵌套对象。

这和 Vue 2 初始化时递归遍历已有属性的方式不同。

二十三、reactive 返回的是原对象吗

不是。

js 复制代码
const raw = {
  count: 0
}

const proxy = reactive(raw)

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

reactive() 返回的是原对象的 Proxy。

因此应该尽量通过代理读写:

js 复制代码
proxy.count++

如果绕过代理修改原对象:

js 复制代码
raw.count++

操作不会经过 Proxy 的 set 陷阱,也就不能按正常方式触发响应式更新。

还要注意身份比较:

js 复制代码
const rawUser = {}
const proxyUser = reactive(rawUser)

console.log(rawUser === proxyUser) // false

项目中不要随意混用原始对象和代理对象进行严格相等比较。需要获取原始对象时,Vue 提供 toRaw(),但通常不应长期持有并用它绕开响应式系统修改状态。

二十四、为什么 reactive 解构后可能失去响应性

看下面的代码:

js 复制代码
const state = reactive({
  count: 0
})

const { count } = state

此时读取 state.count 的动作确实经过了 Proxy,但解构得到的 count 是一个普通数字:

js 复制代码
console.log(count) // 0

之后执行:

js 复制代码
state.count++

不会自动修改局部变量 count

原因不是"解构语法破坏了 Proxy",而是:

text 复制代码
后续读取的是独立的普通局部变量,不再通过 Proxy 访问 state.count。

可以使用:

js 复制代码
const count = toRef(state, 'count')

或者:

js 复制代码
const { count } = toRefs(state)

此时 count 是一个 ref,通过 .value 把访问继续转发到原响应式属性。

对于对象值需要稍微区分:

js 复制代码
const { user } = state

如果 user 本身已经是代理对象,修改 user.name 仍可能响应;但把 user 局部变量整体重新赋值,不会替换 state.user。面试回答时不要笼统地说"任何解构都会彻底失去响应性",应该说明断开的究竟是哪一层属性访问连接。

二十五、ref 的响应式原理

Proxy 只能代理对象,不能直接代理数字、字符串、布尔值等基本类型:

js 复制代码
new Proxy(1, {}) // TypeError

因此 Vue 使用一个对象包装值,并通过 .value 的 getter 和 setter 拦截访问。

简化实现:

js 复制代码
function convert(value) {
  return isObject(value)
    ? reactive(value)
    : value
}

function ref(rawValue) {
  let value = convert(rawValue)
  const wrapper = {
    get value() {
      track(wrapper, 'value')
      return value
    },

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

      rawValue = newValue
      value = convert(newValue)
      trigger(wrapper, 'value')
    }
  }

  return wrapper
}

使用:

js 复制代码
const count = ref(0)

effect(() => {
  console.log(count.value)
})

count.value = 1

如果 ref 保存对象:

js 复制代码
const user = ref({
  name: '张三'
})

二十六、computed 为什么能够缓存

先看使用:

js 复制代码
const firstName = ref('三')
const lastName = ref('张')

const fullName = computed(() => {
  console.log('重新计算')
  return lastName.value + firstName.value
})

计算属性有两个重要特点:

text 复制代码
只有被读取时才需要得到结果;
依赖没有变化时,多次读取可以复用缓存。

一个便于学习的简化模型如下:

js 复制代码
function computed(getter) {
  let value
  let dirty = true

  const computedRef = {
    get value() {
      if (dirty) {
        value = runner()
        dirty = false
      }

      track(computedRef, 'value')
      return value
    }
  }

  const runner = effect(getter, {
    lazy: true,

    scheduler() {
      if (!dirty) {
        dirty = true
        trigger(computedRef, 'value')
      }
    }
  })

  return computedRef
}

第一次读取

js 复制代码
console.log(fullName.value)

因为 dirtytrue,执行 getter,保存结果,然后把 dirty 改成 false

依赖没有变化时再次读取

js 复制代码
console.log(fullName.value)

因为 dirtyfalse,直接返回缓存值。

依赖发生变化

js 复制代码
firstName.value = '四'

调度器不会立刻计算新值,而是:

text 复制代码
把 dirty 标记为 true;
通知依赖 computed.value 的外层使用者。

下一次真正读取 fullName.value 时才重新计算。

因此计算属性缓存的依据不是"参数是否相同",而是它所追踪的响应式依赖是否发生了变化。

二十九、watch 的响应式原理

watch 需要完成:

text 复制代码
执行数据源并收集依赖;
依赖变化后重新得到新值;
比较和保存新旧值;
按照调度策略调用用户回调;
在下一次执行前清理失效副作用。

一个极度简化的模型:

js 复制代码
function traverse(value, seen = new Set()) {
  if (!isObject(value) || seen.has(value)) {
    return value
  }

  seen.add(value)

  for (const key in value) {
    traverse(value[key], seen)
  }

  return value
}

function watch(source, callback) {
  const getter = typeof source === 'function'
    ? source
    : () => traverse(source)

  let oldValue
  let cleanup

  function onCleanup(fn) {
    cleanup = fn
  }

  const job = () => {
    const newValue = runner()

    if (cleanup) {
      cleanup()
      cleanup = undefined
    }

    callback(newValue, oldValue, onCleanup)
    oldValue = newValue
  }

  const runner = effect(getter, {
    lazy: true,
    scheduler: job
  })

  oldValue = runner()
}

使用:

js 复制代码
watch(
  () => state.user.name,
  (newName, oldName, onCleanup) => {
    console.log(oldName, '→', newName)

    const controller = new AbortController()

    onCleanup(() => {
      controller.abort()
    })
  }
)

为什么 source 经常写成 getter

js 复制代码
watch(() => state.user.name, callback)

Vue 执行 getter 时读取 state.user.name,响应式系统才能收集对应依赖。

下面这样写不对:

js 复制代码
watch(state.user.name, callback)

因为调用 watch 前,表达式已经求值成一个普通字符串,watch 无法通过这个字符串知道它来自哪个对象的哪个属性。

deep 为什么需要遍历

监听整个对象的深层变化时,需要读取嵌套属性,才能让 getter/Proxy 的 get 陷阱被触发并建立依赖。

因此深度监听通常意味着遍历,数据结构很大时会有成本。真实 Vue 还要处理数组、集合、循环引用、最大遍历深度等情况。

三十、watchEffect 为什么能自动收集依赖

使用方式:

js 复制代码
watchEffect(() => {
  console.log(state.user.name)
  console.log(state.page)
})

Vue 执行回调期间,读取了 user.namepage,这些属性会自动收集当前副作用。

所以 watchEffect 不需要显式声明 source。

这不是 Vue 对代码进行了字符串分析,也不是它提前知道函数里有哪些变量,而是:

text 复制代码
真正执行函数;
谁在执行期间被读取,谁就建立依赖。

这也解释了为什么异步回调通常只能自动追踪第一次 await 之前同步读取的依赖:

js 复制代码
watchEffect(async () => {
  console.log(state.id) // 同步执行阶段,可以追踪

  await fetchData()

  console.log(state.page) // 已经离开原同步收集上下文
})

依赖追踪依靠的是当前同步执行上下文,而不是贯穿任意异步任务的全局猜测。

三十一、组件是怎样自动更新的

前面一直使用:

js 复制代码
effect(() => {
  console.log(state.count)
})

组件渲染也可以用相似的思路理解。

模板:

vue 复制代码
<template>
  <p>{{ count }}</p>
</template>

会被编译成渲染函数。概念上类似:

js 复制代码
function render() {
  return h('p', null, String(count.value))
}

Vue 为组件建立渲染相关的响应式订阅:

js 复制代码
effect(() => {
  const nextVNode = render()
  patch(previousVNode, nextVNode)
  previousVNode = nextVNode
})

第一次渲染时:

text 复制代码
执行渲染逻辑;
读取 count;
count 收集组件更新相关的 effect。

修改 count 时:

text 复制代码
trigger 找到组件更新 effect;
调度器把组件更新任务放入队列;
队列在合适时机执行;
组件重新运行渲染函数,得到新的 VNode;
patch 对比新旧 VNode,并更新必要的真实 DOM。

所以"响应式更新"和"虚拟 DOM diff"不是同一件事:

text 复制代码
响应式系统负责判断哪个组件需要重新运行;
虚拟 DOM 和 patch 负责判断组件内部哪些 DOM 需要改变。

这也是 Vue 不需要在任何数据变化后重新构建整个页面的原因。

三十二、为什么修改状态后 DOM 不一定立即变化

连续执行:

js 复制代码
count.value++
count.value++
count.value++

如果每一次修改都立即操作 DOM,会产生重复工作。

Vue 通常会通过调度器将组件更新任务放入队列,并进行去重,再在后续时机统一刷新。

因此:

js 复制代码
count.value++
console.log(element.textContent)

这里读取到的 DOM 可能仍然是更新前的内容。

需要等待 Vue 完成 DOM 更新时,可以使用:

js 复制代码
import { nextTick } from 'vue'

count.value++
await nextTick()

console.log(element.textContent)

但要区分:

text 复制代码
JavaScript 中的 count.value 已经同步改变;
延迟的是组件渲染和 DOM 提交。

下一篇文章会专门解释更新队列、微任务、nextTick 和组件更新时机。

三十三、响应式不等于深拷贝

reactive 返回代理,并不会深拷贝原对象:

js 复制代码
const raw = {
  user: {
    name: '张三'
  }
}

const state = reactive(raw)

响应式系统主要做的是:

text 复制代码
在访问原对象数据时增加代理行为。

它不是把所有数据复制出一份。

因此不要把下面几个概念混在一起:

text 复制代码
响应式代理:拦截读取和修改;
浅拷贝:复制第一层结构;
深拷贝:递归复制数据;
不可变数据:更新时创建新结构而不是原地修改。

它们解决的是不同问题。

三十四、响应式数据能直接传给所有第三方库吗

不一定。

有些第三方库会进行严格身份判断、冻结对象、序列化、克隆,或者要求原始对象实例。传入 Proxy 后可能出现兼容问题。

可以按场景考虑:

js 复制代码
const rawData = toRaw(state)

或者创建普通快照:

js 复制代码
const snapshot = structuredClone(toRaw(state))

但二者含义不同:

text 复制代码
toRaw 返回代理背后的原对象,不是拷贝;
structuredClone 创建新的结构化克隆,但只支持可克隆的数据类型。

不要通过 toRaw 得到原对象后长期修改它,否则会绕过代理。

三十五、常见错误理解

1. 错误:Vue 3 不再使用 getter 和 setter

不准确。

reactive 对象主要使用 Proxy,但 ref.value 仍可以通过 getter/setter 访问器完成依赖追踪和触发。

准确说法是:

text 复制代码
Vue 3 使用 Proxy 实现响应式对象,使用访问器实现 ref.value 等响应式容器。

2. 错误:Proxy 会自动更新 DOM

Proxy 只能拦截对象操作。

DOM 能够更新,是因为 Vue 在拦截时完成依赖追踪和触发,再通过组件渲染与 patch 更新页面。

3. 错误:读取响应式数据一定会建立依赖

只有在可追踪的响应式 effect 上下文中读取,才需要收集依赖。

普通读取:

js 复制代码
console.log(state.count)

通常不会因为这行日志创建一个未来要重新执行的订阅。

4. 错误:一个对象变化会让所有使用它的组件更新

依赖通常按属性和操作类型追踪。

只读取 state.count 的组件,不应该因为 state.name 修改就更新。

5. 错误:computed 每次读取都会执行 getter

依赖没有变化时,computed 会复用缓存。依赖失效后,下一次需要值时才重新求值,这是它适合派生状态的重要原因。

6. 错误:Vue 3 有 Proxy,所以没有任何响应式限制

仍然存在边界:

text 复制代码
普通局部变量无法被代理;
绕过代理修改原对象不会正常触发;
基本类型需要 ref 容器;
解构基本类型属性会断开后续代理访问;
外部系统可能不认识 Proxy;
异步代码的依赖追踪受执行上下文影响。

7. 错误:响应式数据变化后 DOM 同步完成

状态通常同步改变,但组件 DOM 更新会被调度和批处理。需要等待 DOM 完成时使用 nextTick,而不是反复读取或使用随意的 setTimeout 猜时间。

三十六、面试高频问题

1. Vue 响应式原理是什么

可以这样回答:

Vue 会拦截响应式状态的读取和修改。读取时通过 track 把当前正在执行的响应式副作用收集到属性依赖中;修改时通过 trigger 找到相关副作用,并直接执行或交给调度器。组件渲染也是一种响应式订阅,因此状态变化后会进入组件更新队列,重新生成 VNode 并 patch 必要的 DOM。

2. Vue 2 和 Vue 3 响应式有什么区别

可以这样回答:

Vue 2 主要使用 Object.defineProperty 把初始化时已有属性转换成 getter 和 setter,需要递归遍历对象,并且不能直接检测对象属性新增、删除以及部分数组索引和 length 操作。Vue 3 使用 Proxy 代理整个对象,能够拦截更多操作,也更适合数组和集合类型;ref 则仍通过 value 访问器追踪和触发。

3. Vue 2 为什么需要 Vue.set

可以这样回答:

Object.defineProperty 只能拦截已经被定义访问器的具体属性。对象初始化完成后直接新增一个属性,不会经过任何已有 setter。Vue.set 会为新属性建立响应式处理,并通知对象相关依赖更新。

4. Vue 2 怎样监听数组

可以这样回答:

Vue 2 会对数组原型上的 push、pop、shift、unshift、splice、sort 和 reverse 七个变更方法做响应式封装,在方法执行后观察新增元素并通知依赖。它不能可靠检测直接修改数组索引或 length,因此通常使用 Vue.set 或 splice。

5. Proxy 相比 Object.defineProperty 有什么优势

可以这样回答:

Proxy 代理整个对象,可以拦截属性读取、设置、删除、存在性判断和遍历等操作,不需要为每个新增属性预先设置 setter,还可以更自然地处理数组及 Map、Set 等集合。代价是 Proxy 无法通过普通 polyfill 完整兼容不支持它的旧浏览器。

6. track 和 trigger 分别做什么

可以这样回答:

track 在响应式属性被读取时,把 activeEffect 收集到 target 和 key 对应的依赖集合中;trigger 在属性变化时,根据相同的 target 和 key 找到依赖,并通知这些 effect 重新执行或进入 scheduler。

7. activeEffect 是什么

可以这样回答:

activeEffect 表示当前正在同步执行、并允许收集响应式依赖的副作用。effect 执行前把自己设为 activeEffect,响应式 getter 就知道应该收集谁;执行结束后恢复之前的上下文。存在嵌套执行时需要用栈或父级引用正确恢复。

8. 为什么依赖使用 WeakMap、Map 和 Set

可以这样回答:

WeakMap 用原始目标对象作为键,不会仅因为依赖表引用目标就阻止其垃圾回收;Map 负责从属性 key 映射到依赖集合;Set 保存 effect 并自动去重。整体结构可以理解为 WeakMap<target, Map<key, Set>>。

9. effect 为什么需要清理依赖

可以这样回答:

effect 每次执行时实际读取的属性可能因条件分支而变化。如果不清理旧依赖,它会继续订阅已经不再读取的属性,导致无效更新。通常在重新执行前从旧 dep 中删除 effect,再按照本轮真实读取重新收集。

10. reactive 为什么可以处理嵌套对象

可以这样回答:

外层 Proxy 的 get 捕获到嵌套属性后,如果结果仍是对象,就返回该对象对应的 reactive 代理。这个过程通常按访问发生,并通过 WeakMap 缓存代理,避免同一原始对象被重复创建代理。

11. 为什么 ref 要有 .value

可以这样回答:

JavaScript 不能直接代理数字、字符串等基本类型,也不能拦截普通局部变量赋值。ref 用对象包装任意值,并通过 value 属性的 getter 和 setter 提供 track 和 trigger 的拦截入口,所以 JavaScript 中需要使用 .value。

12. computed 为什么有缓存

可以这样回答:

computed 会追踪 getter 中读取的响应式依赖,并保存计算结果。依赖未变化时直接返回缓存;依赖变化后先把计算结果标记为失效并通知使用者,下一次读取时再重新计算。它建立在同一套依赖追踪、触发和调度机制之上。

13. watch 的原理是什么

可以这样回答:

watch 会把显式数据源转换为可执行的 getter,并在响应式订阅上下文中运行它以收集依赖。依赖变化后由 scheduler 执行任务,重新获取新值、处理旧值与清理函数,再按照 flush 等配置调用用户回调。

14. reactive 解构为什么可能失去响应性

可以这样回答:

Proxy 只能拦截通过代理对象发生的属性访问。把 state.count 解构成普通数字后,后续读取的是局部变量,不再经过 state 的 Proxy,因此不会随 state.count 自动变化。可以使用 toRef、toRefs,或者保留 state.count 的访问方式。

15. reactive 对象和原对象相等吗

可以这样回答:

不相等。reactive 返回原始对象的 Proxy,因此 proxy !== raw。应该尽量通过代理读写;绕过代理修改 raw 不会正常触发依赖。Vue 会缓存同一个原对象对应的代理,以保持代理身份稳定。

16. 为什么 Vue 不会每次修改状态都立即更新 DOM

可以这样回答:

trigger 表示依赖已经失效,但组件 effect 通常由 scheduler 放入异步更新队列,同一轮中的重复任务会被去重和批处理,从而减少重复渲染。状态值本身已经同步改变,只是 DOM 提交通常要等队列刷新;需要等待时使用 nextTick。

17. Proxy 是否解决了所有问题

可以这样回答:

没有。Proxy 提供更完整的对象操作拦截,但基本类型仍需要 ref,解构普通值仍会断开代理访问,绕过代理修改原对象仍不能正常触发,数组和集合还需要框架实现专门的依赖语义,组件更新也仍然需要调度器和虚拟 DOM。

18. 响应式系统和虚拟 DOM 有什么关系

可以这样回答:

响应式系统负责追踪组件渲染读取了哪些状态,并在这些状态变化后调度对应组件更新;虚拟 DOM 则描述更新后的界面,并通过 patch 比较新旧 VNode,尽量只修改必要的真实 DOM。一个解决"谁需要更新",另一个解决"具体更新什么"。

三十七、本文知识地图

最后把整篇文章压缩成一张知识地图。

1. 响应式目标

text 复制代码
状态变化后,自动通知依赖该状态的计算、侦听和组件更新。

2. 拦截方式

text 复制代码
Vue 2:Object.defineProperty 转换已有属性;
Vue 3 reactive:Proxy 代理整个对象;
Vue 3 ref:通过 value 的 getter/setter 包装任意值。

3. 核心流程

text 复制代码
effect 执行
    ↓
读取响应式属性
    ↓
track(target, key)
    ↓
保存 activeEffect
    ↓
修改响应式属性
    ↓
trigger(target, key)
    ↓
effect 直接执行或交给 scheduler

4. 依赖数据结构

text 复制代码
WeakMap<target, Map<key, Set<effect>>>

5. 必要的工程能力

text 复制代码
cleanup:移除失效分支依赖;
effect 栈:处理嵌套执行;
scheduler:控制执行方式与时机;
Proxy 缓存:保持身份稳定;
操作类型:处理新增、删除、遍历、数组和集合。

6. 上层 API

text 复制代码
reactive:返回对象 Proxy;
ref:通过 value 建立响应式容器;
computed:缓存派生值,并在依赖变化时失效;
watch:追踪指定数据源,并在变化后调度回调;
组件渲染:追踪渲染期间读取的状态,并调度组件更新。

三十八、总结

Vue 响应式看起来涉及很多 API,但主线并不复杂:

text 复制代码
读取时建立联系,修改时通知联系。

Vue 2 使用 Object.defineProperty 为已有属性设置 getter 和 setter,再通过 DepWatcher 完成依赖管理。由于它针对具体属性工作,所以对象新增、删除以及部分数组操作需要额外 API 协助。

Vue 3 使用 Proxy 代理整个对象,可以拦截新增、删除、遍历、数组和集合等更多操作;同时使用 ref 的 .value 访问器解决基本类型无法被 Proxy 直接代理的问题。

但 Proxy 只是入口,完整响应式还需要:

text 复制代码
effect 上下文;
track 依赖收集;
trigger 依赖触发;
分支清理;
调度队列;
代理缓存;
对数组和集合语义的特殊处理。

真正理解这套链路后,之前学过的 API 会连成一体:

text 复制代码
computed 为什么缓存;
watch 为什么要传 getter;
watchEffect 为什么自动追踪;
reactive 为什么不能随便解构;
ref 为什么需要 .value;
组件为什么只更新相关部分。

下一篇将继续讲解 Vue 的异步更新机制,重点包括:

text 复制代码
为什么连续修改状态不会连续更新 DOM;
Vue 更新队列怎样去重和排序;
微任务与宏任务有什么区别;
nextTick 到底在等待什么;
组件更新和生命周期钩子的执行顺序。

参考资料

相关推荐
ikoala4 小时前
DeepSeek 官方仓库惊现 DeepSeek Harness 桌面端!
前端·javascript·后端
liangshanbo12154 小时前
React useEffect 与 useLayoutEffect 面试题整理
前端·javascript·react.js
掰头战士5 小时前
聊聊 Function Calling,你的模型是否答非所问?
前端·typescript·agent
Yeyu5 小时前
车载多 App 同屏渲染(三):RemoteCompose 序列化 UI
前端
weixin199701080165 小时前
[特殊字符]《京东POP二手品类对接:B2C订单模型 vs 二手C2C属性的字段转换难题》(附Python源码)
前端·python·算法
三小河6 小时前
AI 对话 "打字机" 是怎么实现的?从 SSE 流式、Markdown 组件到 Nginx 防粘连
前端·人工智能·面试
雪芽蓝域zzs6 小时前
第三十七节:防抖、节流封装(composables 通用函数,搜索框防重复请求)
前端·javascript·vue.js
by组态6 小时前
Ricon组态系统通信配置指南
前端·后端·物联网
可涵不会debug6 小时前
LangChain 文本分割器详解:字符分割、Token 分割、硬约束递归分割实战
java·前端·数据库
掘金挖土6 小时前
前端手摸手跑路之 AI 应用开发(四)
前端