03|组合式函数:输入、状态与调用隔离
第四章概览 / 03。目标:把 composable 写成有输入合同、最小返回面和明确状态所有权的“组件级服务”。
什么值得叫 composable
普通工具函数接收值并立即返回结果;composable 使用 Vue Composition API 封装随时间变化的状态或副作用。名称通常以 use 开头,但前缀不会自动带来隔离、清理或测试能力。
下面的函数没有组件或浏览器依赖,可在组件、测试或手动 effectScope 中使用:
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
}
}<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。它保留了调用方解构的自由,但不会让任意深度的模板表达式自动解包:
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();先在外面解包成普通值只会得到快照。
const max = computed(() => toValue(maxSource)) // 会持续追踪
const maxSnapshot = toValue(maxSource) // 只读取这一刻getter 特别适合 props:useFeature(() => props.id) 保留依赖关系,而 useFeature(props.id) 只传入当前字符串。不要让 composable 猜测调用方到底想要快照还是持续同步,应在参数类型和说明中写明。
返回普通对象里的 refs
推荐返回一个普通对象,其中每项状态是 ref。这样调用方解构后仍保留响应式连接:
const { value, error, set } = useBoundedNumber(2, 0, 10)若直接返回 reactive({ value, error }),调用方再解构属性会得到普通值并失去连接。需要对象式访问时,可以在调用处使用 reactive(useFeature()),但 API 作者应优先提供可安全解构的合同。
每次调用隔离与显式共享
下面的写法把状态放在模块顶层,所有调用者都会拿到同一份计数:
const sharedCount = ref(0)
export function useAccidentalCounter() {
return { count: sharedCount }
}这不是永远错误;全局主题、认证会话或客户端 store 可能本来就要共享。问题在于名字和合同假装每次调用独立。SSR 中风险更高:模块会跨请求复用,用户相关数据可能泄漏给下一次请求。共享状态应由 store / provider 工厂创建,并在 SSR 中每请求实例化。
实例归属与同步调用
不使用生命周期、inject 或自动清理 watcher 的纯 composable 可以在普通函数中调用。只要 composable 依赖当前组件实例,就应在 setup() / <script setup> 的同步调用链中调用:
// 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,才算通过。