一、技术难点:为什么"按选项写组件"会崩
Options API 把组件拆成 data / methods / computed / watch / mounted 几个抽屉。组件小的时候很整齐,一旦一个功能涉及"一份状态 + 两个方法 + 一个侦听 + 一个生命周期",它的代码就被切碎扔进四个抽屉里。四处翻找改一个功能,是大型组件维护痛苦的根源。四个难点:
- 逻辑按"选项类型"聚合,而非按"功能"聚合。发票管理、权限校验、草稿自动保存三个功能的状态和方法全部交织在同一个 data 和 methods 里,删一个功能要小心翼翼地从各处摘除。
- 逻辑复用只能靠 mixin,而 mixin 是隐式契约 。多个 mixin 之间变量名冲突、来源不可追溯、
this上挂了什么只能靠运行时猜,类型推导基本失效。逻辑复用的需求真实存在,但 mixin 给了一个坏答案。 - setup 的心智转变 。从"在 this 上挂属性"变成"用函数闭包组织逻辑"。最典型的坑是响应式丢失 :
const { count } = reactive(state)解构出来的 count 是个普通数字,后续视图不会更新。 - 组合函数的边界模糊 。抽成
useXxx之后,什么时候用 ref 什么时候用 reactive、生命周期钩子为什么必须在 setup 同步执行期注册、组合函数之间怎么通信,全是实践中的模糊地带。
一句话:组合式 API 解决的不是"语法更简洁",而是"让代码按业务功能聚合、让逻辑复用有类型可追的显式契约"。
二、完整解法:从 setup 语义到组合函数设计
2.1 先用 60 行手写一版"组合式响应式",把语义吃透
理解 Composition API 的关键是理解 ref / computed / watchEffect 三者如何靠依赖收集串起来。下面这版骨架用 Node 可直接跑,输出与 Vue 真实行为一致:
javascript
// mini-composition.mjs ------ ref / computed / watchEffect 的最小实现
let activeEffect = null; // 当前正在执行的副作用
const effectStack = [];
function ref(value) {
const dep = new Set(); // 谁用了这个 ref
return {
get value() {
if (activeEffect) dep.add(activeEffect); // 收集依赖
return value;
},
set value(v) {
if (v === value) return;
value = v;
[...dep].forEach(fn => fn()); // 触发依赖
},
};
}
function watchEffect(fn) {
const runner = () => { activeEffect = runner; fn(); effectStack.pop(); activeEffect = null; };
runner();
return runner;
}
function computed(getter) {
const result = ref(undefined);
watchEffect(() => { result.value = getter(); }); // 依赖变了自动重算
return result;
}
// ---- 验证:组合式 API 的逻辑聚合 ----
const price = ref(10); // 功能A:计价
const qty = ref(2);
const total = computed(() => price.value * qty.value);
const logs = [];
watchEffect(() => logs.push(`总价变了 → ${total.value}`));
price.value = 20; // 触发重算与副作用
qty.value = 3;
console.log(logs);
// 输出: [ '总价变了 → 20', '总价变了 → 60' ] ← 依赖是精确的, price 变只走一次
这 60 行揭示了组合式 API 的本质:ref 是可被追踪的容器,watchEffect 是会被自动重跑的函数,computed 是被依赖驱动的缓存值。组件里那些 setup 写法,最后都编译成这套机制。
2.2 ref / reactive 的选择与解构陷阱
| 场景 | 选择 | 理由 |
|---|---|---|
| 基本类型、需要整体替换 | ref |
state.value = newObj 可整体换掉,视图照常更新 |
| 结构化对象、表单聚合 | reactive |
直接 form.name 访问,无需 .value |
| 需要解构出来传参 | toRefs / toRef |
保住响应性,避免拆包丢失 |
javascript
import { reactive, toRefs } from 'vue';
const state = reactive({ count: 0 });
const { count } = toRefs(state); // ✅ count 仍是响应式引用
// const { count } = state; // ❌ count 是普通数字, 视图不更新
反模式 :把 reactive 对象整个解构后再传入子函数,或把 ref 当普通值塞进闭包深处。判断标准只有一条:它是不是还能被响应系统追踪到。
2.3 组合函数:把"一个功能"收进一个函数
组合函数的写法有稳定的三段式:定义本地状态 → 定义操作与副作用 → 返回对外的引用。以"草稿自动保存"为例:
javascript
// useAutosave.js
import { ref, watch, onScopeDispose } from 'vue';
export function useAutosave(source, save, { delay = 1000 } = {}) {
const saving = ref(false); // ① 本地状态
const savedAt = ref(null);
let timer = null;
watch(source, () => { // ② 操作与副作用
saving.value = true;
clearTimeout(timer);
timer = setTimeout(async () => {
await save(); // 传给函数的保存策略, 有类型可追
saving.value = false;
savedAt.value = Date.now();
}, delay);
});
onScopeDispose(() => clearTimeout(timer)); // 清理, 防内存泄漏
return { saving, savedAt }; // ③ 只暴露该暴露的
}
javascript
// 组件里按功能聚合, 而不是按选项抽屉
const { content } = toRefs(form);
const { saving, savedAt } = useAutosave(content, () => api.save(form));
对比 mixin:useAutosave 的输入输出是显式签名(谁传进、返回什么一目了然),来源清晰、可单测、类型能推导。这正是 mixin 做不到的。
2.4 两个必须知道的时序约束
- 生命周期钩子必须在 setup 同步执行期注册 。
onMounted放在setTimeout或await之后会失效,因为它依赖当前组件实例这个隐式上下文; <script setup>是语法糖 :组件实例的创建、defineProps/defineEmits/defineExpose这些编译宏,最终被编译成setup()的返回值与 props/emits 声明。理解这一层,就不会把宏当运行时函数去解构或传递。
三、应用场景
- 复杂组件分层 :一个 800 行的表单组件,按功能抽成
useValidation/useDraft/usePermission三个组合函数,组件本体只负责编排,可读性成倍提升; - 跨组件逻辑复用 :鼠标位置、请求封装、分页、埋点、abort 控制,全部收敛成
useXxx,取代 mixin 与 HOC 的隐式注入; - 逻辑可测:组合函数是纯函数式入口,可以在 Node 里直接单测(不必挂载组件),这正是上面手写演示的思路;
- TS 友好 :显式输入输出签名让类型推导自然工作,配合
defineProps<T>()泛型写法,props 类型与运行时校验统一到一处。
四、总结
组合式 API 的价值不在"写法新鲜",而在把代码组织方式从"选项抽屉"换成"功能函数":ref 是可追踪容器、computed 是依赖驱动的缓存、watchEffect 是自动重跑的副作用;组合函数用"状态 → 操作 → 返回引用"三段式收拢一个功能,并用显式签名取代 mixin 的隐式契约。三个最容易踩的坑------解构 reactive 丢响应性、生命周期钩子错过同步执行期、把编译宏当运行时函数------都源于同一件事:不理解响应系统与组件实例的隐式上下文。下一篇 Day21 进入 diff 算法,看 Vue 如何在虚拟 DOM 上把"最小的改动"算出来。