跳到主要内容

创建日期:2026-09-08 | 最近更新:2026-09-08 基于 Vue 3.5.42 / Vite 8 / Pinia 4(2026-09 npm 版本核对)。组合式/类型写法以官方文档为准;前端代码非本文重点实测对象,文中不含伪造运行日志。

组合式进阶 × TypeScript × 工程化

一句话:Composition API 最大的红利不是「换个写法」,而是让逻辑能被抽成 useXxx() 函数去复用、测试、组合——这是 Vue2 mixins 给不了的(命名冲突、来源不明、类型稀烂)。这篇把「怎么写真正好用的组合式函数」讲到能上生产,并补齐 Pinia setup store、Router、Vite 的工程化拼图。

1. 组合式函数(Composable)是什么、为什么比 mixins 强

一个「用 Vue 响应式原语实现、命名以 use 开头、返回状态与操作」的普通函数:

// composables/useCounter.js
import { ref, computed } from 'vue'

export function useCounter(init = 0) {
const count = ref(init)
const double = computed(() => count.value * 2)
const inc = () => count.value++
return { count, double, inc } // 谁调谁拿到一份「自己的状态」
}
<script setup>
const { count, inc } = useCounter(10) // 组件里一行引入
</script>
mixinscomposable
命名冲突(同名字段互相覆盖)解构赋值,冲突在你这层显式解决
来源不明(this.xxx 从哪来)状态/函数从 useX() 返回,来源可见
类型几乎没法推返回对象天然可推 TS 类型
隐式共享同一 data 命名空间每个调用方独立实例(除非刻意共享)

三条纪律(好 composable 的标准):

  1. 每个调用拿独立状态(函数内 ref/reactive),除非你明确要做「单例共享 store」(§4);
  2. 只在组件/别的 composable 的 setup 上下文里调用(要能 onMountedonScopeDispose);
  3. use 前缀:既是约定,也让 linter/工具能识别「这里用了响应式上下文」。

2. 进阶模式:接受 ref 与普通值的通用入参

3.3+ 提供 toValue()——统一处理「传 ref 也行、传普通值也行」:

// composables/useDebounce.js —— 对任意值做防抖
import { ref, watch, toValue } from 'vue'

export function useDebounce(source, delay = 300) {
const debounced = ref(toValue(source)) // 支持 ref / getter / 普通值
watch(source, (v) => {
const timer = setTimeout(() => (debounced.value = v), delay)
// 记得清理(§3)
})
return debounced
}

组件里既可用 useDebounce(searchRef),也能 useDebounce('abc')——接口对调用方友好。

一个带清理与异步取消的完整例子

// composables/useSearch.js —— 输入防抖 + 请求竞态取消(真实项目高频需求)
import { ref, watch, onScopeDispose } from 'vue'
import { apiSearch } from '@/api'

export function useSearch(delay = 300) {
const keyword = ref('')
const results = ref([])
const loading = ref(false)
const controller = ref(null)

watch(keyword, async (kw) => {
loading.value = true
controller.value?.abort() // 取消上一个还没回来的请求
const ctrl = new AbortController(); controller.value = ctrl
try {
results.value = kw ? await apiSearch(kw, ctrl.signal) : []
} finally {
if (controller.value === ctrl) loading.value = false // 只认最新那个
}
})

// 组件卸载 / 外层 scope 销毁时自动清理 —— 这就是「组合式函数也能带生命周期」
onScopeDispose(() => controller.value?.abort())

return { keyword, results, loading }
}

onScopeDispose 是关键:composable 里注册的清理逻辑,会跟着调用它的组件一起销毁——这是 mixins 做不到的「资源生命周期随作用域走」。

3. 再深一层:EffectScope(库作者必看)

默认每个组件都是一个 effect scope,组件卸载 → 内部所有 watch/effect/computed 自动停。想让一堆独立逻辑组成一个可整体启停的域,用 effectScope

import { effectScope } from 'vue'
const scope = effectScope()
scope.run(() => { /* 这里面的 watch/effect 都属于这个 scope */ })
// 想整批停止:scope.stop()

适用:第三方组合式库(用户调 useX() 时替你建 scope,卸载时整体释放)、SSR/测试隔离。日常业务代码一般不需要手写 scope,知道「卸载自动停」的机制即可。

4. Pinia setup store:把「状态」也写成组合式

Vuex → Pinia 后(篇 2),setup store 是组合式爱好者的自然归宿——state 就是 ref,getters 就是 computed,actions 就是函数

// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useUserStore = defineStore('user', () => {
const token = ref<string | null>(null)
const name = ref('')
const isLogin = computed(() => !!token.value)

function login(t: string, n: string) { token.value = t; name.value = n }
function logout() { token.value = null; name.value = '' }

return { token, name, isLogin, login, logout }
})
// 组件里(保持同一份共享状态,不是每组件一份)
const store = useUserStore()
store.login('abc', 'Lin')
  • Pinia 的 store 是单例且跨组件共享(这点和 composable「每人一份」相反,别搞混);
  • setup store 里 return 的 ref 会自动被 Pinia 处理成 store.token(不用 .value)——它内部帮你解包了;
  • 在组件外(路由守卫/API 模块)用 store:要确保 Pinia 已激活——app.use(pinia) 之后即可;若在初始化前用,先 import { setActivePinia, createPinia } from 'pinia'; setActivePinia(createPinia())

5. TypeScript:把类型红利吃满

组件 props / emits / model 全类型化

<script setup lang="ts">
// 泛型 props + withDefaults 给默认值
const props = withDefaults(defineProps<{
items: Item[]
title?: string
max?: number
}>(), { title: '默认标题', max: 10 })

// 类型化 emits
const emit = defineEmits<{
(e: 'update:title', value: string): void
(e: 'save', payload: { id: number }): void
}>()

// 类型化 v-model(defineModel 3.4+)
const model = defineModel<string>()
</script>

泛型组件(3.3+)

<script setup lang="ts" generic="T extends { id: string | number }">
defineProps<{ rows: T[]; selected: T | null }>()
</script>

让一个组件在父级用不同实体类型时,props 自动跟随——表格/下拉这类「通用容器」组件直接受益。

组合式函数的类型(返回推断即可,必要时显式接口)

export interface UseFetch<T> { data: Ref<T | undefined>; loading: Ref<boolean>; run(): Promise<void> }
export function useFetch<T>(url: string): UseFetch<T> { /* … */ }

provide / inject 的类型(InjectionKey)

import { type InjectionKey } from 'vue'
export const ThemeKey: InjectionKey<Ref<string>> = Symbol('theme')
// 注入方:const theme = inject(ThemeKey) // 直接拿到 Ref<string> | undefined

6. 工程化拼图:Vite + Router

Vite(替代 webpack 的默认答案)

// vite.config.ts
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
plugins: [vue()],
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
server: { port: 5173, proxy: { '/api': 'http://localhost:8080' } }, // 开发代理
})
  • 环境变量:import.meta.env.VITE_API_BASE(只有 VITE_ 前缀会暴露给前端);
  • npm create vue@latest 直接出带 TS/ESLint/路由/Pinia 的骨架;
  • 构建产物交给生产服务器或 Docker(vite build)。

Router 组合式用法

import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
router.push({ name: 'detail', params: { id: route.params.id } })

类型增强路由 meta:

// 想在 beforeEnter/守卫里拿到带类型的 meta
declare module 'vue-router' {
interface RouteMeta { requiresAuth?: boolean; title?: string }
}

7. 一份「团队组合式分层」参考

把「逻辑放哪」定出规矩,比炫技更重要:

放什么例子
composables/useXxx与 UI 无关的通用可复用逻辑useDebounce / useFetch / useLocalStorage
stores/*跨组件共享状态useUserStore / useCartStore
composables/useDomain聚合「一个业务域」的多个 composable/storeuseOrderFlow()
api/*纯请求函数(无响应式)apiSearch / apiOrder

组合式函数的原则:UI 越少越好——把「防抖」「请求」「存取」写成纯逻辑,组件只负责把它接到模板上。这样逻辑能单测、能跨框架片段复用,组件薄得像壳。

关联

参考