Vue 3 Composables 设计模式实战:以 JavaScript + JSDoc 构建类型安全的组合式函数
2026/9/16 11:54:15 网站建设 项目流程

Vue 3 Composables 设计模式实战:以 JavaScript + JSDoc 构建类型安全的组合式函数

【免费下载链接】claude-skills67 Specialized Skills for Full-Stack Developers. Transform Claude Code into your expert pair programmer.项目地址: https://gitcode.com/GitHub_Trending/claud/claude-skills

组合式函数(Composables)是 Vue 3 中复用有状态逻辑的核心机制,而纯 JavaScript 项目中如何写出既结构规范又具备完整类型提示的组合式函数,则是一门值得深入的技术课题。本篇指南以 claude-skills 仓库中vue-expert-js技能的参考文档 composables-patterns.md 为主体,结合该技能其他参考文档与仓库结构,系统讲解从基础组合式函数、响应式 API 选型、生命周期封装、共享单例状态到异步取消的完整模式集。读完本文,你将掌握一套可直接复制到真实 Vue 3 纯 JS 项目中的组合式函数设计范式,并学会用 JSDoc 补齐类型安全,让代码在无 TypeScript 的前提下依然可被编辑器、ESLint 与 Agent 精确理解。

文档定位:这套模式在项目中的角色

composables-patterns.md是 claude-skills 仓库中 vue-expert-js 技能 的五份核心参考文档之一。该技能专门面向「只用 JavaScript、不用 TypeScript」构建 Vue 3 应用的场景,其工作流明确规定:用 JSDoc 的@typedef@param@returns@type注解实现完整类型覆盖,并用eslint-plugin-jsdoc校验覆盖率,随后以 Vitest 验证。

组合式函数正是这套工作流的核心产物——技能要求「每个公开函数都标注@param@returns、复杂对象结构用@typedef定义、响应式变量用@type注解」。因此本参考文档中的每一个模式示例都是「组合式函数设计」与「JSDoc 类型标注」两种能力的融合体,阅读时建议与同技能下的 jsdoc-typing.md 对照学习。

基础组合式函数结构:以 useToggle 为例

组合式函数的第一个设计要点是统一的结构约定:函数命名以use开头、内部通过ref创建响应式状态、返回一个包含状态与操作方法的对象。文档给出的最小完整示例是useToggle

// composables/useToggle.js import { ref } from 'vue' /** * @typedef {Object} UseToggleReturn * @property {import('vue').Ref<boolean>} value * @property {() => void} toggle */ /** * @param {boolean} [initialValue=false] * @returns {UseToggleReturn} */ export function useToggle(initialValue = false) { const value = ref(initialValue) const toggle = () => { value.value = !value.value } return { value, toggle } }

这段代码体现了三个值得固化的习惯:

  1. 返回类型的显式声明:通过@typedef {Object} UseToggleReturn定义返回对象的完整形状,再以@returns {UseToggleReturn}标注函数返回值。这正是 vue-expert-js 技能「每个公开函数都要有@param@returns」约束的直接落地。
  2. 参数默认值语义@param {boolean} [initialValue=false]中的方括号表示可选参数,=false表示默认值,这与 JSDoc 的可选参数语法完全一致,与export function useToggle(initialValue = false)的实现互相印证。
  3. .value的语义边界toggle内部通过value.value = !value.value修改响应式状态,对外返回的仍是value本身(一个Ref<boolean>),调用方在模板中会被自动解包,在脚本中则通过.value访问。

从仓库的 SKILL.md 核心工作流 可以印证这套约定的完整闭环:设计结构(含 JSDoc 类型注解)→ 用<script setup>实现 → 用 ESLint + JSDoc 插件校验类型覆盖 → 用 Vitest 验证。也就是说,参考文档中的每个组合式函数都是「类型标注 + 逻辑实现」双重交付的模板,而不是可以省略注解的示意代码。

ref 与 reactive 的选择:按数据的形状决定

文档给出的第二条准则是「响应式 API 按数据形状选型」。完整示例与注解如下:

import { ref, reactive, toRefs, toValue } from 'vue' // Use ref for: primitives, reassignable values, composable returns /** @type {import('vue').Ref<number>} */ const count = ref(0) // Use reactive for: complex objects with nested properties /** @type {{ email: string, password: string }} */ const form = reactive({ email: '', password: '' }) // Convert reactive to refs for destructuring const { email, password } = toRefs(form) // Unwrap ref or return plain value /** @param {number | import('vue').Ref<number>} maybeRef */ function double(maybeRef) { return toValue(maybeRef) * 2 }

逐个拆解其中的决策点:

  • ref()用于原始值与可整体重新赋值的值count是一个数字,且组合式函数返回的响应式状态通常以Ref形式对外暴露,便于调用方解构后依然保持响应性。
  • reactive()用于具有嵌套属性的复杂对象。表单这类「多字段、深层结构」的数据用reactive声明后,内部所有嵌套属性都自动具备响应性,无需像ref那样逐个访问.value
  • toRefs()解决「解构丢失响应性」问题。直接const { email } = form会把email变成一个普通字符串,之后对该变量的修改不再触发视图更新;toRefs(form)会把每个属性转换成独立的Ref,解构后依然与源对象保持响应式连接。
  • toValue()是 Vue 3.3 引入的通用解包工具。它接受一个Ref或普通值:传入Ref时返回其内部值,传入普通值时原样返回。上面的double(maybeRef)因此既能接受double(4)也能接受double(count),这让组合式函数的参数设计可以「同时兼容响应式和非响应式输入」。

同样的选型原则在仓库同技能的其他文档中反复出现:composition-api.md 参考文档 以 TypeScript 版本演示了同一组 API 的用法,二者可互为对照;而 jsdoc-typing.md 则补充了用@typedef+@typereactive表单声明完整类型结构的写法。

生命周期钩子的组合式封装:事件监听与异步安全

组合式函数的一大优势是能在函数体内直接调用生命周期钩子,把「注册 + 清理」的配对逻辑封装成可复用的单元。文档给出了两个典型场景。

场景一:封装事件监听

// composables/useEventListener.js import { onMounted, onUnmounted, toValue } from 'vue' /** * @template {keyof WindowEventMap} K * @param {K} event * @param {(ev: WindowEventMap[K]) => void} handler * @param {EventTarget | import('vue').Ref<EventTarget>} [target=window] */ export function useEventListener(event, handler, target = window) { onMounted(() => toValue(target).addEventListener(event, handler)) onUnmounted(() => toValue(target).removeEventListener(event, handler)) }

这个例子把@template泛型注解也用上了:@template {keyof WindowEventMap} K约束了事件名必须是浏览器内置事件类型,handler 的参数类型随之被推导为对应的事件对象。注意target同时接受EventTargetRef<EventTarget>,配合toValue(target)在挂载时解包——这正好呼应上文「参数兼容响应式与非响应式输入」的设计思想。

场景二:生命周期感知的异步状态

「组件卸载后再更新状态」是 Vue 应用中常见的隐患。文档给出的useAsyncState用标志位加onUnmounted优雅地规避了这个问题:

// Lifecycle-aware async (prevents state updates after unmount) import { ref, onUnmounted } from 'vue' export function useAsyncState(fn) { const data = ref(null) const loading = ref(false) let isMounted = true onUnmounted(() => { isMounted = false }) async function execute() { loading.value = true try { const result = await fn() if (isMounted) data.value = result } finally { if (isMounted) loading.value = false } } return { data, loading, execute } }

其原理是:模块闭包中的isMounted初始为true,组件卸载时onUnmounted将其置为false。当异步的fn()在卸载之后才 resolve 时,isMountedfalse,于是跳过data.value = result,避免了对已卸载组件内部状态的无意义写入。finally中的同样守卫保证了loading状态也不会被卸载后的残留任务错误地翻转。

在 vue-expert 的 composition-api 参考文档 中可以看到完整的生命周期钩子清单(onMountedonUpdatedonUnmountedonErrorCaptured等),其中onUnmounted被明确标注为「清理定时器、监听器」的归宿,与本文的两个例子完全一致。

共享状态单例:模块级 ref

当多个组件需要共享同一份状态时,组合式函数天然支持「单例」模式:ref声明在模块作用域(而非函数体内),所有调用useNotifications()的组件共享同一份状态

// composables/useNotifications.js import { ref, readonly } from 'vue' // Module-level state = singleton shared across all components /** @type {import('vue').Ref<Array<{id: string, message: string}>>} */ const notifications = ref([]) export function useNotifications() { /** @param {string} message */ function notify(message) { const id = Date.now().toString() notifications.value.push({ id, message }) setTimeout(() => dismiss(id), 5000) } /** @param {string} id */ function dismiss(id) { notifications.value = notifications.value.filter(n => n.id !== id) } return { notifications: readonly(notifications), notify, dismiss } }

这个模式有三个要点值得吸收:

  1. 单例的机制notifications定义在模块顶层,模块只被加载一次,因此无论多少组件调用useNotifications(),拿到的都是同一个数组引用。
  2. readonly()保护只读边界:对外暴露的是readonly(notifications),组件只能通过notify/dismiss两个方法修改状态,杜绝了外部直接 push、篡改共享数据的散弹式副作用。
  3. 封装业务规则:自动生成 id、5 秒后自动消失(setTimeout(() => dismiss(id), 5000))这些业务逻辑被收敛在组合式函数内部,组件侧只关心「发起通知」和「手动关闭」。

与「单例」相对的另一极是工厂函数模式:若把ref放进函数体内、返回新的实例,那么每个组件就拥有独立的副本。文档末尾的速查表明确区分了两者:「Module-level ref → Singleton shared state;Factory function → New instance per component」。选择依据很简单——需要跨组件同步就声明在模块级,需要组件隔离就声明在函数体内。当共享状态进一步复杂化时,state-management.md 提供的 Pinia setup store 语法正是对这种模式的工程化扩展(store 内同样用ref声明状态、computed声明派生数据)。

带取消的异步组合式函数:AbortController 全流程

请求竞态与内存泄漏是异步 UI 的两大顽疾。文档给出的useCancellableFetchAbortController给出了完整解法:

// composables/useCancellableFetch.js import { ref, onUnmounted } from 'vue' export function useCancellableFetch() { const data = ref(null) const error = ref(null) const loading = ref(false) /** @type {AbortController | null} */ let controller = null /** @param {string} url */ async function execute(url) { controller?.abort() controller = new AbortController() loading.value = true error.value = null try { const res = await fetch(url, { signal: controller.signal }) data.value = await res.json() } catch (e) { if (/** @type {Error} */ (e).name !== 'AbortError') { error.value = /** @type {Error} */ (e) } } finally { loading.value = false } } onUnmounted(() => controller?.abort()) return { data, error, loading, execute } }

逐层分析它的防御机制:

  • 连续请求的竞态防护:每次execute开头先controller?.abort()中止上一次未完成的请求,再创建新的AbortController。这意味着用户快速触发多次请求时,只有最后一次的结果会被采纳,前序请求被主动作废。
  • AbortError 的静默处理catch中判断e.name !== 'AbortError'才写入error。被我们主动中止的请求会抛出AbortError,这属于「预期内的取消」而非「真实错误」,因此不污染错误状态。代码里的/** @type {Error} */ (e)是 JSDoc 类型断言,帮助编辑器正确识别错误对象类型。
  • 组件卸载时的兜底取消onUnmounted(() => controller?.abort())确保组件卸载瞬间仍有未完成请求时立即中止,避免请求回调访问已销毁的组件状态。

至此,本文生命周期部分的三个技巧(onUnmounted清理、isMounted守卫、AbortController取消)恰好构成了一套完整的异步安全体系:监听类副作用用钩子配对清理,无取消机制的异步用标志位守卫,可取消的网络请求用 AbortController 主动中止。

模式速查表:一表掌握选型决策

文档在末尾给出了精炼的速查表,这是整个组合式函数设计决策的浓缩,完整保留如下:

PatternUse Case
ref()Primitives, values passed to/from composables
reactive()Objects with nested reactivity
toRefs()Destructure reactive while keeping reactivity
toValue()Unwrap ref or return plain value
Module-level refSingleton shared state
Factory functionNew instance per component
onUnmountedCleanup timers, listeners, abort controllers

这张表可以直接作为组合式函数设计的决策清单:先判断数据形状(原始值还是嵌套对象)选ref/reactive;需要解构保持响应性就用toRefs;函数入参要兼容 ref 和普通值时用toValue;状态共享范围决定单例还是工厂;最后检查所有注册的副作用是否在onUnmounted中清理。

用 JSDoc 把类型安全拉满:组合式函数的进阶标注

composables-patterns.md 中的示例已经展示了@typedef@param@returns@template@typeimport('vue').Ref<T>等多种标注。同技能的 jsdoc-typing.md 则把这些技巧系统化,这里选取与组合式函数直接相关的三个进阶用法:

泛型返回:useFetch

// composables/useFetch.js import { ref, watchEffect, toValue } from 'vue' /** * @template T * @typedef {Object} UseFetchReturn * @property {import('vue').Ref<T | null>} data - Fetched data * @property {import('vue').Ref<Error | null>} error - Error if any * @property {import('vue').Ref<boolean>} loading - Loading state * @property {() => Promise<void>} refresh - Refetch data */ /** * Composable for fetching data * @template T * @param {string | import('vue').Ref<string>} url - URL to fetch * @param {RequestInit} [options] - Fetch options * @returns {UseFetchReturn<T>} */ export function useFetch(url, options = {}) { /** @type {import('vue').Ref<T | null>} */ const data = ref(null) /** @type {import('vue').Ref<Error | null>} */ const error = ref(null) /** @type {import('vue').Ref<boolean>} */ const loading = ref(false) async function refresh() { loading.value = true error.value = null try { const response = await fetch(toValue(url), options) if (!response.ok) { throw new Error(`HTTP error: ${response.status}`) } data.value = await response.json() } catch (e) { error.value = /** @type {Error} */ (e) } finally { loading.value = false } } watchEffect(() => { refresh() }) return { data, error, loading, refresh } }

@template Tdata的类型随调用上下文自动推断;url参数同时接受字符串或Ref<string>并通过toValue解包,与 useEventListener 的 target 参数设计一脉相承;watchEffect则让url变化时自动重新拉取数据。

可配置选项对象:useLocalStorage

// composables/useLocalStorage.js import { ref, watch } from 'vue' /** * @template T * @typedef {Object} UseLocalStorageOptions * @property {(value: T) => string} [serialize] - Custom serializer * @property {(value: string) => T} [deserialize] - Custom deserializer */ /** * Reactive localStorage composable * @template T * @param {string} key - Storage key * @param {T} defaultValue - Default value if key not found * @param {UseLocalStorageOptions<T>} [options] - Options * @returns {import('vue').Ref<T>} */ export function useLocalStorage(key, defaultValue, options = {}) { const serialize = options.serialize ?? JSON.stringify const deserialize = options.deserialize ?? JSON.parse /** @type {import('vue').Ref<T>} */ const data = ref(defaultValue) // Load from storage const stored = localStorage.getItem(key) if (stored) { try { data.value = deserialize(stored) } catch { data.value = defaultValue } } // Persist on change watch(data, (value) => { localStorage.setItem(key, serialize(value)) }, { deep: true }) return data }

这里展示了「自定义序列化函数」的选项注入模式:默认用JSON.stringify/JSON.parse,调用方可通过options.serialize/options.deserialize覆盖,且@typedef把选项函数的签名精确到参数与返回值,IDE 悬停即可看到完整契约。

跨文件共享类型

在纯 JS 项目中,复杂类型常被多个组合式函数共享。jsdoc-typing.md 推荐把共享类型集中到types.js,用「导出空对象」的方式帮助 IDE 解析:

// types.js - Shared type definitions /** * @typedef {Object} User * @property {number} id * @property {string} name * @property {string} email * @property {UserRole} role */ /** * @typedef {'admin' | 'user' | 'guest'} UserRole */ // Export empty object for IDE import support export const Types = {}

其他文件通过/** @typedef {import('./types.js').User} User */引入,组合式函数内部即可使用/** @type {import('vue').Ref<User | null>} */标注状态。这种「类型模块」的组织方式让组合式函数的类型定义可复用、可维护,是纯 JS 项目中替代.d.ts的轻量方案。

组合式函数的可测性设计

设计良好的组合式函数同样易于测试。vue-expert-js 技能的 testing-patterns.md 专门演示了如何在组件测试中 mock 组合式函数——这反向印证了组合式函数的一个核心设计约束:必须通过模块导出,且返回结构要可被vi.spyOn().mockReturnValue()完整替换

// Header.test.js import { describe, it, expect, vi } from 'vitest' import { mount } from '@vue/test-utils' import { ref, computed } from 'vue' import Header from './Header.vue' import * as useAuthModule from '@/composables/useAuth' describe('Header', () => { it('shows login button when logged out', () => { vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({ user: ref(null), isLoggedIn: computed(() => false), login: vi.fn(), logout: vi.fn() }) const wrapper = mount(Header) expect(wrapper.find('[data-test="login-btn"]').exists()).toBe(true) }) it('shows user menu when logged in', () => { vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({ user: ref({ id: 1, name: 'John' }), isLoggedIn: computed(() => true), login: vi.fn(), logout: vi.fn() }) const wrapper = mount(Header) expect(wrapper.find('[data-test="user-menu"]').exists()).toBe(true) }) })

这段测试揭示的组合式函数设计启示是:返回值应保持「状态用 ref、派生值用 computed、操作用普通函数」的稳定结构。mock 之所以能精确还原useAuth的返回形状,正是因为真实实现遵循了本文第一部分useToggle演示的统一约定——这也正是「模式」的价值所在:约定一致,替换才无痛。技能工作流「测试失败则回到组合式函数修正逻辑或注解再重跑」的闭环,也建立在组合式函数返回结构稳定可断言的前提上。

实战落地:把这套模式嵌入开发工作流

在 claude-skills 的 vue-expert-js 技能语境下,这套组合式函数模式的完整使用流程是:

  1. 设计阶段:按「数据形状」选型ref/reactive,按「共享范围」决定单例还是工厂,先写@typedef定义返回契约;
  2. 实现阶段:用<script setup>(不带lang="ts")在组件中引入组合式函数,需要 ES 模块时用.mjs扩展名,参考文档与 SKILL.md 中useCounter.mjs示例的完整标注格式;
  3. 校验阶段:运行 ESLint +eslint-plugin-jsdoc检查每个公开 API 的注解完整性,缺失或格式错误的注解需在继续前修复;
  4. 测试阶段:用 Vitest + Vue Test Utils 验证,如vi.spyOnmock 组合式函数、flushPromises()等待异步渲染,测试不绿则回到组合式函数修正逻辑或注解。

仓库对技能文档的质量约束也在保障这套模式的稳定性:scripts/validate-skills.py会校验技能的 YAML 元数据完整性、Core Workflow 步骤数(恰好为 5 步,与上述流程对应)、reference 文档的相对路径是否可解析、以及是否残留非标准标题头。也就是说,你在 composables-patterns.md 中看到的每一个模式,都处于一套被脚本化校验保障的文档体系之内,可以直接作为团队内部知识库或 Agent 技能参考使用。

小结

回顾 composables-patterns.md 的核心脉络:组合式函数的本质是把「响应式状态 + 生命周期 + 副作用清理」打包成可复用的函数单元。基础结构解决「怎么写」,ref/reactive 选型解决「用什么响应式容器」,生命周期封装解决「何时注册与清理」,模块级 ref 解决「状态共享范围」,AbortController 解决「异步取消」。配合 JSDoc 的@typedef/@template/@type体系,纯 JavaScript 项目也能获得接近 TypeScript 的开发体验——这正是 claude-skills 的 vue-expert-js 技能试图交付的完整方案。把这五类模式沉淀为团队内共享的组合式函数库,是 Vue 3 应用中长期可控、可维护、可测试的可靠路径。

【免费下载链接】claude-skills67 Specialized Skills for Full-Stack Developers. Transform Claude Code into your expert pair programmer.项目地址: https://gitcode.com/GitHub_Trending/claud/claude-skills

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

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

立即咨询