本文目录
我第一次觉得 Vue 的响应式有点“神奇”,是在改一个购物车数量的时候。
const cart = reactive({
price: 12,
count: 2,
})
const total = computed(() => cart.price * cart.count)按钮里只写了一句 cart.count++,页面上的数量和总价就都变了。没有手动找到那两个 DOM,也没有调用某个 render()。刚开始用 Vue 时,这种体验很舒服;等到页面复杂起来,它又会变成一个问题:Vue 到底记住了什么,为什么有些修改能更新页面,有些却不能?
我后来发现,理解响应式不需要先背 API。只要抓住一条主线:
读取时登记关系,修改时通知用过这个值的人。
后面的 reactive、ref、computed 和 watchEffect,都可以放回这条主线里看。
响应式不是不停检查,而是有人订阅
假设一个小区没有快递通知系统。居民想知道包裹到了没有,只能每隔几分钟跑一趟门口。程序也可以这样做:不断比较数据有没有变化,再决定要不要更新页面。但这种轮询既浪费,也很难知道一处变化究竟影响哪些界面。
Vue 走的是另一条路。快递到站时,驿站只通知留下过手机号的人;某个值变化时,Vue 也只通知曾经读取过它的副作用。
这里的“副作用”不是贬义词。组件渲染、更新 DOM、执行一个监听回调,都属于数据之外发生的动作。Vue 用 ReactiveEffect 表示这类需要在依赖变化后重新运行的工作。
所以第一个问题不是“谁修改了数据”,而是“谁读过数据”。关系是在读取那一刻建立的。
Proxy 像一个统一的快递驿站
普通 JavaScript 对象不会主动告诉我们它的属性被读取或修改了:
const state = { count: 0 }
console.log(state.count)
state.count++代码执行完,JavaScript 不会额外发出“刚才有人读取了 count”这样的事件。Vue 3 使用 Proxy 包住对象,让属性访问先经过一个统一入口。这有点像小区把所有快递统一放到驿站:包裹最终还是交给居民,但入站和出站都能被记录。
把实现压到只剩主线,大致是这样:
function reactive(target) {
return new Proxy(target, {
get(target, key, receiver) {
track(target, key)
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
const oldValue = Reflect.get(target, key, receiver)
const result = Reflect.set(target, key, value, receiver)
if (!Object.is(oldValue, value)) {
trigger(target, key)
}
return result
},
})
}读取 proxy.count 会进入 get,Vue 在这里调用 track;修改它会进入 set,Vue 在这里调用 trigger。真正的 Vue 还要处理数组、Map、Set、新增属性、删除属性和迭代等情况,但入口仍然是“读时追踪,写时触发”。
这也解释了一个常见问题:
const state = reactive({ count: 0 })
let { count } = state
count++解构时确实读取过一次 state.count,但后面的 count++ 修改的是局部变量,不再经过代理。就像包裹离开驿站后,居民把它从客厅搬到卧室,驿站不会知道这次移动。需要保留响应式连接时,可以继续访问 state.count,或者使用 toRef、toRefs。
activeEffect:当前是谁在读取
track 要登记订阅关系,先得知道当前是谁在读。
可以把一次副作用运行想成到驿站办理通知登记。工作人员面前一次只处理一个人:这个人查了哪些包裹,就把他的联系方式记到对应的通知名单里。程序里也会保存一个“当前正在运行的副作用”。
let activeEffect
class ReactiveEffect {
constructor(fn) {
this.fn = fn
}
run() {
const parent = activeEffect
activeEffect = this
try {
return this.fn()
} finally {
activeEffect = parent
}
}
}
function effect(fn) {
const reactiveEffect = new ReactiveEffect(fn)
reactiveEffect.run()
return reactiveEffect
}当 fn 执行并读取响应式属性时,代理的 get 会调用 track。track 看见 activeEffect,就知道该把谁放进通知名单。
这里用 parent 恢复上一个副作用,是为了说明副作用可能嵌套。真正的 Vue 还会处理清理旧依赖、暂停追踪、调度和递归保护。我们暂时不展开,因为它们没有改变最核心的登记过程。
targetMap:一份分层通讯录
一个应用里有很多响应式对象,每个对象又有很多属性,每个属性可能被多个副作用读取。把所有关系塞进一张表会很难查找,所以官方文档常用下面的简化模型解释依赖结构:
WeakMap<object, Map<PropertyKey, Set<ReactiveEffect>>>它像一份分层通讯录:
- 先按对象找到哪一栋楼;
- 再按属性找到哪一个房间;
- 最后拿到订阅这个房间变化的通知名单。
最外层使用 WeakMap 很重要。它不会因为依赖表还留着一个键,就强行阻止原对象被垃圾回收。
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)
}读到这里,可以回头看 computed(() => cart.price * cart.count)。计算函数执行时会依次读取 price 和 count,所以同一个计算副作用会进入两个属性的依赖集合。Vue 不需要分析函数字符串,也不需要猜测你用了什么;函数真实执行时读到什么,就登记什么。
trigger:只通知真正订阅过的人
修改发生时,trigger 沿着同一份通讯录反向查找:
function trigger(target, key) {
const depsMap = targetMap.get(target)
const dep = depsMap?.get(key)
if (!dep) return
const effectsToRun = new Set(dep)
effectsToRun.forEach((effect) => effect.run())
}先复制一份集合,是为了避免副作用在运行过程中清理并重新收集依赖时,直接修改当前正在遍历的集合。真正的 Vue 还会把更新放进批处理和调度流程,组件不会因为同一轮里连续修改多个值就无节制地重复渲染。
把前面的片段合起来,已经能做出一个最小响应式系统:
const state = reactive({ price: 12, count: 2 })
effect(() => {
console.log('总价:', state.price * state.count)
})
state.count = 3
// 总价:36第一次运行 effect 是为了完成初次渲染,也为了收集依赖。之后只有 price 或 count 变化时,这个副作用才需要重新执行。
依赖会重收集,不是登记一次就结束
页面真正运行时,依赖关系可能随条件变化:
const state = reactive({
showTotal: true,
total: 24,
})
effect(() => {
console.log(state.showTotal ? state.total : '暂不显示')
})第一次运行时,副作用读取了 showTotal 和 total。后来 showTotal 变成 false,副作用再次运行,却不再读取 total。从这时起,修改 total 不应该继续触发这段输出。
这意味着通知名单不能只增不减。每次副作用重新运行,Vue 都要确认这一次实际读了哪些依赖,并清理已经不用的旧关系。否则页面里切换过一次条件,组件就可能永久订阅条件分支里的所有数据,既多做更新,也难以释放关系。
教学代码通常省略这一步,是为了把 track 和 trigger 讲清楚。Vue 3.5 的 Dep、Link 和版本字段也服务于这类关系维护:链接不只是表示“订阅过”,还要帮助判断它在本轮运行中是否仍然有效。
简化模型和 Vue 3.5 源码不是一回事
WeakMap -> Map -> Set 很适合理解概念,但它不是 Vue 3.5 源码的一比一抄写。
Vue 3.5 仍然使用 targetMap 这个 WeakMap,对象下面也仍然按属性维护 Map。不同之处在最后一层:源码用 Dep 表示一个依赖,用 Link 把依赖和订阅者连接起来,并通过双向链表、版本号和批处理减少重复遍历与清理成本。computed 也会利用全局版本号判断缓存是否可能继续使用。
这层差别值得知道,但不必一开始就背 Dep 和 Link 的每个字段。先用通知名单理解“谁依赖谁”,再读源码里的链表优化,会比直接陷进实现细节更稳。文章里的 Set<ReactiveEffect> 是教学模型,不是假装复刻当前源码。
ref 为什么总要写 .value
Proxy 只能代理对象,不能直接代理一个数字或字符串:
reactive(1) // 没有可拦截的属性访问ref 的办法是给值套一个对象外壳,把真正的读取和修改统一放在 .value 上。这个外壳仍然像驿站,只是它只管理一个固定窗口:
function ref(rawValue) {
const wrapper = {
get value() {
track(wrapper, 'value')
return rawValue
},
set value(newValue) {
if (Object.is(rawValue, newValue)) return
rawValue = newValue
trigger(wrapper, 'value')
},
}
return wrapper
}因此 .value 不是随意增加的语法负担,而是一个真实的拦截点。模板里不用写 .value,是因为 Vue 在模板求值时做了解包;JavaScript 代码里仍然需要明确访问它。
对象当然也可以放进 ref。Vue 会把对象值转换成响应式对象,所以常见经验不是“对象一律用 reactive,基本类型一律用 ref”这么绝对。更实用的判断是:这个状态是否需要整体替换、是否需要跨函数传递一个稳定引用,以及团队更希望用哪种一致写法。
computed:合单预打包,但不会提前把所有包裹封好
我更愿意把 computed 想成驿站的合单预打包。
没人取件时,驿站不会不停重做同一份合单;第一次有人来取,才按清单装箱。只要清单上的货没变,后面再来取同一单可以直接发上次封好的包。货变了,先把“这份合单还能不能用”的标记改掉,等下一次真的有人来取时再重新打包。
这对应 computed 的两个关键点:
- 惰性:没人读取
.value时,不必立即重新计算; - 缓存:依赖没变时,重复读取可以复用上次结果。
const price = ref(12)
const count = ref(2)
const total = computed(() => {
console.log('重新计算')
return price.value * count.value
})
console.log(total.value) // 重新计算,24
console.log(total.value) // 直接使用缓存,24
count.value = 3 // 先让缓存失效
console.log(total.value) // 重新计算,36computed 自己既是订阅者,也是一个可被别人订阅的值。它订阅 price 和 count;组件渲染又可以订阅 total.value。Vue 3.5 的 ComputedRefImpl 内部也有自己的 Dep、依赖链和版本信息,用来完成这两层关系。
watch 和 watchEffect:指定提醒与自动登记
watch 和 watchEffect 都建立在响应式副作用之上,但它们回答的问题不同。
watch 像给某个快递单号设置提醒。你明确告诉系统要看什么,状态变化后再拿到新旧值:
watch(
() => cart.count,
(count, previousCount) => {
console.log(`数量从 ${previousCount} 变成 ${count}`)
},
)依赖来源和执行逻辑是分开的。默认情况下,回调不会因为创建监听就立刻执行。
watchEffect 更像工作人员先开始处理一次任务,并把过程中查询过的项目自动加入通知名单:
watchEffect(() => {
console.log(`当前总价:${cart.price * cart.count}`)
})它会立即运行一次,并在同步执行期间自动收集读取过的响应式依赖。代码短,但依赖藏在函数体里。需要明确来源、旧值和新值时,我通常优先用 watch;逻辑本身就是“用到什么就跟随什么”时,watchEffect 更自然。
异步 watchEffect 还要注意一个边界:只有首次 await 之前同步读取的依赖会被自动收集。因为 await 之后已经进入另一个执行阶段,不能再简单地归到这次同步副作用登记里。
几个容易误判的地方
修改数据不等于立刻改完 DOM
响应式触发的是更新调度。Vue 会把同一轮里的多次修改合并,再刷新组件。刚改完状态就要读取更新后的 DOM 时,应使用 nextTick,而不是假设赋值语句结束时 DOM 已经同步完成。
computed 不是后台常驻计算
依赖变化时,计算属性通常先失效;下一次读取才重新求值。把它理解为缓存,比理解为“自动运行的函数”更准确。
reactive 返回的不是原对象
代理和原对象行为相近,但身份不同:
const raw = { count: 0 }
const proxy = reactive(raw)
console.log(proxy === raw) // false业务代码应尽量围绕代理工作。混用原对象和代理,会让依赖追踪与身份比较都变得难以判断。
真实实现比教学代码多很多边界
数组长度、Map 的键迭代、属性新增与删除、嵌套副作用、依赖清理、调度器和递归保护,都不是几十行示例能覆盖的。最小实现的价值是建立地图,不是替代源码。
再走一遍完整路径
现在把购物车例子重新走一遍:
- 组件渲染或
computed开始运行,当前ReactiveEffect被设为活跃订阅者; - 代码读取
cart.price和cart.count; Proxy.get调用track;track在targetMap中找到对象和属性,把当前副作用连到相应Dep;- 点击按钮执行
cart.count++; Proxy.set发现值真的变化,调用trigger;count对应的依赖通知订阅者,计算属性缓存失效,组件更新进入调度队列;- 下一轮渲染读取
total.value,得到重新计算后的结果; - Vue 比较新旧虚拟 DOM,只更新真正变化的部分。
“页面自己更新”并不是魔法。它只是把读取、依赖和更新连成了一条稳定的链。
以后遇到响应式问题,我通常先问三个问题:这次读取有没有经过代理或 .value?读取发生时有没有活跃副作用?修改之后,通知名单里到底登记了谁?沿着这三个问题往下查,多数问题都会从“Vue 怎么没反应”变成一个可以定位的具体环节。