Vue 响应式原理
理解 Vue 的响应式系统对于深入掌握 Vue 至关重要。本章将详细介绍 Vue 3 响应式系统的工作原理,帮助你更好地理解和使用响应式 API。
为什么需要响应式?
在传统的 JavaScript 中,我们无法自动检测到普通变量的访问或修改。考虑以下代码:
let count = 0
console.log(count) // 输出 0,但没有任何机制可以"追踪"这次访问
count = 1 // 修改了值,但没有机制可以"通知"依赖这个值的地方
Vue 的响应式系统解决了这个问题:它能够追踪数据的读取,并在数据修改时通知所有依赖它的地方进行更新。
Vue 的解决方案
Vue 使用两种技术来实现响应式:
- 依赖追踪:当组件渲染时,记录所有被使用的响应式数据
- 触发更新:当响应式数据被修改时,通知所有依赖该数据的组件重新渲染
响应式基础
什么是响应式?
响应式是指当数据变化时,依赖这些数据的视图会自动更新。这是一个声明式的编程范式——你只需要声明数据和视图的关系,Vue 会自动处理同步:
import { ref } from 'vue'
// 创建响应式数据
const count = ref(0)
// 当 count 变化时,所有使用它的地方都会自动更新
count.value = 1
Vue 2 vs Vue 3 对比
| 特性 | Vue 2 | Vue 3 |
|---|---|---|
| 实现方式 | Object.defineProperty | Proxy |
| 添加属性 | 需要 Vue.set | 直接支持 |
| 删除属性 | 需要 Vue.delete | 直接支持 |
| 数组索引 | 需要 Vue.set | 直接支持 |
| 数组长度 | 不能监听 | 直接支持 |
| Map/Set | 不支持 | 直接支持 |
| 性能 | 一般 | 显著提升 |
Vue 3 使用 Proxy 的优势:
- 可以拦截对象的所有操作,而不仅是属性读写
- 可以监听动态添加的属性
- 可以监听数组索引和长度变化
- 支持原生集合类型(Map、Set、WeakMap、WeakSet)
- 性能更好,惰性响应式(嵌套对象只有被访问时才转为响应式)
Vue 3 响应式原理详解
Proxy 代理
Proxy 是 ES6 提供的元编程特性,可以拦截对象的各种操作:
const original = { count: 0 }
// 创建代理对象
const proxy = new Proxy(original, {
// 拦截属性读取
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)
}
})
proxy.count // 输出: 读取属性: count
proxy.count = 1 // 输出: 设置属性: count = 1
delete proxy.count // 输出: 删除属性: count
Reflect 的作用:Reflect 提供了与 Proxy 拦截器对应的方法,用于执行对象的默认行为。使用 Reflect 可以确保 this 指向正确,并保持默认行为的一致性。
reactive() 的实现原理
Vue 3 的 reactive() 函数使用 Proxy 创建响应式对象:
// 简化版实现
const reactiveMap = new WeakMap()
function reactive(target) {
// 如果已经是响应式对象,直接返回
if (reactiveMap.has(target)) {
return reactiveMap.get(target)
}
// 创建 Proxy
const proxy = new Proxy(target, {
get(target, key, receiver) {
// 依赖收集:记录"谁在读取这个属性"
track(target, key)
// 获取值
const result = Reflect.get(target, key, receiver)
// 如果是对象,递归创建响应式(惰性响应式)
if (result !== null && typeof result === 'object') {
return reactive(result)
}
return result
},
set(target, key, value, receiver) {
const oldValue = target[key]
const result = Reflect.set(target, key, value, receiver)
// 只有值真正变化时才触发更新
if (oldValue !== value) {
// 触发更新:通知所有依赖这个属性的地方
trigger(target, key)
}
return result
},
deleteProperty(target, key) {
const result = Reflect.deleteProperty(target, key)
trigger(target, key)
return result
}
})
// 缓存代理对象
reactiveMap.set(target, proxy)
return proxy
}
ref() 的实现原理
ref() 用于创建任意类型的响应式数据,包括基本类型:
// 简化版实现
function ref(value) {
// 如果已经是 ref,直接返回
if (value.__v_isRef) {
return value
}
return {
__v_isRef: true, // 标记为 ref
_rawValue: value, // 存储原始值
_value: value, // 存储响应式值
get value() {
// 依赖收集
track(this, 'value')
return this._value
},
set value(newValue) {
// 只有值真正变化时才触发更新
if (newValue !== this._rawValue) {
this._rawValue = newValue
// 如果新值是对象,转为响应式
this._value = isObject(newValue) ? reactive(newValue) : newValue
// 触发更新
trigger(this, 'value')
}
}
}
}
为什么 ref 需要 .value?
JavaScript 中没有一种机制可以拦截基本类型(如数字、字符串)的访问和修改。通过将基本类型包装在一个对象中,使用 .value 属性进行读写,Vue 就可以在 getter 和 setter 中执行依赖追踪和触发更新:
// 问题:无法拦截基本类型的修改
let count = 0
count = 1 // 没有办法知道 count 被修改了
// 解决方案:包装成对象
const count = ref(0)
count.value = 1 // 可以在 setter 中拦截
依赖追踪系统
核心概念
Vue 的响应式系统基于三个核心操作:
- track(追踪):记录"谁在读取这个响应式数据"
- trigger(触发):通知"这个响应式数据被修改了"
- effect(副作用):响应式数据变化时要执行的代码
依赖收集实现
// 当前正在执行的副作用函数
let activeEffect = null
// 存储所有依赖关系
// targetMap: WeakMap<target, Map<key, Set<effect>>>
const targetMap = new WeakMap()
// 追踪依赖
function track(target, key) {
if (!activeEffect) return
// 获取或创建 target 的依赖映射
let depsMap = targetMap.get(target)
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()))
}
// 获取或创建 key 的副作用集合
let dep = depsMap.get(key)
if (!dep) {
depsMap.set(key, (dep = new Set()))
}
// 将当前副作用添加到集合
dep.add(activeEffect)
}
// 触发更新
function trigger(target, key) {
const depsMap = targetMap.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
if (effects) {
// 执行所有依赖这个属性的副作用
effects.forEach(effect => effect())
}
}
// 创建副作用
function effect(fn) {
activeEffect = fn
fn() // 执行时会触发 track
activeEffect = null
}
依赖追踪流程
┌─────────────────────────────────────────────────────────────────┐
│ 依赖追踪流程 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 组件渲染 │
│ │ │
│ ▼ │
│ 2. effect(组件渲染函数) │
│ │ │
│ ▼ │
│ 3. 读取响应式数据 → track(target, key) │
│ │ │
│ ▼ │
│ 4. 记录依赖关系:target.key → [effect] │
│ │ │
│ ▼ │
│ 5. 修改响应式数据 → trigger(target, key) │
│ │ │
│ ▼ │
│ 6. 执行所有依赖的 effect → 组件重新渲染 │
│ │
└─────────────────────────────────────────────────────────────────┘
响应式对象的特性
Reactive Proxy vs 原始对象
reactive() 返回的是原始对象的 Proxy,两者不相等:
import { reactive } from 'vue'
const raw = { count: 0 }
const proxy = reactive(raw)
console.log(proxy === raw) // false
console.log(proxy.count) // 0(通过 Proxy 访问)
重要原则:只使用 Proxy 版本的对象,不要保留对原始对象的引用。修改原始对象不会触发更新:
// 错误:修改原始对象不会触发更新
raw.count = 1 // 不会触发更新!
// 正确:修改 Proxy 对象
proxy.count = 1 // 会触发更新
Proxy 的一致性
对同一个对象多次调用 reactive() 会返回同一个 Proxy:
const raw = { count: 0 }
const proxy1 = reactive(raw)
const proxy2 = reactive(raw)
console.log(proxy1 === proxy2) // true
// 对 Proxy 调用 reactive 返回自身
console.log(reactive(proxy1) === proxy1) // true
深层响应式
reactive() 会将嵌套对象也转为响应式:
import { reactive } from 'vue'
const state = reactive({
user: {
name: '张三',
address: {
city: '北京'
}
}
})
// 深层修改也是响应式的
state.user.address.city = '上海' // 触发更新
这是通过"惰性响应式"实现的:只有访问嵌套对象时,才会将其转为响应式。这种设计比 Vue 2 的递归转换性能更好。
ref 的解包行为
在 reactive 对象中自动解包
当 ref 作为 reactive 对象的属性时,会自动解包:
import { ref, reactive } from 'vue'
const count = ref(0)
const state = reactive({ count })
// 自动解包,不需要 .value
console.log(state.count) // 0
// 修改时也自动解包
state.count = 1
console.log(count.value) // 1
// 赋值新的 ref 会替换旧 ref
const otherCount = ref(2)
state.count = otherCount
console.log(state.count) // 2
console.log(count.value) // 1(旧 ref 不再关联)
数组和集合类型中的 ref 不会解包
在数组、Map、Set 等集合类型中,ref 不会自动解包:
import { ref, reactive } from 'vue'
// 数组中的 ref
const books = reactive([ref('Vue 3 Guide')])
console.log(books[0].value) // 需要 .value
// Map 中的 ref
const map = reactive(new Map([['count', ref(0)]]))
console.log(map.get('count').value) // 需要 .value
模板中的自动解包
在模板中使用 ref 时会自动解包,但有一个重要的限制:只有顶层的 ref 属性才会解包。
<script setup>
import { ref } from 'vue'
const count = ref(0)
const object = { id: ref(1) }
</script>
<template>
<!-- ✅ 正确:顶层 ref 自动解包 -->
<p>{{ count }}</p>
<p>{{ count + 1 }}</p>
<!-- ❌ 错误:嵌套 ref 不会解包 -->
<p>{{ object.id + 1 }}</p>
<!-- 结果: "[object Object]1" -->
<!-- ✅ 正确:解构到顶层 -->
<p>{{ object.id }}</p>
<!-- 结果: 1(文本插值的特殊处理) -->
</template>
解决方案:解构到顶层:
<script setup>
import { ref } from 'vue'
const object = { id: ref(1) }
const { id } = object // 解构到顶层
</script>
<template>
<p>{{ id + 1 }}</p> <!-- 2 -->
</template>
DOM 更新时机
异步更新策略
Vue 的 DOM 更新是异步的。当响应式数据变化时,Vue 不会立即更新 DOM,而是将这些更新缓存在一个队列中,在"下一个 tick"时批量执行:
import { ref } from 'vue'
const count = ref(0)
count.value++
count.value++
count.value++
// DOM 还没有更新
console.log(document.querySelector('#count').textContent) // 0
// 多次修改只会触发一次 DOM 更新
这种策略确保了无论修改了多少次数据,组件只会更新一次,提高了性能。
nextTick
使用 nextTick() 等待 DOM 更新完成:
import { ref, nextTick } from 'vue'
const count = ref(0)
async function increment() {
count.value++
// DOM 还没更新
console.log(document.querySelector('#count').textContent) // 旧值
// 等待 DOM 更新
await nextTick()
// DOM 已更新
console.log(document.querySelector('#count').textContent) // 新值
}
实际应用场景:
<script setup>
import { ref, nextTick } from 'vue'
const show = ref(false)
const inputRef = ref(null)
async function showAndFocus() {
show.value = true
// DOM 还没更新,input 不存在
// inputRef.value 是 null
await nextTick()
// DOM 已更新,可以安全地操作 input
inputRef.value?.focus()
}
</script>
<template>
<input v-if="show" ref="inputRef" />
<button @click="showAndFocus">显示并聚焦</button>
</template>
Computed 原理
计算属性的实现
计算属性基于"惰性求值"和"缓存":
function computed(getter) {
let value
let dirty = true // 脏标记:是否需要重新计算
// 创建一个副作用,用于追踪依赖
const effect = () => {
value = getter()
dirty = false
}
return {
get value() {
if (dirty) {
// 执行计算,追踪依赖
activeEffect = effect
value = getter()
activeEffect = null
dirty = false
}
return value
}
}
}
缓存机制
计算属性只有在其依赖发生变化时才会重新计算:
import { ref, computed } from 'vue'
const count = ref(1)
const doubled = computed(() => {
console.log('计算中...') // 只在需要时输出
return count.value * 2
})
// 首次访问,执行计算
console.log(doubled.value) // 输出: 计算中... 然后输出: 2
// 再次访问,使用缓存
console.log(doubled.value) // 直接输出: 2(不重新计算)
// 依赖变化,标记为脏
count.value = 2
// 重新计算
console.log(doubled.value) // 输出: 计算中... 然后输出: 4
Watch 原理
watchEffect 的实现
watchEffect 立即执行回调,并自动追踪其中使用的所有响应式数据:
function watchEffect(effect) {
// 创建一个包装函数
const wrappedEffect = () => {
activeEffect = wrappedEffect
effect() // 执行时会触发 track
activeEffect = null
}
// 立即执行一次
wrappedEffect()
// 返回停止函数
return () => {
// 清理所有依赖
cleanup(wrappedEffect)
}
}
watch 的实现
watch 允许指定要监听的源,并获取新旧值:
function watch(source, callback, options = {}) {
let getter
let oldValue
// 确定如何获取值
if (typeof source === 'function') {
getter = source
} else if (isRef(source)) {
getter = () => source.value
} else if (isReactive(source)) {
getter = () => traverse(source)
}
// 创建副作用
const effect = () => {
const newValue = getter()
if (newValue !== oldValue || options.deep) {
callback(newValue, oldValue)
oldValue = newValue
}
}
// 立即执行(immediate 选项)
if (options.immediate) {
effect()
} else {
oldValue = getter()
}
// 追踪依赖
trackEffect(effect)
// 返回停止函数
return () => stopEffect(effect)
}
// 深度遍历以触发所有属性的追踪
function traverse(value, seen = new Set()) {
if (typeof value !== 'object' || seen.has(value)) return value
seen.add(value)
for (const key in value) {
traverse(value[key], seen)
}
return value
}
shallowRef 和 shallowReactive
浅层响应式
默认情况下,ref 和 reactive 都是深层响应式。如果只需要追踪顶层变化,可以使用浅层版本:
import { shallowRef, shallowReactive } from 'vue'
// shallowRef:只有 .value 的变化是响应式的
const state = shallowRef({
nested: { count: 0 }
})
state.value = { nested: { count: 1 } } // ✅ 触发更新
state.value.nested.count = 2 // ❌ 不触发更新
// shallowReactive:只有顶层属性变化是响应式的
const shallow = shallowReactive({
count: 0,
nested: { value: 1 }
})
shallow.count = 1 // ✅ 触发更新
shallow.nested.value = 2 // ❌ 不触发更新
使用场景
import { shallowRef, triggerRef } from 'vue'
// 大型数据结构,避免深层响应式开销
const bigData = shallowRef({
// 大量嵌套数据...
})
// 修改深层数据后手动触发更新
bigData.value.nested.deepValue = 'new'
triggerRef(bigData) // 手动触发更新
// 与外部状态管理系统集成
const externalState = shallowRef(null)
externalState.value = thirdPartyLib.getState()
响应式丢失问题
常见陷阱
import { reactive, toRefs } from 'vue'
const state = reactive({
name: '张三',
age: 25
})
// ❌ 错误:解构会丢失响应式
let { name, age } = state
name = '李四' // 不会触发更新,只是修改了局部变量
// ✅ 正确:使用 toRefs 保持响应式
const { name, age } = toRefs(state)
name.value = '李四' // 会触发更新
toRefs 的原理
function toRefs(object) {
const result = {}
for (const key in object) {
result[key] = toRef(object, key)
}
return result
}
function toRef(object, key) {
return {
get value() {
return object[key]
},
set value(newValue) {
object[key] = newValue
}
}
}
toRefs 创建的 ref 与原对象保持同步:修改任一方都会影响另一方。
性能优化建议
避免不必要的响应式
import { ref, reactive, shallowRef, markRaw } from 'vue'
// 大型不可变数据使用 shallowRef
const bigList = shallowRef([])
// 确实不需要响应式的对象使用 markRaw
const staticConfig = markRaw({
// 大量静态配置...
})
// 第三方库实例通常不需要响应式
const chartInstance = markRaw(new Chart(...))
合理使用计算属性
import { ref, computed } from 'vue'
const items = ref([...])
// ✅ 好:计算属性有缓存
const filteredItems = computed(() =>
items.value.filter(item => item.active)
)
// ❌ 差:方法每次都重新计算
function getFilteredItems() {
return items.value.filter(item => item.active)
}
避免在 watchEffect 中修改依赖
import { ref, watchEffect, watch } from 'vue'
const count = ref(0)
// ❌ 危险:可能导致无限循环
watchEffect(() => {
count.value = count.value + 1
})
// ✅ 正确:使用 watch 并添加条件
watch(count, (newValue) => {
if (newValue < 10) {
count.value = newValue + 1
}
})
小结
本章我们深入学习了 Vue 响应式原理:
- 响应式概念:数据变化自动更新视图的机制
- Proxy 代理:Vue 3 响应式系统的基础
- reactive 实现:对象响应式代理的创建
- ref 实现:基本类型和对象类型的响应式包装
- 依赖追踪:track 和 trigger 的协作机制
- ref 解包规则:reactive 对象中、数组/集合中、模板中的不同行为
- DOM 更新时机:异步更新策略和 nextTick
- Computed 原理:惰性求值和缓存机制
- Watch 原理:监听数据变化的不同方式
- 浅层响应式:shallowRef 和 shallowReactive
- 性能优化:避免不必要的响应式开销
练习
- 手写一个简化版的
reactive()函数 - 手写一个简化版的
ref()函数 - 分析
computed和watchEffect的依赖追踪过程 - 实现
toRefs()函数