编程 写给别人复用的 composable:VueUse 官方指南里的 9 条约定与 5 处取舍

2026-10-17 00:04:14

写给别人复用的 composable:VueUse 官方指南里的 9 条约定与 5 处取舍

文档:(中文:)
仓库:

自己项目里写个 useXxx 很随意,但一个要被别人 import 的 composable,接口一旦发出去就很难改。VueUse 官方指南把这类库作者要守的约定列了一遍,下面按条目整理,并说明每条为什么这么定。

通用约定

  • 从 "vue" 导入所有 Vue API(不依赖全局构建,也不从其他包转手)
  • 尽可能使用 ref 而不是 reactive
  • 尽可能用选项对象作为参数,未来加参数时不会破坏调用方
  • 包装大量数据时用 shallowRef 而不是 ref
  • 使用 window 这类全局变量时支持 configurableWindow,多窗口、测试模拟、SSR 场景下才可替换
  • 涉及浏览器尚未广泛实现的 Web API 时,同时输出 isSupported 标志
  • 内部使用 watch / watchEffect 时,尽量让 immediate 和 flush 可配置
  • 用 tryOnUnmounted 清理副作用
  • 避免 console.log
  • 函数是异步的时,返回 PromiseLike

包大对象时用 shallowRef

ref 会递归地把整个对象变成响应式。对于 fetch 回来的数据,这个开销没人需要,深度追踪还会在数据量大时拖慢渲染。

export function useFetch(url: MaybeRefOrGetter) {
const data = shallowRef()  // 使用 shallowRef 防止深度响应式
const error = shallowRef()
fetch(toValue(url)).then(r => r.json()).then(r => data.value = r).catch(e => error.value = e)
}

边界在于:如果调用方需要直接改 data.value.xxx 并触发更新,shallowRef 就不合适了——这种场景应该由调用方自己用 toRef 或 reactive 处理,库本身不做深层承诺。

全局变量走 configurableWindow

直接用 window 会让函数在 Node.js(SSR)里炸掉,也没法在 iframe 或测试里换成别的窗口。约定是在选项接口里暴露 configurableWindow / configurableDocument,实现来自 _configurable.ts 的 ConfigurableWindow,defaultWindow = isClient ? window : undefined。

export function useActiveElement(options: ConfigurableWindow = {}) {
const { window = defaultWindow } = options
let el: T
if (window) { // 在 Node.js 环境中跳过(SSR)
useEventListener(window, 'blur', () => { el = window?.document.activeElement }, true)
}
}

使用方式:

useActiveElement({ window: window.parent }) // 在 iframe 中并绑定到父窗口

if (window) 分支是关键:SSR 下拿不到 window 时静默跳过,而不是抛错。

内部 watch 的选项要透传

库内部怎么 watch 是库的事,但 immediate 和 flush 会影响调用方拿到的时机,必须可配。做法是让选项接口继承 WatchOptions,再把自己的选项拼上去。

export interface WatchDebouncedOptions extends WatchOptions { debounce?: number }

export function watchDebounced(source, cb, options: WatchDebouncedOptions = {}): WatchStopHandle {
return watch(source, () => {}, options) // 传递 watch 选项
}

不透传的后果是调用方只能改库源码,或者在外面再包一层 watch 去凑时机。

controls:让简单用法保持简单

一个函数如果通常只关心单个 ref,默认返回值就该是那个 ref,不要一上来就返回 { value, pause, resume, ... }。需要更多控制的人再加 controls: true(issue #362)。

适合提供 controls 的:函数更常用于单个 ref,比如 useTimestamp、useInterval。

const timestamp = useTimestamp() // 常见用法
const { timestamp, pause, resume } = useTimestamp({ controls: true }) // 更多控制

不适合提供 controls 的:函数本身就更常用于多个返回值,比如 useRafFn、useRefHistory——这类默认返回对象即可。

const { pause, resume } = useRafFn(() => {})

判断依据是调用方的默认用法,不是函数能力多少。

isSupported:新 Web API 的降级开关

用到 navigator.canShare 这类还没普遍实现的 API 时,返回一个 isSupported 标志,让调用方能先判断再用,而不是等运行时报错。

export function useShare(shareOptions, options: ConfigurableNavigator = {}) {
const { navigator = defaultNavigator } = options
const isSupported = useSupported(() => navigator && 'canShare' in navigator)

const share = async (overrideOptions) => {
if (isSupported.value) { /* ...implementation */ }
}

return { isSupported, share }
}

注意 isSupported 是响应式的:SSR 首屏为 false、客户端 hydrate 后变 true 的情况要能被追踪到。

异步 composable 返回 PromiseLike

useFetch 这类异步函数,最好返回一个 PromiseLike 对象,这样用户可以 await 它,在 Vue 的 `` 下特别有用。三个要点:

  • 用一个 ref 决定函数何时可以解析,例如 isFinished
  • 返回状态存在变量里,因为它要返回两次:一次在返回值里,一次在 promise 里
  • 返回类型是普通返回类型与 PromiseLike 的交集,例如 UseFetchReturn & PromiseLike
export function useFetch(url: MaybeRefOrGetter): UseFetchReturn & PromiseLike> {
const data = shallowRef()
const error = shallowRef()
const isFinished = ref(false)

fetch(toValue(url)).then(r => r.json()).then(r => data.value = r).catch(e => error.value = e).finally(() => isFinished.value = true)

const state: UseFetchReturn = { data, error, isFinished }

return {
...state,
then(onFulfilled, onRejected) { // 向对象添加 then 使其可以被 await
return new Promise>((resolve, reject) => {
until(isFinished).toBeTruthy().then(() => resolve(state)).then(() => reject(state))
}).then(onFulfilled, onRejected)
},
}
}

无渲染组件:把 composable 包一层给模板用

有些 composable 想直接在模板里用,就再包一个无渲染组件。规范:

  • 使用渲染函数,不用 Vue SFC
  • 把 props 包在 reactive 中,方便直接作为 props 传给插槽
  • 优先复用函数的选项作为 prop 类型,而不是自己重新声明一遍
  • 仅当函数需要绑定目标时,才把插槽包在 HTML 元素里
export const UseMouse = defineComponent({
name: 'UseMouse',
props: ['touch', 'resetOnTouchEnds', 'initialValue'] as unknown as undefined,
setup(props, { slots }) {
const data = reactive(useMouse(props))
return () => {
if (slots.default) return slots.default(data)
}
},
})

函数有多个参数时,需要新建一个接口把它们合并成组件 props,例如 UseTimeAgoComponentOptions extends Omit, 'controls'>,再加上 time: MaybeRef。

VueUse 当前为 v13.0.0,MIT 许可,作者 Anthony Fu 与贡献者。Composables 的设计参考了 Anthony Fu 的演讲 Composable Vue(VueDay 2021)与 VueConf China 2021。

推荐文章

程序员茄子在线接单