Skip to content

03|组合式函数:输入、状态与调用隔离 ​

第四章概览 / 03。目标:把 composable 写成有输入合同、最小返回面和明确状态所有权的“组件级服务”。

什么值得叫 composable ​

普通工具函数接收值并立即返回结果;composable 使用 Vue Composition API 封装随时间变化的状态或副作用。名称通常以 use 开头,但前缀不会自动带来隔离、清理或测试能力。

下面的函数没有组件或浏览器依赖,可在组件、测试或手动 effectScope 中使用:

ts
import {
  computed,
  readonly,
  ref,
  toValue,
  type MaybeRefOrGetter
} from 'vue'

export function useBoundedNumber(
  initial: number,
  minSource: MaybeRefOrGetter<number>,
  maxSource: MaybeRefOrGetter<number>
) {
  const initialMin = toValue(minSource)
  const initialMax = toValue(maxSource)
  if (initialMin > initialMax || initial < initialMin || initial > initialMax) {
    throw new RangeError('initial 必须位于有效的初始范围内')
  }

  const value = ref(initial)
  const error = ref<string | null>(null)
  const min = computed(() => toValue(minSource))
  const max = computed(() => toValue(maxSource))

  function set(next: number): boolean {
    if (min.value > max.value) {
      error.value = 'min 不能大于 max'
      return false
    }
    if (next < min.value || next > max.value) {
      error.value = `值必须位于 ${min.value}..${max.value}`
      return false
    }
    value.value = next
    error.value = null
    return true
  }
  return {
    value: readonly(value),
    error: readonly(error),
    min,
    max,
    set
  }
}
vue
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useBoundedNumber } from './useBoundedNumber'

const upper = ref(10)
const first = reactive(useBoundedNumber(2, 0, upper))
const second = reactive(useBoundedNumber(8, 0, upper))
</script>

<template>
  <button type="button" @click="first.set(first.value + 1)">
    first: {{ first.value }}
  </button>
  <button type="button" @click="second.set(second.value + 1)">
    second: {{ second.value }}
  </button>
  <button type="button" @click="upper = 5">把上限改为 5</button>
  <p role="alert">{{ first.error || second.error }}</p>
</template>

每次调用都执行函数体并创建新的 value 与 error,所以两个实例互不影响;upper 是调用方显式共享的输入。返回只读 ref 与 set(),让修改入口保持可观察。

为什么模板消费时多了一层 reactive ​

返回值是普通对象,里面的 value 与 error 才是 ref。它保留了调用方解构的自由,但不会让任意深度的模板表达式自动解包:

ts
const raw = useBoundedNumber(2, 0, 10)
console.log(raw.value.value) // 2:对象属性是 ref,再读 ref 的值
const { value } = raw      // value 是顶层 ref,模板里可以直接用 value + 1

主例选择对象式消费,reactive(raw) 在读取对象属性时解包 ref。因此点击时 first.value 为数字 2,set(3) 检查范围后才写回原 ref,下一轮渲染显示 3。若省去这一层,first.value + 1 是对象与数字相加;单独的 {{ raw.value }} 插值恰好能显示值,并不能证明运算或 raw.error || other.error 也安全。

把上限改为 5 后,first 与 second 的 max 一起变为 5,second 已有值仍为 8。此时点击 second 会被拒绝,first 仍可独立递增。这一区别来自“共享输入、私有输出”,与对象是否被代理无关。

接受值、ref 与 getter ​

Vue 3.3+ 的 toValue() 会读取普通值、解包 ref,或调用 getter。若输入需要持续响应,必须在 computed、watch 或 watchEffect 的追踪阶段调用 toValue();先在外面解包成普通值只会得到快照。

ts
const max = computed(() => toValue(maxSource)) // 会持续追踪
const maxSnapshot = toValue(maxSource)          // 只读取这一刻

getter 特别适合 props:useFeature(() => props.id) 保留依赖关系,而 useFeature(props.id) 只传入当前字符串。不要让 composable 猜测调用方到底想要快照还是持续同步,应在参数类型和说明中写明。

返回普通对象里的 refs ​

推荐返回一个普通对象,其中每项状态是 ref。这样调用方解构后仍保留响应式连接:

ts
const { value, error, set } = useBoundedNumber(2, 0, 10)

若直接返回 reactive({ value, error }),调用方再解构属性会得到普通值并失去连接。需要对象式访问时,可以在调用处使用 reactive(useFeature()),但 API 作者应优先提供可安全解构的合同。

每次调用隔离与显式共享 ​

下面的写法把状态放在模块顶层,所有调用者都会拿到同一份计数:

ts
const sharedCount = ref(0)

export function useAccidentalCounter() {
  return { count: sharedCount }
}

这不是永远错误;全局主题、认证会话或客户端 store 可能本来就要共享。问题在于名字和合同假装每次调用独立。SSR 中风险更高:模块会跨请求复用,用户相关数据可能泄漏给下一次请求。共享状态应由 store / provider 工厂创建,并在 SSR 中每请求实例化。

实例归属与同步调用 ​

不使用生命周期、inject 或自动清理 watcher 的纯 composable 可以在普通函数中调用。只要 composable 依赖当前组件实例,就应在 setup() / <script setup> 的同步调用链中调用:

ts
// useViewport 内部会登记 onMounted / onScopeDispose。
const viewport = useViewport() // 同步调用,可归属当前实例

setTimeout(() => useViewport(), 0) // 失去原实例归属

<script setup> 顶层 await 后调用 composable 是编译器恢复实例上下文的特例;普通 setup()、事件回调和任意 Promise 回调不能类推。更稳定的设计是同步创建 composable,让内部状态决定何时真正启动异步工作。

副作用合同不能藏起来 ​

一个会请求、监听或计时的 composable 至少要说明:

  • 是否调用即启动,还是由 start() 启动;
  • 新输入出现时旧请求是否 Abort,哪些错误会暴露;
  • 组件 / effect scope 结束时哪些资源自动释放;
  • 在 SSR 中返回什么初值,何时才接触浏览器对象;
  • 多次调用是隔离、共享,还是按 key 去重。

后续 资源清理 会把这份合同写成完整实现。

练习:实现 useBoundedNumber ​

不复制主例,自己按以下合同实现并测试:

  • 接收 initial、minSource、maxSource;后两者支持普通值、ref 或 getter(Vue 3.3+)。
  • initial 不在初始范围内或初始 min > max 时抛 RangeError。
  • 返回只读 value、只读 error、响应式 min / max 与动作 set(next)。
  • 合法写入返回 true 并清空错误;越界或 min > max 返回 false,保留旧值并给出错误。
  • 两次调用必须隔离;共享同一个上限 ref 时只共享上限,不共享当前值与错误。
  • 不读取 DOM / window,不要求组件实例;用普通脚本即可验证成功、越界、范围反转和双实例。

参考答案:查看第 03 题。

自检 ​

能明确说出“每次调用隔离”来自函数内部创建状态,而不是来自 use 前缀;能把隐藏全局依赖改成参数或 provider,才算通过。

用心记录每一步成长