Vuetify 共享组合式函数(Shared Composables)开发规范与源码级实践指南
2026/9/19 8:10:03 网站建设 项目流程

Vuetify 共享组合式函数(Shared Composables)开发规范与源码级实践指南

【免费下载链接】vuetify🐉 Vue Component Framework项目地址: https://gitcode.com/gh_mirrors/vu/vuetify

导读

Vuetify 的核心组件库由数百个共享组合式函数(composables)驱动,例如useDisplayuseLocaleuseRounded等。本文以仓库中 .claude/rules/composables.md 这份团队开发规范为骨架,结合packages/vuetify/src/composables/packages/vuetify/src/util/下的真实源码,系统讲解共享组合式函数的设计约束、两种标准形态(Props 驱动形态与插件形态)、作用域与 SSR 注意事项、公共 API 发布纪律及测试要求。读完本文,你将能写出与 Vuetify 核心组件风格一致、可被 API 生成器自动收录、可被多个组件安全复用的高质量组合式函数。


一、为什么需要共享组合式函数规范

共享组合式函数被众多组件同时消费——一个签名或响应式处理上的小失误,会顺着组件树扩散到所有使用者。因此规范首先强调两件事:

  1. 组件专属逻辑放组件旁边:仅被单个组件使用的逻辑,应写成组件目录下的局部 composable(见components.md的约定),而不是塞进共享目录。
  2. 共享逻辑优先复用:在动手写新函数之前,先检索src/composablessrc/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 目前再导出了isArrayisBooleanisElementisFunctionisNullisNullOrUndefinedisNumberisObjectisStringisSymbolisThenableisUndefinedfindMatchRanges等工具,并把rangecreateRange的名字导出——注释解释了原因:range会在VPaginationVRatingVSlider等多个组件中遮蔽局部变量。这既是集中入口,也是命名治理。


二、形态一: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 签名规范:propsMaybeRefOrGetter,绝不解构

规范对use函数的签名有严格要求:

  • 接收propsMaybeRefOrGetter
  • computed/toRef内部用toValue读取,保证同时兼容响应式与非响应式输入;
  • 绝不在函数签名中解构 propsfunction 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 普通对象,命名加前缀

规范明确三条返回约定:

  1. 返回普通对象,元素是 refs,绝不用reactive——reactive会带来代理与解包的心智负担,组件侧需要的是可单独解构的 refs;
  2. 命名加组合式函数前缀,如exampleClassesexampleStyles。原因很实际:组件往往并排解构多个组合式函数的返回值,前缀能避免命名冲突、让来源一目了然;
  3. 修饰类遵循${name}--x约定,其中name是组件名(如v-btn--rounded)。useRounded${name}--rounded就是这一约定的直接体现,useDisplay生成的${name}--mobile同理。

三、形态二:插件形态(Plugin shape)

全局服务(display、theme、locale 等)采用另一种形态。它们的生命周期绑定在 Vuetify 实例上,而非单个组件:

  1. createExample(options)在 framework.ts 的createVuetify中被调用;
  2. 实例通过app.provide(ExampleSymbol, example)注入到全局;
  3. 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 真实案例:localedisplay

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 中createVuetifyinstall阶段把所有全局服务挂到 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时创建effectScopescope.run(fn)
  • 变回falsescope.stop()并置空,内部所有 effect 一并销毁;
  • 支持带reset参数的fn,可手动重启作用域;
  • 组件卸载时通过onScopeDispose兜底停止。

4.3 监听器与观察器必须在onScopeDispose中清理

凡是addEventListenerobserve、定时器等长生命周期副作用,都要在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 环境下windowdocument不存在,所有浏览器 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_BROWSERfalse时,后续的'IntersectionObserver' in window根本不会执行,天然规避了 SSR 抛错。


五、公共 API 与发布纪律

共享组合式函数是 Vuetify 对外 API 的一部分,规范从四个层面约束其发布流程:

5.1composables/index.ts是唯一公共出口

composables/index.ts 顶部注释写得很直白:PUBLIC INTERFACES ONLY。目前只导出useDateuseDefaultsuseDisplayuseGoTouseLayoutuseLocaleuseRtluseThemeuseHotkeyuseMaskcreateRulesPluginuseRules。这里导出的成员会被 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 里重复维护。这正是propsFactorysource标记(见 2.1)发挥作用的地方——api-generator 依靠它把组件 props 与共享来源的描述关联起来,一处编写、全局生效。


六、测试要求

所有共享组合式函数都必须有单元测试,放在packages/vuetify/src/composables/__tests__/目录下,文件命名为example.spec.ts。仓库现有测试覆盖了大部分组合式函数:

  • rounded.spec.tsborder.spec.tselevation.spec.tssize.spec.tstag.spec.ts——Props 驱动形态的类名/样式推导测试;
  • display.spec.browser.tsgoto.spec.browser.tsxresizeObserver.spec.browser.tsxscroll.spec.browser.tsx——依赖浏览器环境的测试使用.browser后缀,与普通单元测试分离;
  • theme.spec.tsdefaults.spec.tsicons.spec.tsvalidation.spec.ts——插件形态服务的状态与提供/注入行为测试;
  • proxiedModel.spec.tsdelay.spec.tsgroup.spec.tslist-items.spec.tsfilter.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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询