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)模式:
- 项目使用 Nuxt 2 或 Vue 2 +
@vue/composition-api,Volar 的 takeover 识别不够完善; - 团队成员的 VS Code 版本过低,无法运行 Volar 2.x 的 Language Server;
- 存在大量基于
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 访问,解构后仍然安全。
相关阅读
- Vue 详解 — 核心概念
- Vue Composition API 完全指南 — TypeScript 模式
- React + TypeScript 实战指南 — 跨框架对比
- Zod 官方文档
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。