Vue 2 用 做响应式,遇到"新增属性不更新""数组索引改了没反应"只能靠 打补丁。Vue 3 把底层换成了 ,从根上解决了这些坑。本文把响应式的"任督二脉"——依赖收集与派发更新——彻底拆开,再看 Composition API 怎么把逻辑组织得更干净。
一、为什么 Vue 2 的响应式不够用
Vue 2 在初始化时递归遍历 data,用 Object.defineProperty 给每个属性加 getter/setter。这带来三个绕不开的硬伤:
| 痛点 | 现象 | Vue 2 的妥协方案 |
|---|---|---|
| 新增属性无响应 | this.obj.newKey = 1 视图不更新 |
必须用 this.$set(obj, 'newKey', 1) |
| 删除属性无响应 | delete this.obj.key 视图不更新 |
必须用 this.$delete(obj, 'key') |
| 数组索引/length 监听不到 | arr[0] = x / arr.length = 0 不触发 |
重写数组 7 个方法(push/pop/...)打补丁 |
只能劫持已经存在的属性,无法感知"属性被新增或删除",这是语言层面决定的死穴。Vue 3 的 能拦截 等十余种操作,从根本上覆盖这些场景。
二、Vue 3 响应式核心:Proxy + Reflect
reactive() 的本质,是给目标对象包一层 Proxy,在 get 时收集依赖、在 set 时触发更新。
function reactive(target) {
return new Proxy(target, {
get(obj, key, receiver) {
// 依赖收集:谁在读这个属性,就记住谁
track(obj, key)
const result = Reflect.get(obj, key, receiver)
// 嵌套对象懒代理,只有被访问才继续 reactive
return typeof result === 'object' && result !== null
? reactive(result)
: result
},
set(obj, key, value, receiver) {
const oldVal = obj[key]
const result = Reflect.set(obj, key, value, receiver)
if (oldVal !== value) {
// 派发更新:属性变了,通知所有依赖它的副作用重跑
trigger(obj, key)
}
return result
},
deleteProperty(obj, key) {
const had = key in obj
const result = Reflect.deleteProperty(obj, key)
if (had) trigger(obj, key) // 删除属性也能触发更新
return result
}
})
}
Reflect 的作用有两个:一是保证 this 指向正确(配合 receiver 处理继承链上的 getter),二是返回 boolean 表示操作是否成功。
为什么必须配合 ?当对象存在继承、且父类 getter 里访问了 时,没有 会让 指向原始对象而非 Proxy,导致依赖收集遗漏。这是手写响应式最容易踩的坑。
三、依赖收集:effect / track / trigger
Vue 3 用一个三级数据结构记录"谁依赖了谁":
| 结构 | 类型 | 作用 |
|---|---|---|
targetMap |
WeakMap |
key 是响应式对象,value 是它的依赖表 |
depsMap |
Map |
key 是属性名,value 是该属性的依赖集合 |
dep |
Set |
存放所有依赖该属性的副作用函数(effect) |
let activeEffect = null // 当前正在执行的副作用
function effect(fn) {
activeEffect = fn
fn() // 执行时会触发 get → track 收集 activeEffect
activeEffect = null
}
function track(target, key) {
if (!activeEffect) return
const depsMap = targetMap.get(target) ?? targetMap.set(target, new Map()).get(target)
const dep = depsMap.get(key) ?? depsMap.set(key, new Set()).get(key)
dep.add(activeEffect)
}
function trigger(target, key) {
const dep = targetMap.get(target)?.get(key)
dep?.forEach(eff => eff()) // 重新执行所有副作用
}
用对象作 key 且不影响垃圾回收——当响应式对象不再被引用时,它的依赖表会随之被回收,避免内存泄漏。这是 Vue 3 对比手写实现的一个关键细节。
四、ref vs reactive:到底用哪个
reactive 只能代理对象,基本类型(number/string/boolean)没法直接 Proxy。于是 Vue 3 引入了 ref:用一个带 .value 的对象把基本类型包起来。
class RefImpl {
constructor(value) {
this.__v_isRef = true
this._value = value
}
get value() { track(this, 'value'); return this._value }
set value(newVal) { this._value = newVal; trigger(this, 'value') }
}
| 维度 | ref |
reactive |
|---|---|---|
| 适用类型 | 基本类型 + 对象 | 仅对象/数组 |
| 访问方式 | 脚本里 .value,模板里自动解包 |
直接访问 |
| 重新赋值 | count.value = 2 安全 |
state = newObj 会丢失响应性 |
| 解构 | 直接解构丢失响应,需 toRefs |
toRefs(state) 保持 |
解构会丢失响应性。直接 拿到的只是那一刻的值;要用 , 会把每个属性包成 ,从而继续追踪。
另外还有几个变体:
shallowRef/shallowReactive:只做浅层响应式,深层变化不触发,适合大对象性能优化。readonly:深层只读代理,常用于把状态暴露给子组件或外部,防止被意外修改。
五、computed:带缓存的派生状态
computed 不是简单包一层 effect,它的核心是两个标志:lazy(默认不立即执行) 和 dirty(脏检查)。
function computed(getter) {
let value, dirty = true
const runner = effect(getter, { lazy: true })
return {
get value() {
if (dirty) { value = runner(); dirty = false } // 依赖没变就返回缓存
track(this, 'value')
return value
},
set value(newVal) { /* 有 setter 时支持写回 */ }
}
}
只有依赖的响应式数据变化、dirty 被 trigger 置回 true 时,下次读取才会重新计算。这避免了模板里重复调用带来的无谓开销。
六、watch 与 watchEffect 的差异
两者都基于 effect,但触发时机和写法不同:
| 对比项 | watch |
watchEffect |
|---|---|---|
| 依赖来源 | 显式指定(第一个参数) | 函数体内用到谁就追踪谁 |
| 触发时机 | 默认 pre(组件更新前),可配 flush |
同上,但首次立即执行 |
| 拿到旧值 | 有 oldVal / newVal |
无旧值 |
| 典型场景 | 监听某个具体状态做异步/副作用 | 依赖收集式地自动追踪 |
的 能保证回调在 DOM 更新后执行,适合"数据变了再操作 DOM"; 则同步触发,谨慎使用,容易引发性能问题。
七、Composition API 实战
Composition API 把"按逻辑关注点"组织代码变成可能,而不是 Options API 的"按选项类型分散"。
import { ref, onMounted, computed } from 'vue'
export function useCounter(initial = 0) {
const count = ref(initial)
const double = computed(() => count.value * 2)
const increment = () => count.value++
onMounted(() => console.log('counter mounted:', count.value))
return { count, double, increment }
}
// 组件中使用
export default {
setup() {
const { count, double, increment } = useCounter(10)
return { count, double, increment } // 模板直接可用
}
}
把状态、计算、方法、生命周期收敛进 useCounter 这样的组合函数(composable),多个组件之间可以零成本复用,逻辑内聚、可读性远胜 Options API 的 data/methods/computed/mounted 四分五裂。
八、编译期 + 运行时的双重性能优化
Vue 3 快,不只是因为 Proxy,更因为编译期帮你做了大量优化:
| 优化手段 | 说明 |
|---|---|
| 静态提升(hoistStatic) | 不变的节点/属性提升到 render 外,只创建一次 |
PatchFlag |
给动态节点打标记,diff 时只比对有变化的维度(class/text/props) |
Block Tree |
以结构稳定的"块"为单位收集动态节点,跳过静态子树 |
| 缓存事件处理函数 | @click="foo" 编译为缓存引用,避免每次渲染新建函数 |
v-memo |
手动声明依赖数组,数组不变则跳过该子树更新 |
<!-- v-memo:列表项成本较高时,只有 id/selected 变才更新 -->
<div v-for="item in list" :key="item.id" v-memo="[item.id, item.selected]">
{{ item.label }}
</div>
九、生产避坑清单
- 大对象用
shallowRef:深层不常变、只在顶层替换时,避免深层递归代理带来的初始化开销。 - 不要在
setup顶层写同步重逻辑:耗时操作放进onMounted或watch回调。 reactive不要整体重新赋值:需要替换请用ref包对象,或Object.assign(state, newObj)。- 解构必用
toRefs:传给子组件的 props/state 解构后仍是响应式的。 computed里不要有副作用:它可能被多次求值或缓存跳过,副作用应放在watch。watch监听对象用深层{ deep: true },但深层监听成本高,尽量监听具体路径() => state.a.b。v-for务必带稳定key,且避免用index当 key 造成错位复用。
一句话总结:Vue 3 响应式 = 拦截读写 + 三级依赖表;组织逻辑用 的组合函数;性能靠编译期 / 与运行期 双管齐下。理解了这条主线,绝大多数"为什么不更新""为什么这么慢"的问题都能定位到根因。