Vuetify 共享组合式函数(Shared Composables)开发规范与源码级实践指南
【免费下载链接】vuetify🐉 Vue Component Framework项目地址: https://gitcode.com/gh_mirrors/vu/vuetify
导读
Vuetify 的核心组件库由数百个共享组合式函数(composables)驱动,例如useDisplay、useLocale、useRounded等。本文以仓库中 .claude/rules/composables.md 这份团队开发规范为骨架,结合packages/vuetify/src/composables/与packages/vuetify/src/util/下的真实源码,系统讲解共享组合式函数的设计约束、两种标准形态(Props 驱动形态与插件形态)、作用域与 SSR 注意事项、公共 API 发布纪律及测试要求。读完本文,你将能写出与 Vuetify 核心组件风格一致、可被 API 生成器自动收录、可被多个组件安全复用的高质量组合式函数。
一、为什么需要共享组合式函数规范
共享组合式函数被众多组件同时消费——一个签名或响应式处理上的小失误,会顺着组件树扩散到所有使用者。因此规范首先强调两件事:
- 组件专属逻辑放组件旁边:仅被单个组件使用的逻辑,应写成组件目录下的局部 composable(见
components.md的约定),而不是塞进共享目录。 - 共享逻辑优先复用:在动手写新函数之前,先检索
src/composables与src/util,确认仓库里是否已有现成实现。
一个典型印证是 VHover.tsx:它自身只有 hover 相关的薄薄一层逻辑,而modelValue的双向绑定交给useProxiedModel(来自@/composables/proxiedModel)、延迟开关交给makeDelayProps/useDelay(来自@/composables/delay),两者都是共享组合式函数。这正是"复用优先"原则的日常形态。
@vuetify/v0的进入通道:src/util/v0.ts
规范特别指出:@vuetify/v0(Vuetify 4.x 迭代中引入的工具库)只能通过 v0.ts 这一入口进入核心代码。调用点一律import from '@/util',新增 v0 导出也追加到该文件,禁止在调用点直接import from '@vuetify/v0'。
从源码看,v0.ts 目前再导出了isArray、isBoolean、isElement、isFunction、isNull、isNullOrUndefined、isNumber、isObject、isString、isSymbol、isThenable、isUndefined、findMatchRanges等工具,并把range以createRange的名字导出——注释解释了原因:range会在VPagination、VRating、VSlider等多个组件中遮蔽局部变量。这既是集中入口,也是命名治理。
二、形态一:Props 驱动形态(Prop-driven shape)
大多数共享组合式函数采用这种形态:一个 props 工厂 + 一个use函数,use函数接收props与组件名。规范给出了标准模板:
// Utilities import { computed } from 'vue' import { getCurrentInstanceName, propsFactory } from '@/util' // Types export interface ExampleProps { example?: boolean | string } // Composables export const makeExampleProps = propsFactory({ example: [Boolean, String], }, 'example') export function useExample ( props: ExampleProps, name = getCurrentInstanceName(), ) { const exampleClasses = computed(() => props.example ? `${name}--example` : []) return { exampleClasses } }2.1propsFactory的底层实现
propsFactory定义在 propsFactory.ts,其核心机制是柯里化:第一次调用传入 props 定义与来源标识(source),返回一个可接受defaults的函数;第二次调用传入默认值后,为每个 prop 注入default字段,并统一打上source标记:
export function propsFactory< PropsOptions extends ComponentObjectPropsOptions > (props: PropsOptions, source: string) { return <Defaults extends PartialKeys<PropsOptions> = {}>( defaults?: Defaults ): AppendDefault<PropsOptions, Defaults> => { return Object.keys(props).reduce<any>((obj, prop) => { const isObjectDefinition = isObject(props[prop]) const definition = isObjectDefinition ? props[prop] : { type: props[prop] } if (defaults && prop in defaults) { obj[prop] = { ...definition, default: defaults[prop] } } else { obj[prop] = definition } if (source && !obj[prop].source) { obj[prop].source = source } return obj }, {}) } }关键收益有两个:
- 类型收窄:
AppendDefault等类型工具会根据defaults推导出更精确的 prop 类型。源码注释中的例子很直观——提供{ foo: 'a' }默认值后,props.foo的类型从string | undefined收窄为string; - 来源追溯:
source标记让 api-generator 能定位该 prop 的出处文档,这正是"共享 props 只描述一次"机制(见第五节)的实现基础。
2.2 签名规范:props或MaybeRefOrGetter,绝不解构
规范对use函数的签名有严格要求:
- 接收
props或MaybeRefOrGetter; - 在
computed/toRef内部用toValue读取,保证同时兼容响应式与非响应式输入; - 绝不在函数签名中解构 props(
function useX({ foo })是被禁止的)——解构会丢失响应式,破坏 props 的响应性传递。
真实实现可参考 rounded.ts,它同时演示了两种输入形态的兼容写法:
export function useRounded ( props: RoundedProps | Ref<RoundedValue>, name = getCurrentInstanceName(), ): RoundedData { const roundedClasses = computed(() => { const rounded = isRef(props) ? props.value : props.rounded const tile = isRef(props) ? false : props.tile const classes: string[] = [] // ... }) const roundedStyles = computed<CSSProperties>(() => { const rounded = isRef(props) ? props.value : props.rounded // ... }) return { roundedClasses, roundedStyles } }useRounded能接收整份 props 或单个Ref,内部用isRef分支取值;返回的RoundedData类型也明确声明了roundedClasses: Ref<string[]>与roundedStyles: Ref<CSSProperties>。
2.3 返回值规范:refs 普通对象,命名加前缀
规范明确三条返回约定:
- 返回普通对象,元素是 refs,绝不用
reactive——reactive会带来代理与解包的心智负担,组件侧需要的是可单独解构的 refs; - 命名加组合式函数前缀,如
exampleClasses、exampleStyles。原因很实际:组件往往并排解构多个组合式函数的返回值,前缀能避免命名冲突、让来源一目了然; - 修饰类遵循
${name}--x约定,其中name是组件名(如v-btn--rounded)。useRounded中${name}--rounded就是这一约定的直接体现,useDisplay生成的${name}--mobile同理。
三、形态二:插件形态(Plugin shape)
全局服务(display、theme、locale 等)采用另一种形态。它们的生命周期绑定在 Vuetify 实例上,而非单个组件:
createExample(options)在 framework.ts 的createVuetify中被调用;- 实例通过
app.provide(ExampleSymbol, example)注入到全局; useExample()用inject取回实例。
规范给出的模板:
export const ExampleSymbol: InjectionKey<ExampleInstance> = Symbol.for('vuetify:example') export function useExample () { const example = inject(ExampleSymbol) if (!example) throw new Error('[Vuetify] Could not find example injection') return example }3.1 真实案例:locale与display
locale.ts 是插件形态的完整范本。它定义LocaleSymbol: InjectionKey<LocaleInstance & RtlInstance> = Symbol.for('vuetify:locale'),createLocale(options)构造实例(选择外部 adapter 或内置的createVuetifyAdapter),useLocale()注入并校验:
export function useLocale () { const locale = inject(LocaleSymbol) if (!locale) throw new Error('[Vuetify] Could not find injected locale instance') return locale }display.ts 则展示了插件形态的复杂性:createDisplay(options, ssr)内部用watchEffect根据window.innerWidth与阈值(xs: 0, sm: 600, md: 840, lg: 1145, xl: 1545, xxl: 2138)推算全部断点布尔值,注册resize监听,并在onScopeDispose中移除;同时它还向外暴露makeDisplayProps/useDisplay供组件按 Props 驱动形态消费。一个组合式函数同时承载两种形态,是 Vuetify 中常见的设计。
3.2 注册与注入的完整链路
framework.ts 中createVuetify的install阶段把所有全局服务挂到 Vue 应用上:
app.provide(DefaultsSymbol, defaults) app.provide(RootDefaultsSymbol, defaults) app.provide(DisplaySymbol, display) app.provide(ThemeSymbol, theme) app.provide(IconSymbol, icons) app.provide(LocaleSymbol, locale) app.provide(DateOptionsSymbol, date.options) app.provide(DateAdapterSymbol, date.instance) app.provide(GoToSymbol, goTo)整个createVuetify运行在一个effectScope内,unmount()调用scope.stop()即可释放全部全局副作用——这是插件形态服务优雅销毁的关键设计。
四、实例、作用域与 SSR 注意事项
这是规范中偏"硬核"的部分,涉及 4 条必须遵守的纪律:
4.1 需要 vm 时用getCurrentInstance('useExample')
组合式函数需要访问组件实例(vm)时,必须从@/util引入getCurrentInstance并传入自身名称。getCurrentInstance.ts 的实现会让名字出现在报错信息里:
export function getCurrentInstance (name: string, message?: string) { const vm = _getCurrentInstance() if (!vm) { throw new Error(`[Vuetify] ${name} ${message || 'must be called from inside a setup function'}`) } return vm }配套的getCurrentInstanceName()还会把组件名转成 kebab-case——这就是useExample(props, name = getCurrentInstanceName())中默认组件名参数的来源。
4.2 条件生效的副作用用useToggleScope
useToggleScope(source, fn)用于"仅在某个条件为真时才存在"的副作用(如 hover 时的监听器)。其实现位于 toggleScope.ts,核心是利用 Vue 的effectScope:
watch(source, ...)监听布尔源,为true时创建effectScope并scope.run(fn);- 变回
false时scope.stop()并置空,内部所有 effect 一并销毁; - 支持带
reset参数的fn,可手动重启作用域; - 组件卸载时通过
onScopeDispose兜底停止。
4.3 监听器与观察器必须在onScopeDispose中清理
凡是addEventListener、observe、定时器等长生命周期副作用,都要在onScopeDispose中成对移除。范例就在 display.ts:
if (IN_BROWSER) { window.addEventListener('resize', updateSize, { passive: true }) onScopeDispose(() => { window.removeEventListener('resize', updateSize) }, true) }4.4 浏览器 API 用IN_BROWSER/SUPPORTS_*守卫
SSR 环境下window、document不存在,所有浏览器 API 访问都必须守卫。globals.ts 集中提供了这些常量:
export const IN_BROWSER = typeof window !== 'undefined' export const SUPPORTS_INTERSECTION = IN_BROWSER && 'IntersectionObserver' in window export const SUPPORTS_TOUCH = IN_BROWSER && ('ontouchstart' in window || window.navigator.maxTouchPoints > 0) export const SUPPORTS_EYE_DROPPER = IN_BROWSER && 'EyeDropper' in window export const SUPPORTS_MATCH_MEDIA = IN_BROWSER && 'matchMedia' in window && isFunction(window.matchMedia)注意其巧妙的短路结构:IN_BROWSER为false时,后续的'IntersectionObserver' in window根本不会执行,天然规避了 SSR 抛错。
五、公共 API 与发布纪律
共享组合式函数是 Vuetify 对外 API 的一部分,规范从四个层面约束其发布流程:
5.1composables/index.ts是唯一公共出口
composables/index.ts 顶部注释写得很直白:PUBLIC INTERFACES ONLY。目前只导出useDate、useDefaults、useDisplay、useGoTo、useLayout、useLocale、useRtl、useTheme、useHotkey、useMask、createRulesPlugin、useRules。这里导出的成员会被 api-generator 收录进官方 API 文档。
5.2 内部代码禁止走 barrel
与公共出口相反,内部代码必须直接import from '@/composables/example',永远不走composables/index.ts这个 barrel。这样既能避免循环依赖,也保证了公共出口的纯粹性——只有需要公开的成员才出现在那里。
5.3 新增公开成员要登记new-in.json
向某个公共组合式函数新增对外暴露的成员时,需同步更新 packages/docs/src/data/new-in.json,让文档站点的 "New in" 模块能及时呈现新能力。
5.4 破坏性变更必须走next分支
修改公共组合式函数的返回结构或参数属于破坏性变更(breaking change),只能提交到next分支,随大版本发布,禁止在维护分支偷偷改签名——这保护了所有下游组件与第三方库的兼容性。
5.5 共享 props 的文档只描述一次
来自共享组合式函数的 props,其 API 描述集中放在 api-generator 的 locale 文件中,例如 packages/api-generator/src/locale/en/ 下的example.json,而不是在每个组件的 json 里重复维护。这正是propsFactory的source标记(见 2.1)发挥作用的地方——api-generator 依靠它把组件 props 与共享来源的描述关联起来,一处编写、全局生效。
六、测试要求
所有共享组合式函数都必须有单元测试,放在packages/vuetify/src/composables/__tests__/目录下,文件命名为example.spec.ts。仓库现有测试覆盖了大部分组合式函数:
rounded.spec.ts、border.spec.ts、elevation.spec.ts、size.spec.ts、tag.spec.ts——Props 驱动形态的类名/样式推导测试;display.spec.browser.ts、goto.spec.browser.tsx、resizeObserver.spec.browser.tsx、scroll.spec.browser.tsx——依赖浏览器环境的测试使用.browser后缀,与普通单元测试分离;theme.spec.ts、defaults.spec.ts、icons.spec.ts、validation.spec.ts——插件形态服务的状态与提供/注入行为测试;proxiedModel.spec.ts、delay.spec.ts、group.spec.ts、list-items.spec.ts、filter.spec.ts等——被大量组件消费的基础能力测试。
测试文件与实现一一对应,既验证了规范中"签名与返回结构"的稳定性,也为后续重构提供了安全网。
七、把规范落到组件:一个完整示例
以 VHover.tsx 为例,把本文全部要点串起来看:
export const makeVHoverProps = propsFactory({ disabled: Boolean, modelValue: { type: Boolean, default: null }, ...makeDelayProps(), }, 'VHover') export const VHover = genericComponent<VHoverSlots>()({ name: 'VHover', props: makeVHoverProps(), setup (props, { slots }) { const isHovering = useProxiedModel(props, 'modelValue') const internal = shallowRef(false) const { runOpenDelay, runCloseDelay } = useDelay(props, value => { internal.value = value if (!props.disabled) { isHovering.value = value } }) // ... }, })这里可以看到:共享的makeDelayProps/useDelay直接复用(无需重写延迟逻辑);useProxiedModel承担 v-model 桥接;组件自己的 props 通过propsFactory组合(...makeDelayProps())而不是复制粘贴;组件名'VHover'作为source标记传入。整个组件保持轻薄,这正是共享组合式函数规范想要达到的最终效果。
结语
Vuetify 的共享组合式函数体系并非玄学,而是一套边界清晰的工程契约:复用优先控制重复,两种形态统一结构,作用域与 SSR 纪律保证健壮,公共 API 发布流程守护兼容性,测试目录兜底质量。遵循这份规范,你既能为 Vuetify 贡献风格一致的核心代码,也能在自己的 Vue 组件库中复刻这套经过大规模实战验证的组合式函数设计模式。
【免费下载链接】vuetify🐉 Vue Component Framework项目地址: https://gitcode.com/gh_mirrors/vu/vuetify
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考