Options API 的问题是「一个功能的代码散落在 data、computed、watch、methods 四个地方」;Composition API 把同一功能的代码收进一个函数。收益是真的,但前提是状态与副作用的边界划清楚,否则只是把分散变成了混乱。

ref 与 reactive 怎么选
结论先给:默认用 ref。它能装任意类型,解构不丢响应性,传递时也不会因为「换了个对象」而断开追踪。
import { ref, reactive } from 'vue';
const keyword = ref('');
const filter = reactive({ status: 'paid', page: 1 });
Object.assign(filter, { status: 'refunded', page: 1 });
reactive 适合字段固定、成组使用的表单对象;一旦需要整体替换(例如从接口拉回一份新配置),就该用 ref。注意 reactive 对象解构出来的字段会失去响应性,必须配合 toRefs。
组合函数:只暴露必要的状态
import { ref, onScopeDispose } from 'vue';
export function usePolling(fetcher, interval = 5000) {
const data = ref(null);
const loading = ref(false);
let timer = null;
async function tick() {
loading.value = true;
try {
data.value = await fetcher();
} finally {
loading.value = false;
}
}
tick();
timer = setInterval(tick, interval);
onScopeDispose(() => clearInterval(timer));
return { data, loading, refresh: tick };
}
三点值得注意:返回值用对象而不是数组,调用方按需解构;定时器句柄用普通 let 保存,它不需要被追踪;清理逻辑挂在 onScopeDispose 上,组件卸载或 effect scope 销毁时都会执行,比 onUnmounted 的适用范围更广。
watch 与 watchEffect 的取舍
- 需要旧值、需要控制触发时机(
flush: 'post')、需要精确指定来源时,用watch; - 依赖是动态收集的、只是「跟着跑一次」,用
watchEffect更短; - 由用户操作触发的一次性请求,不要放进 watch,写在事件处理函数里。
watch(keyword, async (value, oldValue, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
results.value = await search(value, { signal: controller.signal });
});
上面的 onCleanup 是关键:输入变化很快时,上一次请求会被中止,避免旧结果覆盖新结果。分页参数这种「两个状态都变化才请求」的场景,写成 watch([page, size], ...) 更直观。
跨组件共享状态
不需要为了一个主题开关装 Pinia。把状态放在模块作用域,导出一个组合函数就够了:
import { ref, readonly } from 'vue';
const dark = ref(false);
export function useTheme() {
const toggle = () => { dark.value = !dark.value; };
return { dark: readonly(dark), toggle };
}
模块级 ref 天然是单例,readonly 防止外部直接改值。涉及服务端数据缓存、跨页面持久化时再引入 Pinia,别让简单问题变复杂。
小结
状态尽量靠近使用它的组件,组合函数只做「把同一件事的代码收在一起」,不承担全局状态职责。渲染层面的性能问题属于另一类,见Vue 3 渲染卡顿排查。