适合人群:会写模板和指令,但说不清
.value是干嘛的、reactive解构后为什么页面不更新的同学。
本篇基于 Vue 3.5。上一篇《模板语法与指令》结尾留了个问题:数据变化是怎么被察觉的? 这篇正面回答。
一、先想清楚:命令式与声明式的分野
用 jQuery(或者裸 JS)写页面的日子是这样的:
1 | // 命令式:每一步都亲手指挥 DOM |
数据变了?你得自己记得去找所有用到它的地方,一个一个更新。忘了一处,页面就和数据不一致——这类 bug 的本质是:数据和视图的同步靠人肉维护。
Vue 的世界观相反:
1 | // 声明式:只描述"视图长什么样",不管怎么更新 |
1 | <span :style="{ color: count > 10 ? 'red' : 'black' }">{{ count }}</span> |
数据变了,视图自动变。听起来像魔法,但框架领域没有魔法,只有机制。这个机制就是响应式系统,它要回答三个问题:
- 数据被读取时,怎么知道谁在读?(不然不知道更新谁)
- 数据被修改时,怎么察觉?
- 察觉之后,怎么精准更新视图?
带着这三个问题往下看。
二、ref:给值套一个”盒子”
先看结论:ref 把值包进一个带 .value 属性的对象。
1 | <script setup> |
为什么非要 .value?回到问题 2——怎么察觉数据被修改。Vue 3 的答案是 Proxy,但 Proxy 只能拦截对象的操作(属性读写),对 let count = 0 这种基本类型毫无办法:数字就是数字,赋值时没有任何钩子可挂。
所以 Vue 把值装进盒子 { value: 0 },对盒子的 .value 读写就可以被拦截了。count.value++ 实际经历了:读取 .value → 修改 .value → 拦截器通知更新。.value 不是设计洁癖,是拦截的挂载点。
模板里为什么不用写 .value?编译器会把模板里对 ref 的裸引用自动补上(count → count.value),这是编译期做的事,省你一层心智。
两个补充事实:
ref也能装对象:ref({ name: 'frank' }),此时.value是一个深层响应式的对象,里面嵌套多深都会被追踪;- 只想要浅层追踪时用
shallowRef,性能敏感的大列表场景偶尔会用到。
三、reactive:Proxy 直接包对象
reactive 是不用盒子的方案——直接用 Proxy 代理整个对象:
1 | import { reactive } from 'vue' |
写起来比 ref 爽,但它有三条铁律级的局限,全都源于一点:响应式在”这个代理对象”身上,不在变量名上。
1 | const { name } = user // 坑 1:解构后再改 name,页面不动 |
ref vs reactive 怎么选
官方现在的推荐很明确:默认用 ref。理由:
ref没有解构丢响应问题——你永远带着.value,想丢都难;.value是个显式信号,读代码时一眼分清”这是响应式数据”还是普通变量;reactive的舒适区只有”一大坨配置型对象”,而这种场景往往用ref包对象也能覆盖。
.value 多敲五个字符,换来的是整类 bug 从根上消失,这笔账划算。后端类比:就像 Java 里宁可显式 Optional 也不愿拿到一个”可能是 null 也可能不是”的引用——约束写在类型上,好过靠自觉。
四、computed:带缓存的派生值
页面经常需要”由现有数据算出来的值”:
1 | import { ref, computed } from 'vue' |
1 | <p>总价:{{ total }}</p> |
不要写成方法:function getTotal() { ... } 每次渲染都重算;computed 会缓存——依赖(items)没变,多次读取 total 只算一次,模板里用十次也只算一次。这是 computed 和方法调用的本质区别,语义上接近 Spring 的 @Cacheable:以依赖为 key 的自动缓存。
三条使用纪律:
- getter 必须纯净:不发起请求、不改别的状态、不写
Date.now()这种每次都变的值——缓存的前提是”依赖不变则结果不变”; - 别在 computed 里干重活以外的事,副作用是 watch 的领地;
computed默认只读,需要可写时提供 getter/setter 双函数(比如全名 = 名 + 姓,setter 里拆回去)。
五、watch 与 watchEffect:响应”变化”这件事
computed 是”派生一个值”,watch 是”数据变了之后做点事“——发请求、写 localStorage、操作非响应式的外部系统:
1 | import { ref, watch } from 'vue' |
三个高频配置:
1 | // 深度监听:watch 一个 ref 装的对象时默认会深层追踪, |
watchEffect 是自动版:不指定监听谁,回调里读了谁就监听谁,并且立即执行一次:
1 | watchEffect(() => { |
选型口诀:要”值”用 computed,要”动作”用 watch,动作依赖的关系懒得列(或天然就是”一启动就要跑”)用 watchEffect。工程里的常见拍板:搜索框防抖用 watch + onCleanup,初始加载用 watchEffect 或 onMounted,列表过滤/汇总一律 computed。
六、原理三步走:拦截 → 收集 → 派发
现在把开篇三个问题串起来。Vue 3 响应式的核心是两个拦截时机 + 一个登记簿:
- 拦截 get(读):任何人读响应式数据,Vue 假设”当前正在运行的这个副作用函数”依赖它。”副作用函数”目前可以粗略理解为组件的渲染函数——组件渲染时读了
count,说明它依赖count。 - 依赖收集(track):把”count 被组件 A 依赖”这条关系登记进一个全局的映射表(数据 → 依赖它的副作用集合)。这就是观察者模式的注册环节,和你在后端写事件监听器
addListener是一回事,只不过 Vue 帮你全自动注册。 - 拦截 set(写)→ 派发更新(trigger):
count.value++触发 set 拦截,查登记簿找到依赖方,逐个重新执行——组件渲染函数重跑,生成新的虚拟 DOM,diff 后更新真实 DOM。
上一篇文章讲的模板更新、这一篇讲的 computed 缓存失效、watch 触发,底层都是这一套。所谓”双向绑定”,也不过是 v-model 的 @input 回写触发 set,set 触发 trigger,trigger 重渲染——同一个环。
七、新手最常踩的五个坑
- 解构 reactive:
const { name } = user之后改name页面不动——解构出来的是快照不是引用。要解构用toRefs(user),每个属性转成 ref。 - ref 忘了
.value:脚本里count++改的是盒子的引用比较?不,那是对一个对象做++,直接报错或静默无效。记住:脚本里永远 .value,模板里永远不用。 - watch 以为监听了,其实监听了个寂寞:
watch(form.value, ...)传的是当前快照对象,换成watch(form, ...)(传 ref 本身)。 - 在 computed 里发请求/改状态:缓存语义被破坏,还可能引发连锁更新死循环。派生归 computed,动作归 watch。
- 把接口返回直接塞进 reactive 再整体替换:
state = res.data丢响应。要么Object.assign(state, res.data),要么一开始就用 ref:list.value = res.data。
八、总结
- 响应式系统回答三件事:谁在读、怎么察觉写、怎么精准更新;
- Proxy 只能拦对象,所以基本类型用 ref 装盒子,
.value是拦截挂载点; - reactive 写着爽但有解构/替换丢响应的坑,官方推荐默认 ref;
- computed 是以依赖为 key 的缓存派生,watch 负责变化后的动作,watchEffect 自动收集依赖;
- 底层机制是拦截 get 做 track、拦截 set 做 trigger——观察者模式的自动化版本。
数据有了、模板有了,但真实页面是成百上千个组件拼出来的——组件之间怎么传数据、怎么发事件、怎么共享状态?下一篇讲组件通信,其中 provide/inject 那一节,你会发现它和 Spring 的依赖注入惊人地神似。