Learn
Vue/08-composables

组合式函数

React 用「自定义 Hook」抽复用逻辑,Vue 的对应物叫 组合式函数(composable):一个 use 前缀的普通函数,内部调用响应式 API 和生命周期钩子。

// useCounter.ts
import { ref } from 'vue';
 
export function useCounter(initial = 0) {
  const count = ref(initial);
  const inc = () => count.value++;
  const dec = () => count.value--;
  return { count, inc, dec };
}

使用时:

<script setup lang="ts">
import { useCounter } from './useCounter';
 
const { count, inc, dec } = useCounter(0);
</script>
 
<template>
  <button @click="dec">-</button>
  <span>{{ count }}</span>
  <button @click="inc">+</button>
</template>

useCounter 内部用 ref 创建了响应式状态,返回出来;调用方解构后,这些 ref 仍然保持响应——这就是组合式函数的核心:复用的是「逻辑」,不是「副本」。

1. 约定

1.1 返回一组 ref,不要返回 reactive 对象

// ✅ 返回 ref,调用方解构后仍响应
export function useMouse() {
  const x = ref(0);
  const y = ref(0);
  return { x, y };
}
const { x, y } = useMouse(); // x、y 都是 ref,保持响应
 
// ❌ 返回 reactive 对象,调用方一解构就丢响应
export function useMouseBad() {
  const pos = reactive({ x: 0, y: 0 });
  return pos;
}
const { x, y } = useMouseBad(); // x、y 变成普通数字,不再响应

1.2 入参用 MaybeRefOrGetter + toValue

Vue 3.3+ 提供 toValue(),让 composable 同时接受「裸值、ref、getter」:

import { ref, toValue, type MaybeRefOrGetter } from 'vue';
 
export function useFetch(url: MaybeRefOrGetter<string>) {
  const data = ref(null);
  watchEffect(() => {
    fetch(toValue(url)).then((r) => r.json()).then((d) => (data.value = d));
  });
  return { data };
}
 
// 三种调用都行
useFetch('/api/a');
useFetch(urlRef);
useFetch(() => `/user/${id.value}`);

toValue(x) 的行为:x 是 ref → 取 .value;是函数 → 调用它;否则原样返回。一个函数统一处理三种形态。

1.3 副作用自清理

composable 里加了事件监听、定时器等,要在 onUnmounted 里移除:

export function useWindowSize() {
  const width = ref(window.innerWidth);
  const onResize = () => (width.value = window.innerWidth);
  onMounted(() => window.addEventListener('resize', onResize));
  onUnmounted(() => window.removeEventListener('resize', onResize));
  return { width };
}

2. effectScope:脱离组件的批量管理

多数 composable 在组件 setup 里调用,组件卸载时 Vue 自动清理里面的 effect。但有时你想在组件外(比如一个共享 store、一个轮询器)持有一些 effect,并统一管理它们的生命周期——这时用 effectScope:

const scope = effectScope();
scope.run(() => {
  // 这里创建的 effect / 计算属性 / 监听,都被 scope 收集
  const state = reactive({ n: 0 });
  watch(state, () => console.log('changed'));
});
// 想销毁时一次性停掉里面的所有东西
scope.stop();

scope.stop() 会递归停止 scope 内所有 effect 和子 scope,是「批量卸载响应式副作用」的利器。

3. 和 React 自定义 Hook、Vue 2 mixin 的区别

Vue composableReact 自定义 HookVue 2 mixin
复用单元函数函数对象
依赖数组无有(易漏写)无
闭包陷阱无(setup 只跑一次)有无
命名冲突无(显式解构)无有(同名覆盖)
来源可见清晰(函数调用)清晰隐晦(混入全局)

Vue composable 没有依赖数组、不会重复执行——因为 setup 只跑一次。这消除了 React 自定义 Hook 最常见的两类坑(依赖数组漏写、闭包拿到旧值)。

effectScope:组合式函数的批量销毁
type Dep = Set<ReactiveEffect>;
const targetMap = new WeakMap<object, Map<PropertyKey, Dep>>();
let activeEffect: ReactiveEffect | null = null;
 
interface ReactiveEffect {
  deps: Dep[];
  active: boolean;
  run: () => void;
  stop: () => void;
}
 
function cleanup(e: ReactiveEffect): void {
  for (const d of e.deps) d.delete(e);
  e.deps.length = 0;
}
 
function effect(fn: () => void): ReactiveEffect {
  const e: ReactiveEffect = {
    deps: [],
    active: true,
    run: () => {
      if (!e.active) { fn(); return; }
      cleanup(e);
      activeEffect = e;
      try { fn(); } finally { activeEffect = null; }
    },
    stop: () => { if (e.active) { cleanup(e); e.active = false; } },
  };
  e.run();
  if (activeScope) activeScope.effects.push(e);
  return e;
}
 
function track(target: object, key: PropertyKey): void {
  if (!activeEffect) return;
  let m = targetMap.get(target);
  if (!m) { m = new Map(); targetMap.set(target, m); }
  let dep = m.get(key);
  if (!dep) { dep = new Set(); m.set(key, dep); }
  dep.add(activeEffect);
  activeEffect.deps.push(dep);
}
 
function trigger(target: object, key: PropertyKey): void {
  const m = targetMap.get(target);
  if (!m) return;
  const dep = m.get(key);
  if (!dep) return;
  for (const e of new Set(dep)) if (e !== activeEffect) e.run();
}
 
function isObject(v: unknown): v is object { return typeof v === 'object' && v !== null; }
const reactiveMap = new WeakMap<object, object>();
const RAW = Symbol('raw');
function reactive<T extends object>(target: T): T {
  if (!isObject(target)) return target;
  if ((target as Record<symbol, unknown>)[RAW]) return target;
  const existing = reactiveMap.get(target);
  if (existing) return existing as T;
  const proxy = new Proxy(target, {
    get(obj: object, key: string | symbol, receiver: any) {
      if (key === RAW) return target;
      const res = Reflect.get(obj, key, receiver);
      track(obj, key);
      if (isObject(res)) return reactive(res as object) as object;
      return res;
    },
    set(obj: object, key: string | symbol, value: any, receiver: any) {
      const oldVal = (obj as Record<string | symbol, unknown>)[key];
      const hadKey = Object.prototype.hasOwnProperty.call(obj, key);
      const result = Reflect.set(obj, key, value, receiver);
      if (!hadKey || !Object.is(oldVal, value)) trigger(obj, key);
      return result;
    },
  });
  reactiveMap.set(target, proxy);
  return proxy as T;
}
 
interface EffectScope {
  effects: ReactiveEffect[];
  scopes: EffectScope[];
  cleanups: (() => void)[];
  parent: EffectScope | null;
  active: boolean;
  run: <T>(fn: () => T) => T;
  stop: (fromParent?: boolean) => void;
}
 
let activeScope: EffectScope | null = null;
 
function effectScope(detached = false): EffectScope {
  const scope: EffectScope = {
    effects: [],
    scopes: [],
    cleanups: [],
    parent: null,
    active: true,
    run<T>(fn: () => T): T {
      const prev = activeScope;
      activeScope = this;
      try { return fn(); } finally { activeScope = prev; }
    },
    stop() {
      if (this.active) {
        this.active = false;
        for (const e of this.effects) e.stop();
        for (const s of this.scopes) s.stop(true);
        for (const c of this.cleanups) c();
      }
    },
  };
  if (!detached && activeScope) {
    activeScope.scopes.push(scope);
    scope.parent = activeScope;
  }
  return scope;
}
 
function toRefs<T extends object>(obj: T): Record<string, { value: any }> {
  const result: Record<string, { value: any }> = {};
  for (const key in obj) {
    result[key] = {
      get value() { return (obj as Record<string, any>)[key]; },
      set value(v: any) { (obj as Record<string, any>)[key] = v; },
    };
  }
  return result;
}
 
// ===== 测试 =====
const s = reactive({ count: 0, name: 'Ada' });
 
const scope = effectScope();
let runs = 0;
scope.run(() => {
  effect(() => { runs++; console.log("[scope] count=" + s.count); });
  effect(() => { runs++; console.log("[scope] name=" + s.name); });
  const child = effectScope();
  child.run(() => {
    effect(() => { runs++; console.log("[child] count*2=" + (s.count * 2)); });
  });
});
 
console.log("--- 改 count:触发 2 个 scope effect + 1 个 child effect ---");
s.count = 1;
console.log("runs 已 = " + runs);
 
scope.stop();
console.log("--- scope.stop() 后 ---");
s.count = 2;
s.name = 'Grace';
console.log("stop 后 runs 仍 = " + runs);
 
// toRefs 演示
const state = reactive({ x: 1, y: 2 });
const { x } = state;
let directRuns = 0;
effect(() => { directRuns++; console.log("[direct] x=" + x); });
state.x = 99;
console.log("直接解构后 directRuns = " + directRuns + " (x 仍是 " + x + ")");
 
const refs = toRefs(state);
let refRuns = 0;
effect(() => { refRuns++; console.log("[toRefs] x=" + refs.x.value); });
state.x = 100;
console.log("toRefs 后 refRuns = " + refRuns);

第一段证明 scope.stop() 一次性停掉了 3 个 effect(含嵌套子 scope 的 1 个)——改 count/name 后 runs 仍停在 5。第二段对比两种解构:直接解构 const { x } = state 拿到的是快照数值,effect 只跑一次;toRefs 返回的 refs.x 仍是响应式的 ref,state.x 变化后 effect 正确重跑(refRuns 从 1 变 2)。

🎯练习 1:抽 useWindowSize

把下面这段在组件里直接写的逻辑,抽成一个 useWindowSize() composable(返回 width / height 两个 ref,并在卸载时移除监听):

const width = ref(window.innerWidth);
const height = ref(window.innerHeight);
function onResize() {
  width.value = window.innerWidth;
  height.value = window.innerHeight;
}
window.addEventListener('resize', onResize);
🎯练习 2:用 toValue 写 useFetch

用 MaybeRefOrGetter + toValue 写一个 useFetch(url),使它同时支持:

useFetch('/api/a');
useFetch(urlRef);                 // urlRef 是 ref<string>
useFetch(() => '/user/' + id.value);  // getter
🎯练习 3:effectScope 还是组件卸载?

说明什么时候该用 effectScope 而不是依赖组件卸载来清理,并给出一个「多个组件共享同一份轮询逻辑,最后一个卸载时才停止」的场景的实现思路。

小结

  • 组合式函数 = use 前缀的普通函数,内部用响应式 API + 生命周期钩子抽复用逻辑
  • 返回一组 ref(调用方可解构且保持响应);入参用 MaybeRefOrGetter + toValue 兼容裸值/ref/getter
  • 副作用在 composable 内 onUnmounted 清理;组件外的副作用用 effectScope 批量管理,scope.stop() 递归停止所有 effect 和子 scope
  • 相比 React Hook:无依赖数组、无闭包陷阱;相比 mixin:来源清晰、无命名冲突
  • 下一章讲模板引用与响应式工具(shallowRef / toRefs / customRef 等)→