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 的限制
- 初始化时需要递归观测对象,属性多、层级深时初始化成本较高。
- 不能直接检测后添加的对象属性,需要使用
Vue.set / this.$set。
- 不能直接检测数组索引赋值和数组长度变化,例如
arr[0] = value。
- Vue 2 会重写
push、pop、shift、unshift、splice、sort、reverse 等数组方法,因此这些方法仍然可以触发更新。
- 数组索引可以通过
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 和迭代依赖;Map、Set 也有专门的集合代理逻辑。
四、依赖收集: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 还会处理原始值与代理对象的比较、shallowRef、triggerRef 以及代理缓存等情况。
const count = ref(0)
count.value // 读取,收集依赖
count.value = 1 // 修改,触发依赖
六、computed、watch 与 scheduler
computed
computed 通常具备三个特征:
- 惰性执行:只有被读取时才执行 getter。
- 缓存结果:依赖没有变化时,重复读取不会重新执行 getter。
- 依赖变化时标记为 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:
3. 原对象与代理对象不是同一个对象
const raw = {}
const proxy = reactive(raw)
raw !== proxy
Vue 通过缓存尽量保证同一个原对象对应同一个代理,也提供 toRaw 和 markRaw 处理特殊场景。
4. ref 的自动解包有边界
普通响应式对象属性、模板和部分组件场景会自动解包 ref,但数组和 Map 中的 ref 通常仍需要显式访问 .value:
const list = reactive([ref(1)])
list[0].value
5. 集合类型有特殊依赖
Vue 3 对 Map、Set、数组遍历以及 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 2 | Vue 3 |
|---|
| 核心机制 | Object.defineProperty | Proxy |
| 依赖收集 | getter 中收集 Watcher | Proxy trap 中收集 ReactiveEffect |
| 新增对象属性 | 需要 Vue.set | 直接支持 |
| 删除对象属性 | 需要 Vue.delete | 直接支持 |
| 数组索引 | 直接赋值无法触发 | 支持,但有数组索引和 length 联动逻辑 |
| 数组变异方法 | 通过重写方法支持 | 通过 Proxy 拦截并触发 |
| 深层对象处理 | 初始化时递归观测 | 访问时惰性代理 |
| 基本类型响应式 | 不支持独立基本类型 | 通过 ref 包装 |
| 集合类型 | 支持有限 | 对 Map、Set 等提供专门处理 |
十、面试回答模板
如果被问“Vue 3 的响应式原理是什么”,可以按下面顺序回答:
- Vue 3 使用 Proxy 代理对象,
get 时进行依赖收集,set/delete 时触发更新。
- 依赖关系由
WeakMap → Map → Set 保存,分别对应对象、属性和依赖该属性的 effect。
- 组件渲染本质上是一个 effect,读取响应式数据时会建立组件与数据之间的依赖关系。
- 数据变化后,trigger 找到相关 effect,由 scheduler 批量调度组件更新。
ref 用对象包装基本类型,并通过 .value 的 getter/setter 实现响应式。
computed 基于 lazy effect、缓存和 dirty 标记实现;watch 则是在响应式变化后执行用户回调。
- 需要注意解构丢失响应式、数组和集合类型、浅层 API、原始对象与代理对象身份不同等边界。