响应式状态与事件
props 是从外面传进来的、只读的数据(第 3 章)。**状态(state)**是组件自己拥有、可以变化的数据。Vue 里让数据「变 → 界面自动跟着变」的能力,来自两个 API:ref 和 reactive。
1. ref 与 reactive
1.1 基本用法
<script setup lang="ts">
import { ref, reactive } from 'vue';
const count = ref(0);
const user = reactive({ name: 'Ada', age: 36 });
console.log(count.value); // 读要 .value
console.log(user.name); // reactive 对象直接读属性
function inc() {
count.value++; // 写也要 .value
user.age++;
}
</script>
<template>
<p>{{ count }}</p> <!-- 模板里自动解包,不用 .value -->
<p>{{ user.name }}</p>
<button @click="inc">+1</button>
</template>1.2 为什么 ref 要有 .value
这是新手最不习惯的地方。原因很硬:JavaScript 无法拦截「原始值(number/string/boolean)的读写」。
let count = 0;
// 当你写 count = 1,JS 引擎只做了一次赋值,没有任何「钩子」能让 Vue 知道「count 变了」Vue 要「在数据变化时自动更新界面」,就必须有个地方能拦截读写。对原始值,唯一可行的办法是把它装进一个对象里,用 get/set 拦截器:
const count = { get value() { /* 拦截读 */ }, set value(v) { /* 拦截写 */ } };所以 ref 本质上就是一个带 .value 的盒子。模板里 {{ count }} 之所以不用写 .value,是因为编译器在编译模板时自动帮你补上了 .value(只对顶层绑定生效;如果在数组、Map 里,ref 不会被自动解包,还是要手动 .value)。
reactive 用 Proxy 拦截对象,对对象很自然。但 Proxy 只能包对象,不能包原始值(new Proxy(0, ...) 直接报错)。原始值必须用 ref 这个盒子装起来。Vue 的默认建议是:统一用 ref——无论原始值还是对象,ref 都能装(对象会被 ref 内部转成 reactive),心智负担最小。
1.3 ref 还是 reactive?
| ref | reactive | |
|---|---|---|
| 能包什么 | 原始值 + 对象 | 只能对象/数组 |
| 读取 | 要 .value | 直接读属性 |
| 整体替换 | 可以 count = ref(x) | 不能整体替换(user = {...} 会断掉响应性) |
| 解构 | 解构会丢响应(要用 toRefs,第 9 章) | 直接解构直接丢响应 |
| 模板里 | 顶层自动解包 | 直接用 |
结论:默认用 ref。 只有在「明确一个对象且永远不会整体替换」时,才用 reactive。
1.4 Vue 3 的 Proxy 比 Vue 2 强在哪
reactive 基于 Proxy,所以这些写法天然支持,不需要 Vue 2 的 Vue.set:
const state = reactive({ list: [] as number[] });
state.list[0] = 1; // 索引赋值 ✅
state.list.push(2); // 数组方法 ✅
state.list.length = 0; // 改长度 ✅
state.map = new Map(); // Map ✅
state.map.set('k', 1); // Set / Map 也被代理 ✅Vue 2 用 Object.defineProperty,对新增属性、数组索引、length 都无能为力,必须 Vue.set。Vue 3 彻底解决了。
2. 事件处理
2.1 三种写法
<template>
<!-- 内联表达式 -->
<button @click="count++">+1</button>
<!-- 函数名(Vue 自动调用,不用 () =>)-->
<button @click="inc">加</button>
<!-- 内联里访问事件对象用 $event -->
<input @input="onInput($event)" />
<!-- 自定义事件(子传父,见第 3 章 defineEmits)-->
<Child @change="onChange" />
</template>2.2 修饰符
<template>
<form @submit.prevent="onSubmit">提交</form> <!-- preventDefault -->
<button @click.stop="close">关闭</button> <!-- stopPropagation -->
<a @click.once="track">只触发一次</a>
<input @keyup.enter="submit" /> <!-- 回车 -->
<div @click.self="onlySelf">只点自己</div> <!-- 自己不是子元素才触发 -->
</template>修饰符是编译期生成的包装,等价于手写 e.preventDefault() 等。
3. 更新是异步批量的
和 React 类似,同一轮事件里多次改状态,界面只重渲染一次:
<script setup>
const count = ref(0);
const double = ref(0);
function handleClick() {
count.value++;
double.value = count.value * 2;
// 这里立即读 DOM,拿到的还是「旧值」——因为渲染还没发生
}
</script>改完状态后立刻读 DOM 或读依赖它的计算值,拿到的可能是旧的。需要「等渲染完」用 nextTick(第 6 章)。
下面这个 Playground 亲手实现一个迷你 ref,看清「盒子 + 订阅者集合 + 同值短路」这套机制——这正是 Vue 响应式的最小内核。
// 全局「当前正在运行的副作用」,用来收集依赖
let activeEffect: (() => void) | null = null;
// 副作用:运行 fn,期间读取到的 ref 会把自己登记到 activeEffect
function effect(fn: () => void): void {
activeEffect = fn;
fn();
activeEffect = null;
}
// 迷你 ref:一个带 .value 的盒子
function miniRef<T>(init: T): { value: T } {
let _value = init;
const subs = new Set<() => void>();
return {
get value(): T {
if (activeEffect) {
subs.add(activeEffect);
}
return _value;
},
set value(nv: T) {
if (Object.is(nv, _value)) return; // 同值短路,不触发
_value = nv;
for (const sub of subs) {
sub();
}
},
};
}
const c = miniRef(0);
let renderCount = 0;
// 第一次运行 effect:读取 c.value -> 建立订阅,并打印
effect(() => {
renderCount++;
console.log("渲染: count = " + c.value);
});
c.value = 0; // 赋相同值,不触发
console.log("赋相同值后 renderCount = " + renderCount);
c.value = 5; // 赋新值,触发重新渲染
console.log("最终 renderCount = " + renderCount);
// 对照组:普通变量,赋值无法被拦截
let plain = 0;
let plainCount = 0;
effect(() => {
plainCount++;
console.log("普通变量渲染: " + plain);
});
plain = 9; // 不会被拦截,plainCount 不变
console.log("改普通变量后 plainCount = " + plainCount);第一组输出最值得琢磨:赋相同值(0)时 renderCount 没变——Object.is 同值短路,这正是 Vue 避免无谓重渲染的第一道关卡。第二组则证明:普通变量的赋值根本无法被「拦截」,所以 Vue 必须用 ref 这个盒子把原始值包起来才能追踪。
4. 设计状态的原则
和 React 一样,Vue 也遵循:
- 不存冗余数据:能从其他状态算出来的,用
computed(第 5 章),不要存成 state - 避免深层嵌套:扁平结构更新简单
- 相关的放一起:两个状态总是一起变,考虑合并成一个
reactive对象 - 能推导就不存:列表里「还剩几项」、
isAllDone这类都该现算
下面 5 个场景,分别用 ref 还是 reactive 更合适?并说明理由:
- 一个计数器数字
- 一个用户的完整信息对象(name/age/email),且不会整体替换
- 一个「当前选中的标签页 id」,会被整体替换
- 表单的多个字段(username/password),需要整体重置
- 一个由其他状态算出来的「是否全选」标志
解释为什么 Vue 不能让 const count = ref(0) 直接写成 count++ 而不用 .value,并保证仍然能触发更新?如果不用盒子,有没有别的语言机制能做到?
<script setup>
const n = ref(0);
function click() {
n.value++;
n.value = n.value + 10;
n.value = 5;
}
</script>点击一次后,n.value 是多少?逐步说明三次赋值分别发生了什么(对照 React 那道题里「传值覆盖」的机制,Vue 这里没有批处理队列的问题,因为 ref 的 set 是立即触发的)。
小结
ref是带.value的盒子,用来把 JS 无法拦截的原始值「装起来」以便追踪;模板里顶层自动解包reactive基于Proxy,能装对象,支持索引/数组方法/length/Map/Set,无需Vue.set- 默认用
ref:原始值只能ref,整体替换只能ref,对象ref/reactive皆可 - 事件:
@click写内联表达式或函数名;修饰符(.prevent .stop .once .enter .self)是编译期包装 - 状态更新会异步批量渲染:同一轮事件里改多次,DOM 只刷新一次
- 能推导出来的值用
computed而不存成 state - 下一章讲
computed与watch,并亲手看 computed 的「惰性缓存」与 watch 的取舍 →