前端开发··2 阅读·预计 10 分钟

用 TypeScript 类型系统为 Vue 3 组件建立编译时 API 契约

一、问题:为什么运行时 PropTypes 已经不够用了

Vue 2 时代我们这样定义 Props:

// Vue 2 Options API
props: {
  userId: { type: Number, required: true },
  role: { type: String, default: 'viewer' }
}

这在大型项目中有两个致命问题:

  1. 父组件传错类型时只在控制台 warning,CI 不会红
  2. 重构字段名后,所有引用处的类型变成 any,IDE 哑火

Vue 3 + <script setup lang="ts"> 的组合提供了新答案:把类型安全前置到编译期


二、Props:从 Runtime 校验到泛型约束

❌ 错误示范:丢失了类型约束

// Bad: 用 Vue 2 的习惯写 Vue 3
const props = defineProps({
  items: Array,     // 类型推断为 any[]
  config: Object    // 类型推断为 Record<string, any>
})

props.items.forEach(item => {
  item.name  // ❌ 没有智能提示,重构时不会报错
})

✅ 正确示范:泛型 + interface

interface Item {
  id: number
  name: string
  status: 'active' | 'archived'
}

interface ListConfig {
  pageSize: number
  sortBy: 'name' | 'createdAt'
}

const props = defineProps<{
  items: Item[]
  config?: ListConfig
  onSelect?: (item: Item) => void   // 函数签名也可约束
}>()

// 现在 IDE 会给出完整的字段补全
props.items.forEach(item => {
  item.status === 'archived' // ✅ 'active' | 'archived' 自动补全
})

当父组件传 items=[{ id: '1' }] 时,编译直接报错而非运行时报 warning。

withDefaults 处理可选默认值

const props = withDefaults(defineProps<{
  items: Item[]
  config?: ListConfig
}>(), {
  config: () => ({ pageSize: 20, sortBy: 'createdAt' })
})

// config 被自动推断为不可为 undefined
console.log(props.config.pageSize) // ✅ 类型安全

三、Emits:强类型事件签名

Vue 3.3+ 的 defineEmits 支持字面量函数签名:

❌ 无类型约束

const emit = defineEmits(['update', 'delete'])
emit('update', 'wrong-payload')  // ❌ 第二个参数没有类型检查

✅ 完整签名约束

const emit = defineEmits<{
  (e: 'update:item', item: Item): void
  (e: 'delete', id: number): void
  (e: 'batch-archive', ids: number[], reason: string): void
}>()

emit('delete', 'abc')          // ❌ Argument of type 'string' is not assignable to 'number'
emit('batch-archive', [1, 2])  // ❌ 缺少 reason 参数
emit('batch-archive', [1, 2], '业务下线') // ✅

在父组件通过 v-on 使用时事件处理器参数也会得到精确推导:

<ItemList @delete="(id) => { /* id 类型自动推断为 number */ }" />

四、Template Refs:类型安全的 DOM/组件引用

import { ref } from 'vue'
import MyModal from './MyModal.vue'

// 元素 ref
const inputRef = ref<HTMLInputElement | null>(null)

// 子组件 ref
const modalRef = ref<InstanceType<typeof MyModal> | null>(null)

function focusInput() {
  inputRef.value?.focus()  // ✅ 类型安全
}

function openModal() {
  modalRef.value?.open()   // ✅ 自动推导 MyModal 暴露的方法
}

坑点提示ref<HTMLInputElement>() 不加 | null 初始值会导致类型报错,必须显式标明 null 联合。


五、Provide / Inject 的类型桥梁

祖孙组件传值最怕字符串 key 拼写错误。用 InjectionKey<T> 彻底解决:

// types.ts —— 集中管理注入 Token
import type { InjectionKey, Ref } from 'vue'

export interface UserSession {
  id: number
  name: string
  permissions: string[]
}

export const USER_KEY: InjectionKey<Ref<UserSession>> = Symbol('user')
export const THEME_KEY: InjectionKey<'light' | 'dark'> = Symbol('theme')
// 祖先组件
import { provide, ref } from 'vue'
import { USER_KEY } from './types'

const session = ref<UserSession>({
  id: 1, name: 'Alex', permissions: ['read', 'write']
})
provide(USER_KEY, session)  // ✅ 类型匹配
// 子组件
import { inject } from 'vue'
import { USER_KEY } from './types'

const session = inject(USER_KEY)
// session 类型自动推导为 Ref<UserSession> | undefined

如果用非 Symbol key 的魔法字符串,类型会丢失:

// Bad
provide('user', { name: 'Alex' })
const user = inject('user') // ❌ unknown

六、将类型约束辐射到整个组件树

每个组件导出 Props/Emits 类型,业务层组装:

// ItemList.vue —— 暴露类型
export type ItemListProps = InstanceType<typeof import('./ItemList.vue')>['$props']
export type ItemListEmits = InstanceType<typeof import('./ItemList.vue')>['$emit']

在组合组件中复用:

// Dashboard.vue
import type ItemList from './ItemList.vue'

const listProps = computed<InstanceType<typeof ItemList>['$props']>(() => ({
  items: filteredItems.value,
  config: { pageSize: 10, sortBy: 'name' }
}))

重构 ItemList 的 Props 时,Dashboard 相关的编译错误会立刻全量暴露。


七、小结

Vue 3 的 Composition API 提供了类型安全的全部基础设施,关键是用好它

场景方案开销
PropsdefineProps<T>() + withDefaults零运行时
EmitsdefineEmits<{...}>() 字面量签名零运行时
Template Refsref<HTMLElement | null>(null)零运行时
跨级注入InjectionKey<T> + Symbol零运行时
类型导出InstanceType<typeof Component>零运行时

全部零运行时开销,全部在编译期绑定。比依赖测试用例抓 bug 更可靠——因为代码根本过不了 tsc

0 评论

评论区

登录 后参与评论