Vue + TypeScript 深度实战:组件类型、Props 泛型、Pinia 类型安全与 API 契约

Vue 3 与 TypeScript 结合的生产级实践:组件 Props 类型标注、defineProps 泛型、 emits 类型、ref/reactive 类型推断、computed 类型、Pinia Store 类型、Vue Router 类型安全、Zod API 数据契约、以及 strict tsconfig 配置。

Vue 3 原生支持 TypeScript ,<script setup lang="ts"> 让 TypeScript 成为 Vue 组件的一等公民。本文从 Props 泛型标注、响应式类型推断、Pinia Store 类型安全、Zod API 数据契约,到 Vue Router Typed Routes、Provide / Inject 的 InjectionKey、泛型组件与插槽类型、Vite 类型扩展,以及 Volar takeover 模式的性能对比,覆盖从组件层到工程层的完整 TypeScript 实践链路。掌握这些内容后,你可以让 Vue 项目在生产环境中拥有接近全链路的类型安全。


一、组件 Props 类型定义

1.1 defineProps 泛型写法(运行时与编译时双保险)

在 Vue 3 单文件组件中,使用 <script setup lang="ts"> 时,defineProps<Props>() 是推荐写法。这种写法由 Vue 编译器在编译阶段解析接口定义,同时保留运行时验证的能力,不需要额外引入 PropType。相比 Options API 中的 props 选项,defineProps 的泛型版本能够直接在 TypeScript 层面约束外部传入的数据形状,并自动推导模板中 $props 的类型。

<script setup lang="ts">
interface Item {
  id: string
  label: string
  done: boolean
}

interface Props {
  title: string
  count?: number
  items: Item[]
  onUpdate?: (id: string) => void
}

// 运行时 + 编译时类型
const props = defineProps<Props>()

// 带默认值时使用 withDefaults 宏,确保模板中不会遇到 undefined
const propsWithDefaults = withDefaults(defineProps<Props>(), {
  count: 0,
  onUpdate: () => {},
})
</script>

若需要运行时更细粒度的校验,可将接口与 PropType 组合。Array 与 Object 类型的 Prop 必须使用 PropType<T> 来告诉 TypeScript 实际存储的数据类型:

<script setup lang="ts">
import type { PropType } from 'vue'

const props = defineProps({
  title: { type: String, required: true },
  count: { type: Number, default: 0 },
  items: { type: Array as PropType<Item[]>, required: true },
})
</script>

1.2 defineEmits 类型化(事件签名枚举)

defineEmits 支持两种类型声明方式:元组风格与对象回调风格。推荐元组风格,因为它更贴近声明式事件签名的语义,且与 Vue 3.3+ 的类型化事件声明保持一致。对 v-model 绑定,应沿用 'update:modelValue' 事件名。

<script setup lang="ts">
const emit = defineEmits<{
  update: [value: string]
  delete: [id: number]
  'update:modelValue': [val: boolean]
}>()

// 调用时类型严格检查,误写事件名会立即报错
emit('update', 'new value')
emit('delete', 42)
</script>

1.3 复杂 Props 验证函数的类型标注

当单个 Prop 需要基于运行时逻辑校验时,可在验证函数内部仍然享受 TypeScript 的类型提示:

<script setup lang="ts">
const props = defineProps({
  status: {
    type: String as PropType<'draft' | 'published' | 'archived'>,
    required: true,
    validator: (v: string) => ['draft', 'published', 'archived'].includes(v),
  },
})
</script>

二、响应式类型

2.1 ref 与类型推断边界

ref 的泛型参数决定了 .value 的类型。对于联合类型,建议始终显式标注,否则 null 会被缩窄为字面量类型,导致后续赋值失败。Ref<T> 裸类型可通过从 vue 导入并在类型层面传递。

import { ref } from 'vue'
import type { Ref } from 'vue'

const count = ref<number>(0)           // Ref<number>
const user = ref<User | null>(null)     // Ref<User | null>
const list = ref<string[]>([])          // Ref<string[]>

interface State {
  loading: boolean
  data: Data | null
  error: Error | null
}

const state = ref<State>({
  loading: false,
  data: null,
  error: null,
})

// 跨模块导出类型时可直接使用 Ref 导入
const shared: Ref<number> = ref(0)

2.2 reactive 与深度响应式类型

reactive 返回的类型会自动解包 ref,因此 reactive 字段中的 Ref<T> 在访问时表现为 T 而非 Ref<T>。使用 reactive<State>() 可以确保对象结构始终受 State 约束,防止新增字段破坏类型完整性。

interface FormState {
  email: string
  password: string
  errors: Record<string, string>
}

const form = reactive<FormState>({
  email: '',
  password: '',
  errors: {},
})

// form.email 类型为 string
// form.errors 类型为 Record<string, string>

2.3 computed 类型(自动推导与显式约束)

computed 大多数情况下自动推导出返回类型,但在以下两种场景应显式声明返回值类型:一是计算逻辑包含分支,导致类型推导过宽;二是需要将结果作为公共 API 导出,防止内部修改行为改变外部类型契约。

import { computed } from 'vue'

const user = ref<User | null>(null)

// 自动推断为 string | undefined
const displayName = computed(() => user.value?.name?.toUpperCase())

// 显式声明返回类型,强制内部过滤逻辑不可返回意外类型
const userList = computed<User[]>(() => {
  return userStore.users.filter(u => u.active)
})

2.4 shallowRef 与复杂对象表面积优化

当一个大对象仅需要替换级响应、不需要深度侦听内部属性变化时,使用 shallowRef 可以显著降低 Vue 响应式系统的开销。这在处理原始 canvas 上下文、WebGL 实例、外部库类实例时尤其有用。

import { shallowRef, triggerRef } from 'vue'

const chart = shallowRef<echarts.ECharts | null>(null)
chart.value = echarts.init(dom)   // 只追踪顶层替换
// chart.value.setOption(...)    // 内部变化不会触发响应式

// 手动触发时才更新依赖
function forceRefresh() {
  triggerRef(chart)
}

三、Pinia 类型安全

3.1 Setup Store 类型推导

Pinia 的 Setup Store 写法与 Vue Composition API 完全一致,因此 ref、computed 的类型可以直接暴露给 Store 消费者。推荐将 Store 抽取为 useAuthStore() 函数,并在模块顶部统一导出,使消费端获得完整的类型提示与自动补全。

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

interface User {
  id: string
  name: string
  email: string
  role: 'admin' | 'user' | 'guest'
}

export const useAuthStore = defineStore('auth', () => {
  const user = ref<User | null>(null)
  const token = ref<string>('')

  const isLoggedIn = computed(() => !!user.value)
  const isAdmin = computed(() => user.value?.role === 'admin')

  async function login(email: string, password: string): Promise<void> {
    const res = await api.login({ email, password })
    user.value = res.user
    token.value = res.token
  }

  function logout(): void {
    user.value = null
    token.value = ''
  }

  return { user, token, isLoggedIn, isAdmin, login, logout }
})

// 使用时类型自动推导
const auth = useAuthStore()
auth.user?.name                   // string | undefined
auth.login('a@b.com', 'pwd')      // 参数类型严格检查

3.2 Store 到 Store 的类型依赖

当一个 Store 需要依赖另一个 Store 时,应该在 Action( Setup Store 中即函数内部)进行 useOtherStore() 调用,而不是在全局作用域直接调用。这可以避免在应用初始化阶段产生循环依赖,并确保热更新不会失效。

import { useCartStore } from './cart'

export const useOrderStore = defineStore('order', () => {
  async function checkout(): Promise<void> {
    const cart = useCartStore()
    await api.createOrder({ items: cart.items })
    cart.clear()
  }

  return { checkout }
})

四、API 数据契约(Zod)

4.1 Schema 与类型同步推导

Zod 的优势在于一次声明即可同时获得运行时校验器与编译时类型。对外部 API 返回的数据,先用 Zod 的 safeParse 或 parse 验证,再转交业务逻辑,可从根源杜绝后端接口变更导致的前端运行时错误。推荐为每个核心领域对象创建独立的 Schema 文件,并在 API 层统一消费。

// schemas/user.ts
import { z } from 'zod'

export const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1),
  email: z.string().email(),
  role: z.enum(['admin', 'user', 'guest']),
  createdAt: z.string().datetime(),
})

export type User = z.infer<typeof UserSchema>

// API 层
export async function fetchUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`)
  const json = await res.json()
  return UserSchema.parse(json)  // 运行时验证 + 类型收窄
}

4.2 可复用的分页 Schema 模式

将通用的分页结构抽取为带泛型的工厂函数,使类型复用率最大化,同时保持各业务列表的类型严格性。

import { z } from 'zod'

function createPaginatedSchema<T extends z.ZodTypeAny>(itemSchema: T) {
  return z.object({
    page: z.number().min(1),
    pageSize: z.number().min(1),
    total: z.number(),
    list: z.array(itemSchema),
  })
}

const paginatedUserSchema = createPaginatedSchema(UserSchema)
type PaginatedUsers = z.infer<typeof paginatedUserSchema>

五、tsconfig.json 推荐配置

5.1 面向 Vite + Vue 的严格配置

strict: true 是生产级 Vue + TypeScript 项目的底线。noUnusedLocals 与 noUnusedParameters 可以避免组件与工具函数中存在幽灵变量。moduleResolution: bundler 配合 Vite 使用可获得正确的 Node16 路径解析行为,同时保留 paths 别名支持。isolatedModules 是所有基于 ESBuild / swc 的构建流程必须开启的选项,防止无法跨文件常量枚举等边缘问题。

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "preserve",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "isolatedModules": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    },
    "types": ["vite/client"]
  },
  "include": ["src/**/*.ts", "src/**/*.vue", "env.d.ts"],
  "exclude": ["node_modules", "dist"]
}

六、Vue Router 类型安全

6.1 Typed Routes 与元信息类型扩展

Vue Router 4 支持通过 TypeScript 模块扩展来为 RouteRecordRaw 的 meta 字段添加自定义类型。这是实现路由守卫中角色拦截、面包屑动态生成、以及标签页缓存策略类型化的前提。

// router/types.ts
import 'vue-router'

declare module 'vue-router' {
  interface RouteMeta {
    requiresAuth?: boolean
    roles?: string[]
    title?: string
    keepAlive?: boolean
    hiddenInMenu?: boolean
  }
}

配合 router.beforeEach 时,守卫的回调参数 to 与 from 会带有完整的 RouteLocationNormalized 类型,访问 to.meta.roles 会被推断为 string[] | undefined,不会退化为 any:

// router/guard.ts
import type { NavigationGuardNext } from 'vue-router'
import { useAuthStore } from '@/stores/auth'

export function setupRouterGuard(router: Router) {
  router.beforeEach((to, from, next: NavigationGuardNext) => {
    const auth = useAuthStore()
    const requiresAuth = to.meta.requiresAuth ?? false
    const roles = to.meta.roles

    if (requiresAuth && !auth.isLoggedIn) {
      return next({ name: 'Login', query: { redirect: to.fullPath } })
    }

    if (roles && !roles.some(r => r === auth.user?.role)) {
      return next({ name: '403' })
    }

    return next()
  })
}

6.2 编程式导航类型约束

当业务需要频繁进行命名路由跳转时,应扩展 RouteNamedMap(或通过 vue-router/auto 配合 unplugin-vue-router)来让 router.push 的参数接收路径级别的类型检查,防止拼写错误导致运行时静默失败。

// 基础做法:对路由名称做字面量类型映射
type AppRouteNames = 'Home' | 'Dashboard' | 'Login' | 'Profile'

function navigateTo(name: AppRouteNames, params?: Record<string, string>) {
  return router.push({ name, params })
}

七、依赖注入 Provide / Inject 类型

7.1 InjectionKey 的规范定义

Vue 的 provide / inject 默认情况下类型退化为 unknown。使用 InjectionKey<T> 可以为跨层级通信提供编译时安全保障。推荐将所有的 InjectionKey 集中放在 keys.ts 中,并搭配 Symbol 生成唯一标识。这样组件拆分、测试迁移、以及 IDE 跳转都会更加清晰。

// keys.ts
import type { InjectionKey, Ref } from 'vue'

export interface TableContext {
  selectedIds: Ref<string[]>
  toggleSelect: (id: string) => void
  isSelected: (id: string) => boolean
}

export const TableKey: InjectionKey<TableContext> = Symbol('table-context')

在祖先组件通过 provide 注册,子组件通过 inject 消费。配合默认值与工厂函数,可以避免子组件在非注入环境中报错:

<script setup lang="ts">
// 祖先组件 Table.vue
import { provide, ref } from 'vue'
import { TableKey } from './keys'

const selectedIds = ref<string[]>([])

function toggleSelect(id: string) {
  const set = new Set(selectedIds.value)
  if (set.has(id)) set.delete(id)
  else set.add(id)
  selectedIds.value = Array.from(set)
}

function isSelected(id: string) {
  return selectedIds.value.includes(id)
}

provide(TableKey, { selectedIds, toggleSelect, isSelected })
</script>
<script setup lang="ts">
// 后代组件 TableRow.vue
import { inject } from 'vue'
import { TableKey } from './keys'

const table = inject(TableKey)

if (!table) {
  throw new Error('TableRow must be used inside a Table')
}

const { toggleSelect, isSelected } = table
</script>

八、Composables 类型最佳实践

8.1 选项对象模式(Parameters Object Pattern)

当 Composable 的参数超过三个时,推荐使用选项对象模式。这不仅让调用语义更清晰,也便于通过接口扩展后续参数而不破坏向后兼容性。

// composables/useAsyncFetch.ts
import { ref, watchEffect, type Ref } from 'vue'

interface UseAsyncFetchOptions<T> {
  url: string | Ref<string>
  method?: 'GET' | 'POST'
  immediate?: boolean
  transform?: (raw: unknown) => T
}

interface UseAsyncFetchReturn<T> {
  data: Ref<T | null>
  loading: Ref<boolean>
  error: Ref<Error | null>
  refresh: () => Promise<void>
}

export function useAsyncFetch<T>(
  options: UseAsyncFetchOptions<T>
): UseAsyncFetchReturn<T> {
  const data = ref<T | null>(null) as Ref<T | null>
  const loading = ref(false)
  const error = ref<Error | null>(null)

  async function refresh() {
    loading.value = true
    error.value = null
    try {
      const res = await fetch(typeof options.url === 'string' ? options.url : options.url.value)
      const json = await res.json()
      data.value = options.transform ? options.transform(json) : json
    } catch (e) {
      error.value = e instanceof Error ? e : new Error(String(e))
    } finally {
      loading.value = false
    }
  }

  if (options.immediate !== false) {
    watchEffect(() => refresh())
  }

  return { data, loading, error, refresh }
}

8.2 泛型 Composable 与约束条件

泛型 Composable 可以将响应式逻辑与业务类型彻底解耦。通过 extends SomeConstraint 可以对泛型施加边界,确保传入的类型至少包含必要字段。

interface WithId {
  id: string | number
}

export function useSelection<T extends WithId>(items: Ref<T[]>) {
  const selected = ref<T[]>([])

  function toggle(item: T) {
    const idx = selected.value.findIndex(s => s.id === item.id)
    if (idx >= 0) {
      selected.value = selected.value.filter((_, i) => i !== idx)
    } else {
      selected.value = [...selected.value, item]
    }
  }

  function isSelected(item: T) {
    return selected.value.some(s => s.id === item.id)
  }

  return { selected, toggle, isSelected }
}

九、复杂组件模式:泛型组件 + 插槽类型

9.1 泛型组件(GenericComponent)

Vue 3.3 引入了 generic 属性,允许在单文件组件中声明泛型参数。这对表格、列表、下拉选择框等高复用组件至关重要。启用泛型后,defineProps 中的引用可以关联到泛型变量,实现行数据类型的精确透传。

<script setup lang="ts" generic="T extends { id: string | number }">
// components/GenericList.vue
interface Props {
  items: T[]
  keyProp?: keyof T
}

const props = withDefaults(defineProps<Props>(), {
  keyProp: 'id',
})

const emit = defineEmits<{
  clickItem: [item: T]
}>()
</script>

<template>
  <ul>
    <li
      v-for="item in items"
      :key="String(item[props.keyProp])"
      @click="emit('clickItem', item)"
    >
      <slot :item="item" />
    </li>
  </ul>
</template>

9.2 SlotsType 与插槽作用域类型

当组件内部定义了多个命名插槽且作用域参数各不相同时,通过 defineSlots 为每个插槽声明准确的类型映射,使父组件在 v-slot 作用域中自动获得类型提示。

<script setup lang="ts">
import { defineSlots } from 'vue'

interface User {
  name: string
  email: string
}

const slots = defineSlots<{
  default: (props: { user: User }) => any
  header: (props: { title: string }) => any
  footer: () => any
}>()
</script>

<template>
  <div>
    <div class="header">
      <slot name="header" :title="'详情页'" />
    </div>
    <div class="body">
      <slot :user="{ name: 'Alice', email: 'a@example.com' }" />
    </div>
    <div class="footer">
      <slot name="footer" />
    </div>
  </div>
</template>

十、Vite + Vue 插件类型扩展

10.1 import.meta.env 类型声明

Vite 通过 import.meta.env 暴露环境变量,但 TypeScript 默认不识别这些自定义字段。应创建 env.d.ts 扩展 ImportMetaEnv 接口,这样访问 import.meta.env.VITE_API_BASE 时不会报错,且能获得字符串类型。

// env.d.ts
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_API_BASE: string
  readonly VITE_APP_TITLE: string
  readonly VITE_ENABLE_MOCK?: string
}

interface ImportMeta {
  readonly env: ImportMetaEnv
}

10.2 自定义模块声明与全局属性扩展

当项目中存在非 JavaScript 模块的导入(如 .svg、.css?inline 等)时,需要补充模块声明。此外,若通过插件在全局 app.config.globalProperties 上挂载工具,应同时扩展 @vue/runtime-core 的 ComponentCustomProperties:

// shims.d.ts
declare module '*.svg' {
  const src: string
  export default src
}

declare module '*.vue' {
  import type { DefineComponent } from 'vue'
  const component: DefineComponent<{}, {}, any>
  export default component
}

import type { MessageApi } from '@/plugins/message'

declare module '@vue/runtime-core' {
  interface ComponentCustomProperties {
    $message: MessageApi
  }
}

十一、Volar takeover 模式与 TS Vue Plugin 性能对比

11.1 takeover 模式的工作机制

Volar 提供的 takeover 模式会将 VS Code 内置的 TypeScript 语言服务完全替换为 Volar 驱动的版本。这样做的好处是:单文件组件的模板表达式也能享受到完整的类型检查,而不仅仅局限在 <script> 区块。激活方式是开启 VS Code 设置 vue.takeOverMode.enabled: true,并在项目工作区禁用内置的 TypeScript 插件。

takeover 模式的类型检查延迟通常比传统 Vue DX 插件路线更低,因为 Volar 基于 Vue 的编译器 API 直接分析 SFC,不再依赖双 TS Server 架构。

11.2 TypeScript Vue Plugin 的兼容性场景

在以下场景中仍然可能回退到 TypeScript Vue Plugin(即 Volar 的 Legacy Bridge)模式:

  1. 项目使用 Nuxt 2 或 Vue 2 + @vue/composition-api,Volar 的 takeover 识别不够完善;
  2. 团队成员的 VS Code 版本过低,无法运行 Volar 2.x 的 Language Server;
  3. 存在大量基于 defineComponent 的深层嵌套类型推断,需要原生 TS Server 的调试能力。

11.3 推荐迁移路径

对于 Vue 3 + TypeScript 5.x 的新生项目,建议直接打开 takeover 模式,并在 CI 中运行 vue-tsc --noEmit,确保模板层面的类型错误不会被遗漏。vue-tsc 底层基于 tsc 封装,其输出与 Volar 编辑器内的诊断保持一致,实现开发时与构建时的类型检查同源。

# package.json scripts
{
  "scripts": {
    "type-check": "vue-tsc --noEmit --skipLibCheck"
  }
}

常见问题(FAQ)

<script setup lang="ts"> 报错找不到模块?

确保 env.d.ts 或 shims-vue.d.ts 中有基础模块声明,为 .vue 文件提供 DefineComponent 的回退类型:

declare module '*.vue' {
  import type { DefineComponent } from 'vue'
  const component: DefineComponent<{}, {}, any>
  export default component
}

同时确认 tsconfig.json 的 include 字段覆盖了 **/*.vue 文件。若使用了路径别名,还需保证 paths 与 baseUrl 配置同步。

Props 解构如何保持响应性?

Vue 3.4 引入了编译时响应式 Props 解构。在 <script setup> 中直接解构 defineProps 的返回值会被编译器自动转换为 toRef 调用,因此访问时仍然保持响应式。

<script setup lang="ts">
const { title, count = 0 } = defineProps<Props>()
// title 和 count 是响应式的(编译时转换)
</script>

但需要注意:在低于 3.4 的版本,或者 toRef 未生效的场景,仍需使用 const props = defineProps<Props>() 然后通过 props.title 访问,或者显式调用 toRef(props, 'title')。

ref 声明对象时为什么不选 reactive?

ref 更适合那些需要整体替换的对象或原始值;reactive 更适合持续修改字段的表单状态。若将 reactive 中的引用解构到局部变量,会丢失响应式连接,而 ref 的值通过 .value 访问,解构后仍然安全。


相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「frontend」更多文章

  1. GraphQL BFF 与微前端:多前端团队的 Schema 分片与协作模式
  2. 前端 GraphQL 客户端集成:Apollo Client、Relay 与 urql 选型
  3. 前端性能调试实战:从首屏加载到运行时瓶颈