写给别人复用的 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。