Airi 项目 Vue 3 组合式函数组织模式实战:从 `.agents/skills/vue-best-practices` 参考文档到 `stage-ui` 源码验证

发布时间:2026/9/9 19:37:53
Airi 项目 Vue 3 组合式函数组织模式实战:从 `.agents/skills/vue-best-practices` 参考文档到 `stage-ui` 源码验证 Airi 项目 Vue 3 组合式函数组织模式实战从.agents/skills/vue-best-practices参考文档到stage-ui源码验证【免费下载链接】airi Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-samas altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi本篇技术指南围绕 Airi 仓库内.agents/skills/vue-best-practices技能集中的核心参考文档 composables.md 展开系统讲解 Vue 3 Composition API 下组合式函数Composable的五大组织模式小粒度组合、Options 对象参数、只读状态 显式方法、纯函数与组合式函数分层、按业务关注点组织代码。文章逐一给出可复制的代码示例并用本仓库最大 UI 包stage-uipackages/stage-ui/src/composables中的真实实现进行源码级印证读完即可在自己的 Vue 3 组件与useXxx库中落地同一套规范。这份文档在仓库中的定位Airi 是一个自托管的 AI 陪伴项目界面层大量使用 Vue 3 script setup langts Vite 构建web、桌面、移动端多端共享 UI。为了让 AI 代理在写 Vue 代码时遵循一致工程规范仓库在.agents/skills/vue-best-practices/SKILL.md中内置了名为vue-best-practices的技能它把 composables.md 与reactivity.md、sfc.md、component-data-flow.md一起列为任何 Vue 任务开工前必须通读的核心参考并要求在整个任务中保持活跃上下文。本参考文档的 frontmatter 声明了它的定位title: Composable Organization Patterns impact: MEDIUM impactDescription: Well-structured composables improve maintainability, reusability, and update performance type: best-practice tags: [vue3, composables, composition-api, code-organization, api-design, readonly, utilities]它把组合式函数视作“可复用、有状态stateful的积木块”要求按业务关注点组织代码从而让大型组件保持可维护、可更新同时避免那些难以调试的响应式状态被外部随意修改mutation以及 API 设计层面的问题。核心任务清单Task List文档开篇给出了五条可勾选的任务也是全篇骨架用小而专注的组合式函数组合出复杂行为Compose complex behavior from small, focused composables参数较多的组合式函数使用 Options 对象Use options objects for composables with multiple optional parameters当状态更新必须经由显式动作时对外返回只读readonly状态Return readonly state when updates must flow through explicit actions保持纯工具函数是普通工具函数不要包装成组合式函数Keep pure utility functions as plain utilities, not composables组合式函数与组件代码按业务关注点组织组件膨胀时及时抽取组合式函数Organize by feature concern, extract when components grow。下面逐一展开每条都配文档原始示例与仓库内真实佐证。模式一由小而聚焦的原子组合式函数组合出复杂行为反例把事件监听、坐标、命中检测全部堆进组件文档给出的反面示例非常典型——一个script setup里同时管理坐标x/y、元素引用、命中标志还要手工在onMounted/onUnmounted里注册与移除全局监听script setup import { ref, computed, onMounted, onUnmounted } from vue const x ref(0) const y ref(0) const inside ref(false) const el ref(null) function onMove(e) { x.value e.pageX y.value e.pageY if (!el.value) return const r el.value.getBoundingClientRect() inside.value x.value r.left x.value r.right y.value r.top y.value r.bottom } onMounted(() window.addEventListener(mousemove, onMove)) onUnmounted(() window.removeEventListener(mousemove, onMove)) /script这段代码的问题在于职责追踪鼠标、判断是否在元素内与生命周期样板监听注册/清理耦合在一起且一旦组件增多onMounted/onUnmounted成对逻辑会被复制得到处都是。正解三个各司其职的原子组合式函数先抽出一个通用的事件监听原语。注意其中使用了 Vue 3.3 的toValue它统一了「ref / getter / 普通值」三种形态因此target既可以传window也可以传useTemplateRef拿到的元素引用// composables/useEventListener.js import { onMounted, onUnmounted, toValue } from vue export function useEventListener(target, event, callback) { onMounted(() toValue(target).addEventListener(event, callback)) onUnmounted(() toValue(target).removeEventListener(event, callback)) }关键原理组合式函数内的onMounted/onUnmounted并不属于组合式函数自己而是绑定到调用它的组件的 setup 作用域。这就是为什么把生命周期逻辑放进组合式函数能成立——它只是把组件的生命周期与第三方资源DOM 事件、定时器、WebSocket之间的绑定关系封装起来、随用随取。这正是 Airi 中vueuse/core生态能无侵入工作的原因。接着用鼠标坐标组合式函数复用它// composables/useMouse.js import { ref } from vue import { useEventListener } from ./useEventListener export function useMouse() { const x ref(0) const y ref(0) useEventListener(window, mousemove, (e) { x.value e.pageX y.value e.pageY }) return { x, y } }最后用派生状态完成命中检测注意isOutside是一个computed它不需要任何onMounted也从不直接持有 DOM 监听// composables/useMouseInElement.js import { computed } from vue import { useMouse } from ./useMouse export function useMouseInElement(elementRef) { const { x, y } useMouse() const isOutside computed(() { if (!elementRef.value) return true const rect elementRef.value.getBoundingClientRect() return x.value rect.left || x.value rect.right || y.value rect.top || y.value rect.bottom }) return { x, y, isOutside } }仓库印证组合式函数互相组合的真实代码这种「组合式函数内部再调用组合式函数」的写法在 Airi 中大量存在。最小而完整的例子是stage-ui的 use-breakpoints.ts它没有造自己的轮子而是组合vueuse/core的useMediaQuery与 Vue 的computed对外只暴露两个布尔值import { useMediaQuery } from vueuse/core import { computed } from vue export function useBreakpoints() { const isDesktop useMediaQuery((min-width: 768px)) const isMobile computed(() !isDesktop.value) return { isDesktop, isMobile } }更典型的「小组合式函数 → 大组合式函数」分层出现在异步状态封装上use-async-state.ts 是一个极小的原语——接收一个返回 Promise 的函数输出state / isLoading / error / execute四个产物而 use-optimistic.ts 在其之上实现「乐观更新 自动回滚」先调用apply()拿到 rollback 函数再执行真实请求出错时按shouldRollback决定是否回滚。两个组合式函数各司其职useOptimisticMutation只关心编排完全不重复实现 loading/error 管理export function useOptimisticMutationT, R T, E unknown(options: UseOptimisticMutationOptionsT, R, E) { const { apply, action, onSuccess, onError, skipActionIf, shouldRollback, lazy false, } options return useAsyncState(async () { if (skipActionIf await skipActionIf()) { return undefined as R } const rollback await apply() try { const result await action() return onSuccess ? await onSuccess(result) : (result as unknown as R) } catch (err) { const allowRollback shouldRollback ? await shouldRollback(err as E) : true if (allowRollback typeof rollback function) { await rollback() } if (onError) { await onError(err as E) } throw err } }, { immediate: !lazy }) }判断依据如果一个组合式函数里出现了onMounted onUnmounted 第三方资源的成对样板、或一段可以在别处复用的状态逻辑就应该先抽出原子原语再通过嵌套调用组装出面向业务的组合式函数。模式二参数较多的组合式函数使用 Options 对象反例位置参数不可读、易错位export function useFetch(url, method, headers, timeout, retries, immediate) { // hard to read and easy to misorder } useFetch(/api/users, GET, null, 5000, 3, true)调用处useFetch(/api/users, GET, null, 5000, 3, true)中null是什么5000、3、true分别代表什么一旦参数超过 23 个位置参数就要求调用者记住完整签名稍不注意就会把timeout和retries写反且编译器难以察觉。正解单个 Options 对象 解构默认值export function useFetch(url, options {}) { const { method GET, headers {}, timeout 30000, retries 0, immediate true } options // implementation return { method, headers, timeout, retries, immediate } } useFetch(/api/users, { method: POST, timeout: 5000, retries: 3 })调用方只需传入「与默认值不同」的字段阅读成本大幅下降。在 TypeScript 项目中更进一步用interface描述 Options 的形态让 IDE 与vue-tsc提供完整的补全与类型校验interface UseCounterOptions { initial?: number min?: number max?: number step?: number } export function useCounter(options: UseCounterOptions {}) { const { initial 0, min -Infinity, max Infinity, step 1 } options // implementation }仓库印证真实项目如何设计 OptionsAiri 的useOptimisticMutation正是这一模式的教科书级实现。use-optimistic.ts 先声明带完整 JSDoc 的UseOptimisticMutationOptionsT, R, E接口六个可选字段全部给出语义化名字export interface UseOptimisticMutationOptionsT, R, E unknown { /** The optimistic update logic. Should return a rollback function. */ apply: () Promise(() Promisevoid | void) | (() Promisevoid | void) /** The actual async task (e.g., API call). */ action: () PromiseT /** Optional callback after successful action to refine state (e.g., replacing temp IDs). */ onSuccess?: (result: T) PromiseR | R /** Optional callback on error. Rollback is handled automatically. */ onError?: (error?: E | null) void | Promisevoid /** Skip the action when this returns true. */ skipActionIf?: () boolean | Promiseboolean /** Decide whether to rollback after an error. */ shouldRollback?: (error: E) boolean | Promiseboolean /** Whether to execute the action lazily. */ lazy?: boolean }其好处在实践中立竿见影这个 API 后期新增了skipActionIf、shouldRollback、lazy三个能力却没有破坏任何既有调用方——Options 对象天然向后兼容这正是位置参数无法提供的演进能力。另一个例证是聊天场景的 use-chat-history-scroll.ts一个管理「跟随对话尾部 vs 用户翻阅历史」状态的复杂组合式函数同样把所有依赖收敛进带类型的 Optionsinterface ChatHistoryScrollOptionsTMessage { container: ReadonlyShallowRefHTMLElement | null messages: ReadonlyRefTMessage[] getKey: (message: TMessage, index: number) string | number scrollToIndex: (index: number, align: start | end) void }注意这里连「依赖注入」都显式化了容器引用与消息列表由外部传入而不是在内部偷偷document.querySelector。这让组合式函数可测试、可换宿主也方便vue-tsc校验依赖类型。Options 对象的设计约定小结主参如url、data、必填回调保留为位置参数可选的、成组的配置全部并入 options每个字段用解构默认值给出安全默认避免undefined污染逻辑回调字段统一命名onXxxonSuccess/onError…布尔开关默认false或给出明确初值TypeScript 中用interface XxxOptions命名并附带 JSDoc作为组合式函数的“公共 API 契约”。模式三状态更新必须经由显式方法 —— 对外返回 readonly 状态反例直接把响应式数组暴露给调用方export function useCart() { const items ref([]) const total computed(() items.value.reduce((sum, item) sum item.price, 0)) return { items, total } // any consumer can mutate directly } const { items } useCart() items.value.push({ id: 1, price: 10 }) // 绕过所有约束直接改内部状态调用方可以对items任意push/pop/整体替换组合式函数的「不变量」形同虚设没人能保证quantity一定存在、价格一定为正、重复商品一定合并。状态被改出问题时调试者只能满仓库搜是谁动了这个数组。正解内部可写对外只读行为全部收敛到方法上import { ref, computed, readonly } from vue export function useCart() { const _items ref([]) const total computed(() _items.value.reduce((sum, item) sum item.price * item.quantity, 0) ) function addItem(product, quantity 1) { const existing _items.value.find(item item.id product.id) if (existing) { existing.quantity quantity return } _items.value.push({ ...product, quantity }) } function removeItem(productId) { _items.value _items.value.filter(item item.id ! productId) } return { items: readonly(_items), total, addItem, removeItem } }内部字段以下划线开头_items提示「私有不对外开放」返回时用readonly()包一层只读代理模板里可以安全读取但任何赋值或push都会在开发模式下抛出警告。同时所有变更路径收敛为addItem/removeItem两个显式方法业务规则合并相同商品、计算含数量总价只存在于这一处。仓库印证Airi 中只读状态 显式动作的完整案例Airi 的听觉训练Hearing段落在 use-hearing-playground-segments.ts 中实践了这一模式它维护「正在转写的当前文本」与「一段段录音/转写结果」要求异步结果按顺序落位、空结果与失败结果保持可见防止后续文本错位到先前的音频上。其返回出口见 第 95-105 行对两个状态都做了 readonly 封装只暴露七个语义化方法return { current: readonly(current), segments: readonly(segments), startRecording, finishRecording, finishEmpty, finishError, replaceStreamingText, finishStreaming, clear, }消费者组件 / 模板永远无法直接向segments塞一条假记录或把current置空只能通过finishRecording成功落字、finishEmpty保留空占位、finishError记录错误信息等方法驱动状态机而内部的每次更新也都采用不可变方式map生成新数组、spread生成新对象见 updateSegment 的实现。从测试层面看这类设计也让 use-optimistic.test.ts 之类的组合式函数单测可以只针对方法做行为断言不必担心绕过 API 的旁路修改。一个容易忽略的技术细节readonly 是浅层的需要说明的是Vue 的readonly()返回的是浅层只读代理它阻止「整体替换」与push/pop这类数组操作但不会冻结嵌套对象——如果你放入数组的元素本身是可变对象调用方仍可能修改item.name这类内部字段。因此在 use-hearing-playground-segments.ts 这类实现里开发者选择彻底禁止直接改动元素每次状态迁移都生成全新对象{ ...segment, text, status: complete }元素本身也不允许被外部持有后修改。若你的业务元素需要深层不可变则应结合Object.freeze或持久化数据结构如immer来配合而非依赖readonly()单点防护。模式四纯工具函数保持为普通函数不要包一层“假组合式函数”反例为纯格式化函数套useFormatters()export function useFormatters() { const formatDate (date) new Intl.DateTimeFormat(en-US).format(date) const formatCurrency (amount) new Intl.NumberFormat(en-US, { style: currency, currency: USD }).format(amount) return { formatDate, formatCurrency } } const { formatDate } useFormatters()formatDate不依赖任何 ref / computed / 生命周期 / provide-inject把它做成组合式函数只会强迫每个使用它的组件在setup中多一次无意义的调用并暗示「这个函数与组件实例绑定」。正解工具函数导出为普通函数组合式函数里用 computed 消费// utils/formatters.js export function formatDate(date) { return new Intl.DateTimeFormat(en-US).format(date) } export function formatCurrency(amount) { return new Intl.NumberFormat(en-US, { style: currency, currency: USD }).format(amount) }// composables/useInvoiceSummary.js import { computed } from vue import { formatCurrency } from /utils/formatters export function useInvoiceSummary(invoiceRef) { const totalLabel computed(() formatCurrency(invoiceRef.value.total)) return { totalLabel } }关键区别在于有状态、有副作用的逻辑需要响应式或生命周期才做成组合式函数无状态的纯转换函数保持普通导出。纯函数由此获得三大好处可单测不需要挂载组件、不需要setup作用域直接import { formatCurrency }断言输出可摇树tree-shaking只用到formatDate的模块不会连带打包formatCurrencySSR/非组件环境可复用普通函数可以在 store、worker、纯逻辑层中被调用而组合式函数一旦用了onMounted便只能在组件上下文中跑。仓库印证composables目录里的两种角色共存stage-ui的 composables 目录 恰好同时容纳这两种角色文件名本身就是判断依据以use开头的useBreakpoints、useAsyncState、useOptimisticMutation、useHearingPlaygroundSegments依赖 Vue 响应式或生命周期是真正的组合式函数不以use开头的如canvas-alpha.ts、linked-account-errors.ts、llm-marker-parser.ts、markdown.ts等则是模块级工具导出普通函数。例如 linked-account-errors.ts 直接导出resolveLinkedAccountOAuthErrorMessageKey(errorCode)这样的纯函数把「OAuth 错误码 → 文案 key」的映射从组件中剥离。更有意思的是 canvas-alpha.ts同一文件内同时存在普通谓词isCanvasRegionTransparent(...)第 19 行与真正的组合式函数useCanvasPixelAtPoint(...)第 95 行、useCanvasPixelIsTransparent(...)第 154 行等——纯的检测逻辑保持纯函数形态便于直接单测需要绑定 canvas 上下文与响应式读取的部分才升级为useXxx。queues.ts也从反面印证了这一点useEmotionsMessageQueue / useDelayMessageQueue 虽然是工厂型组合式函数但其中真正做解析的parseActEmotion、splitDelays被写成不含任何响应式依赖的普通函数返回结构化结果{ ok, emotion }/{ ok, delay }组合式函数只负责把解析结果接入队列副作用。所有纯逻辑与响应式副作用严格隔离。实用口径use前缀保留给那些与组件实例生命周期/响应式/注入绑定的函数其余一律作为普通函数导出即使它们恰好放在composables/目录里也不会造成混淆。模式五按业务关注点组织 composable 与组件代码反例一个script setup塞进五种相互独立的关注点script setup import { ref, computed, watch, onMounted } from vue const searchQuery ref() const items ref([]) const selected ref(null) const showModal ref(false) const sortBy ref(name) const filter ref(all) const loading ref(false) const filtered computed(() items.value.filter(i i.category filter.value)) function openModal() { showModal.value true } const sorted computed(() [...filtered.value].sort(/* ... */)) watch(searchQuery, () { /* ... */ }) onMounted(() { /* ... */ }) /script列表加载、搜索过滤、排序、选中项与弹窗——四个互不相关的业务关注点挤在同一个作用域。任何一个逻辑变化都可能影响其它状态随着模板变长这种「mega 组件」几乎无法单测与复用。正解组件退化为“组合表面”只做解构装配script setup import { useItems } from /composables/useItems import { useSearch } from /composables/useSearch import { useSelectionModal } from /composables/useSelectionModal // Data const { items, loading, fetchItems } useItems() // Search/filter/sort const { query, visibleItems } useSearch(items) // Selection modal const { selectedItem, isModalOpen, selectItem, closeModal } useSelectionModal() /script组件体积被压缩到「把几个组合式函数的返回值接起来」它只负责装配这就是文档所称的composition surface。每个组合式函数成为可单独测试、单独复用的模块。例如数据层// composables/useItems.js import { ref, onMounted } from vue export function useItems() { const items ref([]) const loading ref(false) async function fetchItems() { loading.value true try { items.value await api.getItems() } finally { loading.value false } } onMounted(fetchItems) return { items, loading, fetchItems } }仓库印证按功能边界放置的 feature-local composablesAiri 的目录结构清楚地体现了这套组织原则。SKILL.md中给出的架构建议是 feature-folder 布局components/feature/...配composables/useFeature.tsstage-ui 的实际布局则分成两层全局/领域级与具体界面无关、可跨场景复用的组合式函数统一放在 packages/stage-ui/src/composables并通过 index.ts 这个 barrel 文件export * from ./use-analytics、export * from ./use-async-state…对外提供一个公开入口——使用者永远从包根导入不必关心内部路径场景内聚级只为某个具体功能服务的组合式函数就近放在功能目录下。例如聊天界面滚动的四个 composable——use-chat-history-scroll.ts、use-chat-history-top-fade.ts、use-element-scroll.ts、use-virtualizer-scroll.ts——全部位于 packages/stage-ui/src/components/scenarios/chat/composables 这个 feature 目录内部与它们服务的聊天组件同处一个业务边界。什么时候该抽取客观触发条件综合 SKILL.md 与参考文档出现以下任一信号就应抽取组合式函数状态或副作用被多处复用同一种加载/错误状态出现第二次组件内出现 3 个以上互不相关的 UI/逻辑区块如表单、筛选、列表、底部状态栏出现模板复用行项、卡片等需要重复或独立的块组件同时承担数据编排 大量表现层标记。反过来入口/根组件与路由视图组件默认保持“薄”——只做 shell/布局/provider 装配与特性组合完整功能实现必须下沉到 feature 组件与useXxx。边界什么情况下不该引入组合式函数vue-best-practices技能的可选章节见 SKILL.md给出了清晰的取舍边界与本参考文档互补无状态的纯逻辑→ 普通函数模式四不要包装成组合式函数应用级跨业务共享状态→ Pinia store 或provide/inject如 stage-ui/src/stores 中的 provider 状态层而不是用组合式函数模拟全局单例纯粹 DOM 行为、难以用组件/组合式函数表达的指令型需求→ 自定义指令directives需要应用级安装的行为→ 插件pluginUI 大型列表性能瓶颈→ 先确认功能正确再考虑虚拟滚动、v-memo等性能手段对应参考文档perf-virtualize-large-lists.md与perf-v-once-v-memo-directives.md。一句话总结组合式函数是「复用有状态/有副作用逻辑」的手段不是“把函数都包起来”的仪式。收尾自检清单在 Airi 中提交任何 Vue 代码前可用下面的清单对照可直接作为 PR 自检复杂行为是否由 23 个原子组合式函数组合而成而非堆在一个组件里参数超过 2 个的组合式函数是否已收敛为带类型与默认值的 Options 对象需要受控的状态是否以readonly(...)对外返回变更是否全部收敛到显式方法纯格式化/解析/映射函数是否保持普通导出不叫useXxx并可被直接单测组件是否存在 3 个以上互不相关关注点若是是否已按 feature 拆出useFeature并就近放置组件是否退化成了“组合表面”只解构组合式函数返回值是否误用了组合式函数去实现纯函数、全局 store、或 DOM 指令职责把这七条与参考文档 composables.md 对照执行你写出的useXxx会同时具备「可组合、可测试、状态受控、边界清晰」四个属性——这正是stage-ui中近五十个 composable 文件得以在聊天、音频、画布、模型加载等复杂场景中长期健康演进的底层原因。深入阅读技能主流程与自检项SKILL.md参考文档原文references/composables.md配套核心参考references/reactivity.md、references/sfc.md、references/component-data-flow.md同目录大型组合式函数库入口packages/stage-ui/src/composables/index.ts原子组合式函数原语use-async-state.ts、use-breakpoints.tsOptions 乐观更新实例use-optimistic.ts 及单测 use-optimistic.test.tsreadonly 状态 显式动作实例use-hearing-playground-segments.tsfeature-local composables 实例packages/stage-ui/src/components/scenarios/chat/composables 与 use-chat-history-scroll.ts【免费下载链接】airi Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-samas altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询