Vue 3 组合式 API:响应式原理与逻辑组织
本文目录
Vue 3 的组合式 API 不是把选项式 API 换个写法,而是换了一种组织逻辑的单位:从"按选项类型分块"(data 一块、methods 一块)变成"按功能分块"。要把它用好,前提是理解响应式系统到底怎么工作——Proxy 在哪里收集依赖、在哪里触发更新,ref 和 reactive 为什么是两个东西,computed 和 watch 的执行时机差别在哪。这篇文章按这条线展开,最后落到组合函数的设计规范和性能边界。所有行为以 vuejs.org 官方文档为准,版本基线是 Vue 3.4+。
Proxy 响应式:依赖收集与触发更新
Vue 3 用 Proxy 拦截对象的读写。核心机制可以概括为两条链路:
| 链路 | 发生时机 | 做什么 |
|---|---|---|
| 依赖收集(track) | 读取被代理对象的属性时 | 把"当前正在运行的副作用"记录到这个属性的依赖集合里 |
| 触发更新(trigger) | 写入被代理对象的属性时 | 找到这个属性的依赖集合,逐个重新执行副作用 |
"副作用"可以是一个组件的渲染函数,也可以是 watchEffect 的回调。用一个简化模型验证这条链路:
// 概念演示:真实实现是 @vue/reactivity 内部的 targetMap / track / trigger
import { reactive, effect } from "vue";
const state = reactive({ count: 0 });
let doubled = 0;
effect(() => {
doubled = state.count * 2; // 读取 count → 当前 effect 被 track 到 count 的依赖集合
console.log(doubled);
});
// 打印 0
state.count = 1; // 写入 count → trigger → effect 重新执行,打印 2
组件渲染走的是同一条机制:模板编译出的渲染函数就是一个 effect,读到的每个响应式属性都会记录"我这个组件依赖它"。所以 Vue 能做到组件级精确更新——只有模板里实际读到的属性变化才会重渲染这个组件。
Proxy 方案相比 Vue 2 的 defineProperty 有实际能力差异:
| 能力 | Vue 2(defineProperty) | Vue 3(Proxy) |
|---|---|---|
| 新增/删除属性 | 需要 $set / $delete | 直接 obj.newKey = v 和 delete obj.key 都能触发 |
| 数组索引赋值、length 修改 | 需要重写数组方法 | 原生拦截 |
| Map / Set | 不支持 | 支持 |
| 代理不存在的嵌套对象 | 深层属性初始化时递归 | 惰性代理,访问到才创建 |
一个常被忽略的细节:Proxy 拦截的是"对这个代理对象的操作"。把 reactive 对象整体替换掉,旧引用上的代理就失效了——这是下面 reactive 一节主要坑点的来源。
ref 与 reactive 的选择
| 维度 | ref | reactive |
|---|---|---|
| 覆盖类型 | 任意值,包括原始类型、对象 | 仅对象/集合类型 |
| 替换整个值 | 直接 .value = newObj,响应性保留 | 替换丢失响应性(新对象不是代理) |
| 解构 | .value 逐字段取,响应性由 ref 本身保证 | 解构后是普通值,响应性丢失 |
| 模板中 | 自动解包(不用写 .value) | 直接访问属性 |
| 推荐度 | 官方明确建议:声明响应式状态时 ref 优先 | 适合聚合.group 数据且不解构、不整体替换的场景 |
两个经典事故代码:
// 事故一:整体替换后,通过 state 的后续修改不再触发视图更新
const state = reactive({ list: [] });
async function load() {
const res = await fetchList();
state.list.push(...res); // 正确:保持对代理的写入
// state = reactive({ list: res }) // 事故:state 指向新代理,模板绑定的还是旧的
}
// 事故二:解构丢失响应性
const { count } = state; // count 是普通 number,之后 state.count 变化它不知道
// 需要解构时用 toRefs / toRef
const stateRefs = toRefs(state); // 每个 ref 与源属性保持同步
规则总结:组合函数返回值一律用 ref(调用方解构安全);组件内聚的表单对象可以用 reactive 但禁止解构、禁止整体替换。这条规则能消灭 Vue 3 里一多半"数据变了但视图没动"的问题。
computed 与 watch 的时机
computed 的两个承诺:惰性求值(没人读就不算)和缓存(依赖没变就返回上次结果)。watch 的关键参数是时机和来源。
| API | 执行时机 | 默认行为 | 典型用途 |
|---|---|---|---|
computed | 读取时 | 惰性 + 缓存,依赖不变不重算 | 派生数据(过滤、格式化) |
watchEffect | 立即执行一次,之后依赖变化时 | 自动收集回调里读到的依赖 | "读什么就追什么"的副作用,如请求依赖参数 |
watch(source, cb) | 默认依赖变化后(组件更新前) | 显式声明来源,回调拿到新旧值 | 需要旧值比较、深层对象、精确定时的场景 |
flush 选项决定回调相对 DOM 更新的位置:'pre'(默认,DOM 更新前)、'post'(DOM 更新后,要读更新后的 DOM 时用)、'sync'(同步,慎用)。清理与竞态处理用回调的第三个参数:
watch(keyword, async (kw, _old, onCleanup) => {
const ac = new AbortController();
onCleanup(() => ac.abort()); // keyword 再次变化时取消上一次请求
results.value = await search(kw, ac.signal);
}, { flush: 'post' });
两条选型规则:能用 computed 表达的不要用 watch + state(少一个状态、少一轮更新);watch 的 source 数组要显式列出,watchEffect 的隐式依赖虽然省事,但读代码的人看不出依赖是什么。
组合函数:设计与复用
组合函数是组合式 API 的最大收益点:把"一块带状态的行为"封装成可复用单元,命名以 use 开头,输入 ref 或 getter 保持响应式,输出 ref 保持解构安全。
import { ref, onMounted, onUnmounted, type Ref } from "vue";
// 一个标准的组合函数:输入响应式、输出响应式、自带生命周期清理
export function useMouse(target: Ref<HTMLElement | null>) {
const x = ref(0);
const y = ref(0);
function update(event: MouseEvent) {
const rect = target.value?.getBoundingClientRect();
if (!rect) return;
x.value = event.clientX - rect.left;
y.value = event.clientY - rect.top;
}
onMounted(() => window.addEventListener("mousemove", update));
onUnmounted(() => window.removeEventListener("mousemove", update));
return { x, y }; // 返回 ref:调用方解构后仍是响应式的
}
约定清单:
| 约定 | 原因 |
|---|---|
| 命名 use 前缀 | 工具函数与组合函数一眼区分 |
输入接受 ref / getter,内部 toValue() 归一化 | 调用方传静态值、ref、computed 都成立 |
| 输出用 ref(对象包多个 ref),不输出 reactive | 解构安全 |
| 副作用在内部注册生命周期钩子,或暴露手动 start/stop | 可复用、可测试 |
| 不依赖组件实例上下文(getCurrentInstance) | 脱离组件也能用,例如在路由守卫里 |
与 mixins 的本质区别在这里:组合函数的依赖(入参)和产出(返回值)全部显式,来源可追溯;mixin 的属性是隐式合并进 this 的,两个 mixin 有同名字段时冲突无声。这是 Vue 官方从 mixins 迁移的核心理由。
输入归一化用 toValue() 统一处理三种传入形态,这是组合函数对外 API 质感的分水岭:
import { toValue, type MaybeRefOrGetter } from "vue";
// 调用方传静态字符串、ref、computed getter 三种形态都能工作
export function useTitle(title: MaybeRefOrGetter<string>) {
const normalized = () => toValue(title); // 每次调用时取最新值
const handler = () => { document.title = normalized(); };
// ...
}
生命周期与作用域
组合式 API 里生命周期钩子注册在 setup 执行期间(同步),组件卸载时自动清理。但组合函数可能运行在组件之外(例如在全局路由钩子里创建的响应式上下文),这时用作用域 API 手动管理:
import { effectScope, onScopeDispose } from "vue";
const scope = effectScope();
scope.run(() => {
const state = ref(0);
const timer = setInterval(() => state.value++, 1000);
onScopeDispose(() => clearInterval(timer)); // 作用域停止时集中清理
});
scope.stop(); // 停止作用域内全部 effect 并触发清理
getCurrentScope() 判断当前是否在某个作用域内——组合函数里的 onScopeDispose 在组件内会绑到组件实例(等同 onUnmounted),在 effectScope 内会绑到作用域,两层都覆盖,这就是库作者推荐它做清理的原因。
defineProps 与 defineEmits 的类型化
<script setup> 里两个编译宏直接用类型参数,不需要 import:
<script setup lang="ts">
interface Props {
/** 展示的商品清单 */
items: Product[];
/** 是否显示合计行 */
showTotal?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
showTotal: true,
});
const emit = defineEmits<{
select: [id: string];
"remove-all": [];
}>();
function onPick(id: string) {
emit("select", id); // 参数类型与声明一一对应,写错编辑器直接报错
}
</script>
要点:
withDefaults处理可选 prop 的默认值;Vue 3.5 之后支持响应式 props 解构(const { items, showTotal = true } = defineProps<Props>()),解构出的变量保留响应性,默认值写法更直接。- emits 用具名元组声明参数,比旧的
emit(arg: any)字符串联合安全得多。 - props 是只读的,改 props 要靠 emit 事件让父组件改——单向数据流在 Vue 里是 lint 可查的规则(
vue/no-mutating-props)。 defineExpose控制对外暴露的方法;不暴露则父组件的模板 ref 拿不到内部任何东西,这比选项式 API 默认全暴露更安全。
性能:shallowRef、markRaw 与组件缓存
深层响应式有递归代理成本。数据量大且只需要"整体替换"语义时,浅层方案更合适:
| 手段 | 语义 | 适用场景 |
|---|---|---|
shallowRef(obj) | 只有 .value 整体替换触发更新 | 大列表/图表数据:每条记录不值得代理 |
triggerRef(r) | 手动通知 shallowRef 深层变了 | 少量原地修改又想触发更新的场合 |
markRaw(obj) | 永不代理 | 第三方类实例(地图、编辑器、类库实例) |
v-memo="[dep]" | dep 不变则跳过该元素子树的 diff | 长列表中静态行 |
KeepAlive | 缓存组件实例与状态 | 切换频繁的列表/搜索页 |
const chartData = shallowRef<ChartSeries[]>([]);
async function load() {
const series = await fetchSeries(); // 万级点位的图表数据
chartData.value = series; // 整体替换触发更新,内部点位不被代理
}
import * as monaco from "monaco-editor";
const editor = markRaw(monaco.editor.create(container, {})); // 类实例永远不该被响应式追踪
KeepAlive 的边界:它缓存的是组件状态和 DOM,被激活/失活走 onActivated / onDeactivated;包含表单的页面被缓存时要自己决定"回来时数据是续上还是刷新",这个语义框架不猜。include/max 参数控制缓存集合,无上限缓存页面在长会话里会积累内存。
注意事项与边界
- 模板里 ref 自动解包,但"嵌套在 reactive 里的 ref"与"解构出的 ref"在不同场景解包规则不同,代码里统一用
.value访问最不容易出错。 reactive数组/Map 的遍历输出会自动做 ref 解包,跨层传递后类型和运行时形状可能对不上,序列化前先toRaw。- 直接
v-for万行数据时,响应式不是第一瓶颈,DOM 才是——先虚拟滚动,再谈 shallowRef。 - 组合函数在 setup 同步阶段注册生命周期钩子;异步之后(如 await 后)再调用
onMounted会静默失败并给出警告,把注册动作放在 await 之前。 - 响应式对象放进
JSON.stringify可以正常输出,但把代理对象传给structuredClone会抛错——深度克隆前用toRaw还原原对象。 - 不要为"可能更快"预防性地上 shallowRef/markRaw:先用 DevTools 的性能面板确认响应式开销真实存在,绝大多数页面它不构成瓶颈。
总结
组合式 API 的正确心智模型:Proxy 提供精确的组件级依赖追踪,ref/reactive 的取舍决定响应性能否穿越解构与替换,组合函数把行为与状态打包成显式依赖/显式产出的复用单元。computed 优先于 watch+state,ref 优先于 reactive,生命周期钩子在 setup 同步注册,性能手段(shallowRef/markRaw/v-memo/KeepAlive)按测量结果选用。逻辑组织从"按选项分块"变成"按功能分块",是这一套 API 对可维护性的最大贡献。
参考
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。