第 25 章

TypeScript 集成:泛型编译宏、类型推断链与工程化类型体系

Vue 3 与 TypeScript 的集成是一个编译时协议:defineProps<T>() 的类型参数不是在运行时读取的,而是由 @vue/compiler-sfc 在构建阶段提取,生成运行时 props 配置。整套类型推断链从 .vue 文件的 <script setup> 一路延伸到模板、父组件、测试文件——这是一个静态分析系统,而不是运行时系统。理解这个边界,你就理解了为什么类型声明模式的 defineProps 没有 validator,以及 Volar 为什么必须替代 Vetur。

Level 1 · 你需要知道的(1-3年经验)

defineProps() 的类型推断

// 类型声明方式:TypeScript 接口 → 运行时 props 配置
interface Props {
  title: string           // → required: true, type: String
  count?: number          // → required: false, type: Number
  items?: string[]        // → required: false, type: Array
  callback?: () => void   // → required: false, type: Function
  config?: {              // → required: false, type: Object
    theme: 'light' | 'dark'
  }
}

const props = defineProps<Props>()
// props.title     类型:string
// props.count     类型:number | undefined
// props.items     类型:string[] | undefined

编译器从接口提取:

withDefaults 消除 undefined

const props = withDefaults(defineProps<Props>(), {
  count: 0,
  items: () => [],
  config: () => ({ theme: 'light' })
})

// 有默认值的字段,undefined 被消除:
// props.count     类型:number(不再是 number | undefined)
// props.items     类型:string[]
// props.config    类型:{ theme: 'light' | 'dark' }
// props.title     类型:string(无默认值,required,无变化)

defineEmits 的类型精确性

// 函数签名形式(旧式,仍然支持)
const emit = defineEmits<{
  (e: 'change', value: string): void
  (e: 'update:modelValue', value: number): void
}>()

// 具名元组形式(Vue 3.3+,推荐)
const emit = defineEmits<{
  change: [newVal: string, oldVal: string]
  submit: [data: FormData]
  'update:modelValue': [value: number]
  close: []
}>()

// 使用时:参数类型有完整提示
emit('change', 'new', 'old') // ✅ 类型正确
emit('change', 123)           // ❌ 编译错误:number 不兼容 string

InjectionKey:Symbol 的类型信息携带

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

interface User { id: number; name: string; role: string }
interface Theme { color: string; mode: 'light' | 'dark' }

export const userKey: InjectionKey<Ref<User>> = Symbol('user')
export const themeKey: InjectionKey<Theme> = Symbol('theme')

// provide 侧:TypeScript 强制校验值类型
provide(userKey, ref<User>({ id: 1, name: 'Alice', role: 'admin' }))
// provide(userKey, 'wrong type') // ❌ 编译错误

// inject 侧:自动推断返回类型
const user = inject(userKey)       // Ref<User> | undefined
const theme = inject(themeKey, { color: '#fff', mode: 'light' as const })
// theme 类型:Theme(默认值消除了 undefined)

组件实例类型与 templateRef

// ChildComponent.vue
<script setup>
const count = ref(0)
defineExpose({ count, reset: () => { count.value = 0 } })
</script>

// ParentComponent.vue
import ChildComponent from './ChildComponent.vue'

// InstanceType<typeof ChildComponent>:获取组件暴露的类型
const childRef = ref<InstanceType<typeof ChildComponent>>()

onMounted(() => {
  childRef.value?.count   // Ref<number>
  childRef.value?.reset() // () => void
})

Level 2 · 它是怎么运行的(3-5年经验)

类型推断链的完整路径

.vue 文件(<script setup>)
        │
        ▼
Volar(VS Code 插件)
  解析 <script setup> AST
  从 defineProps<T>() 提取 Props 类型
  生成虚拟类型声明文件(.d.ts)
        │
        ▼
模板类型检查
  模板 AST → TypeScript 代码(虚拟 render 函数)
  检查模板中变量类型是否与 props/emits 匹配
        │
        ▼
父组件中使用子组件
  通过虚拟 .d.ts 推断:
  - 传给子组件的 props 类型
  - 子组件 emit 的事件类型
        │
        ▼
vue-tsc(CI 类型检查)
  在构建/CI 中对 .vue 文件执行全量类型检查
  与 Volar 使用同一套推断逻辑

defineProps 类型提取:编译器如何处理接口

defineProps<Props>() 中的 Props 是一个 TypeScript 接口时,@vue/compiler-sfc 需要解析这个类型并提取运行时信息:

// 编译输入
interface Props {
  title: string
  count?: number
  items?: string[]
}
const props = defineProps<Props>()

// 编译器分析过程:
// 1. 识别 defineProps<Props>() 调用
// 2. 解析泛型参数 Props → 找到接口定义(同文件或 import)
// 3. 遍历接口成员:
//    title: string   → { type: String, required: true }
//    count?: number  → { type: Number, required: false }
//    items?: string[]→ { type: Array, required: false }
// 4. 生成运行时 props 对象

// 编译输出
export default {
  props: {
    title: { type: String, required: true },
    count: { type: Number, required: false },
    items: { type: Array, required: false }
  },
  setup(props) { /* ... */ }
}

重要限制:编译器只能从类型信息提取 required 和基础类型。无法提取:

PropType:Options API 中的复杂类型

在 Options API 或运行时声明模式下,需要 PropType<T> 来为复杂类型提供类型信息:

import type { PropType } from 'vue'

interface UserConfig {
  name: string
  permissions: string[]
  metadata: Record<string, unknown>
}

defineProps({
  // 基础类型:直接用构造函数
  id: Number,
  name: String,
  
  // 复杂类型:必须用 PropType<T>
  config: {
    type: Object as PropType<UserConfig>,
    required: true
  },
  
  // 数组元素类型
  tags: {
    type: Array as PropType<string[]>,
    default: () => []
  },
  
  // 函数类型
  onClick: {
    type: Function as PropType<(event: MouseEvent) => void>
  },
  
  // 联合类型(多个构造函数)
  value: {
    type: [String, Number] as PropType<string | number>
  }
})

组件类型的完整推断:defineExpose 与 InstanceType

子组件的类型推断链:

子组件 defineExpose({ count, reset })
        │ Volar 分析 defineExpose 参数
        ▼
虚拟类型声明:
export interface ChildComponentExposed {
  count: Ref<number>
  reset: () => void
}

        │ 父组件 import 子组件
        ▼
typeof ChildComponent → ComponentPublicInstance & ChildComponentExposed

        │ ref<InstanceType<typeof ChildComponent>>()
        ▼
childRef.value 类型:ComponentPublicInstance & ChildComponentExposed | undefined

        │ childRef.value?.count
        ▼
类型:Ref<number> | undefined

vue-tsc:CLI 类型检查工具

# 安装
npm install -D vue-tsc typescript

# 检查整个项目
npx vue-tsc --noEmit

# CI 集成(package.json)
{
  "scripts": {
    "type-check": "vue-tsc --noEmit",
    "build": "vue-tsc --noEmit && vite build"
  }
}

vue-tsctsc 的包装,内置 Volar 的插件,能理解 .vue 文件。它与 Volar 使用同一套类型推断逻辑,因此本地开发和 CI 的类型检查结果一致。

tsconfig.json 的 vueCompilerOptions 配置

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "jsx": "preserve",
    "lib": ["ESNext", "DOM"],
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  },
  "vueCompilerOptions": {
    "target": 3.3,
    "strictTemplates": true,  // 模板中严格类型检查
    "experimentalRfc436": true // Reactive Props Destructure
  },
  "include": ["src/**/*", "env.d.ts"]
}

类型检查的覆盖范围对比图

纯 tsc(无 Volar 插件):
  ✓ .ts 文件类型检查
  ✗ .vue 文件中的 <script setup>
  ✗ 模板类型检查
  ✗ 组件 props 类型检查

vue-tsc(内置 Volar 语言服务):
  ✓ .ts 文件类型检查
  ✓ .vue <script setup> 类型检查
  ✓ 模板表达式类型检查
  ✓ 组件 props/emits 类型校验
  ✓ defineExpose 暴露类型追踪
  ✓ 跨组件类型推断

Level 3 · 设计文档与源码(资深开发者)

Volar 的架构:语言服务插件

Volar(@volar/vue-language-plugin)是一个 TypeScript Language Service 插件:

TypeScript Language Service(tsc 核心)
        │ 插件扩展点
        ▼
Volar Vue Language Plugin
  ├─ 解析 .vue 文件
  │   ├─ <script setup> → 虚拟 .ts 文件
  │   ├─ <template> → 虚拟 render 函数 .ts 文件
  │   └─ <style> → (忽略,不参与类型检查)
  │
  ├─ 定义转换规则:
  │   ├─ defineProps<T>() → 展开为 props 参数类型
  │   ├─ defineEmits<T>() → 展开为 emit 函数类型
  │   └─ defineExpose({}) → 生成组件暴露类型
  │
  └─ 提供给 TypeScript 引擎
      ├─ 悬停类型信息(hover)
      ├─ 自动补全
      ├─ 类型错误诊断
      └─ 跳转定义(go to definition)

虚拟文件生成:Volar 如何让 TS 理解 .vue

Volar 将 .vue 文件"翻译"成 TypeScript 能理解的虚拟文件:

// 原始 .vue 文件(MyComp.vue)
// <script setup lang="ts">
// const props = defineProps<{ title: string }>()
// const emit = defineEmits<{ change: [value: string] }>()
// </script>

// Volar 生成的虚拟类型声明(简化)
// MyComp.vue.d.ts(虚拟)
declare const _default: import('vue').DefineComponent<
  { title: { type: StringConstructor; required: true } },  // props
  {},      // setup return
  {},      // data
  {},      // computed
  {},      // methods
  {},      // ComponentOptionsMixin
  import('vue').EmitsOptions,
  'change', // emits
  string    // emit payload
>
export default _default

这个虚拟声明让父组件在 TypeScript 类型检查时能看到子组件的完整 props 和 emits 类型。

模板的类型检查实现

Volar 将模板 AST 转换成虚拟 TypeScript 代码来进行类型检查:

<!-- 模板 -->
<template>
  <div>{{ count.toFixed(2) }}</div>
  <Child :title="123" />  <!-- 类型错误:number 不兼容 string -->
</template>
// Volar 生成的虚拟 render 函数(用于类型检查)
function __VLS_render() {
  const count: number = /* 从 setup 推断 */
  count.toFixed(2) // TS 检查:number 有 toFixed,OK
  
  const __VLS_Child = resolveComponent('Child')
  // 检查 props 类型:
  // title 期望 string,但传入 123 (number) → 类型错误
  __VLS_Child({ title: 123 }) // TS 报错
}

defineModel 宏的类型推断(Vue 3.4+)

// <script setup> 中
const modelValue = defineModel<string>()
// 推断:modelValue 是 Ref<string>
// 编译输出:props: { modelValue: String }, emits: ['update:modelValue']

// 具名 model
const checked = defineModel<boolean>('checked')
// props: { checked: Boolean }, emits: ['update:checked']

// 带默认值和修饰符
const count = defineModel<number>({ default: 0 })
// props: { modelValue: { type: Number, default: 0 } }

模板中 $refs 的类型

// 在模板中使用 ref
// <template>
//   <input ref="inputRef" />
//   <Child ref="childRef" />
// </template>

// <script setup>
const inputRef = ref<HTMLInputElement>()
const childRef = ref<InstanceType<typeof Child>>()

// Volar 特殊处理:
// ref="inputRef" 中的 "inputRef" 与 const inputRef 建立关联
// 类型推断:inputRef.value 是 HTMLInputElement | undefined

Volar vs Vetur:为什么必须切换

特性 Vetur(Vue 2 时代) Volar(Vue 3)
类型检查引擎 独立实现,与 tsc 不同步 TypeScript Language Service 插件,与 tsc 完全同步
模板类型检查 有限支持 完整支持,与 setup 类型联动
<script setup> 不支持 完整支持
defineProps 类型推断 不支持 支持,含跨文件引用
性能 单进程 多进程,独立的语言服务器
.d.ts 生成 不支持 支持(vue-tsc)

Take Over Mode(接管模式):Volar 可以完全接管 TypeScript 语言服务,禁用内置的 VS Code TypeScript 支持,提供更一致的开发体验:

在 VS Code 命令面板输入 Extensions: Show Built-in Extensions,找到 TypeScript and JavaScript Language Features,选择 Disable (Workspace)。这样所有 TypeScript 和 Vue 文件都由 Volar 的语言服务处理。


Level 4 · 边界与陷阱(全体适用)

陷阱 1:类型声明 defineProps 无法引用外部文件的类型(Vue 3.3 之前)

// ❌ Vue 3.3 之前:类型只能在同文件定义,无法 import
import type { User } from './types'

// 报错:defineProps<T> only accepts type literals or local reference
const props = defineProps<{ user: User }>()

// ✅ 解决方案一(Vue 3.3 之前):在同文件内定义
interface User { id: number; name: string }
const props = defineProps<{ user: User }>()

// ✅ 解决方案二(Vue 3.3+):支持外部类型引用
import type { User } from './types'
const props = defineProps<{ user: User }>() // Vue 3.3+ 支持

Vue 3.3 引入了对外部类型引用的支持,解决了这个长期痛点。

陷阱 2:InstanceType 的类型不包含 defineExpose 的内容

// 常见误解:InstanceType<typeof ChildComponent> 自动包含 defineExpose 的内容
// 实际上:InstanceType 获取的是组件类型,Volar 在类型层面将 expose 的内容合并进去

// 只有通过 Volar 的类型推断才能正确获得 expose 类型
// 直接用 TS 的 InstanceType 在非 Volar 环境下可能不包含 expose 内容

// ✅ 正确方式:始终通过 ref + InstanceType 的组合
const childRef = ref<InstanceType<typeof ChildComponent>>()
// 在 Volar 环境中,这能正确推断 expose 的内容

陷阱 3:PropType 的类型断言不阻止运行时类型不匹配

// ❌ PropType<T> 只是类型层面的断言,不影响运行时
const props = defineProps({
  user: {
    type: Object as PropType<{ id: number; name: string }>,
  }
})

// 父组件传入::user="{ id: '123', name: 'Alice' }" (id 是字符串)
// TypeScript 会报错(类型不匹配)
// 但如果绕过 TypeScript(any 断言、JS 文件):运行时不会检查,props.user.id 是字符串

// ✅ 如果需要运行时类型安全:使用 validator
const props = defineProps({
  user: {
    type: Object as PropType<User>,
    validator: (v: unknown): v is User => {
      return typeof (v as User).id === 'number'
    }
  }
})

陷阱 4:reactive 对象在模板中的深层类型推断

// 模板中访问深层属性时,类型可能丢失
const state = reactive({
  user: {
    address: {
      city: 'Beijing'
    }
  }
})

// Volar 通常能正确推断
// state.user.address.city → string

// 但某些复杂情形(循环引用、条件类型)可能导致类型推断退化为 any
// 解决方案:显式标注类型
interface AppState {
  user: {
    address: {
      city: string
    }
  }
}
const state = reactive<AppState>({ /* ... */ })

陷阱 5:emits 类型不影响模板中的事件绑定类型检查

// defineEmits 定义了精确的 change 类型
const emit = defineEmits<{ change: [value: string] }>()

// 但在父组件模板中:
// <Child @change="handler" />
// handler 的类型推断取决于 Volar 的模板分析
// 在某些版本中,模板事件处理器的参数类型可能不被严格检查

// ✅ 明确标注事件处理器类型
function handleChange(value: string) { /* ... */ }
// <Child @change="handleChange" />

陷阱 6:vue-tsc 与 tsc 的版本兼容性

# vue-tsc 有自己对 typescript 版本的要求
# 版本不匹配会导致类型检查失败

# ✅ 确保兼容版本
npm install -D vue-tsc@latest [email protected]

# 检查版本兼容矩阵(vue-tsc 的 README 有说明)
npx vue-tsc --version

# 常见问题:tsconfig 的 moduleResolution 设置
# Vue 3 推荐使用 "bundler"(Vite 使用)
{
  "compilerOptions": {
    "moduleResolution": "bundler", // 而不是 "node" 或 "node16"
  }
}

章节小结

  1. Vue 3 + TypeScript 是编译时协议defineProps<T>()defineEmits<T>()InjectionKey<T> 的类型参数在构建时由 @vue/compiler-sfc 和 Volar 处理,在运行时不存在。这套系统的核心价值是将类型错误从运行时提前到编译期,但其精确度受限于编译器能从类型信息中提取的内容(无法生成 validator)。

  2. Volar 是类型推断链的基础设施:它通过 TypeScript Language Service 插件机制,将 .vue 文件翻译成 TS 能理解的虚拟文件,使模板类型检查、跨组件类型推断、defineExpose 类型追踪成为可能。没有 Volar,.vue 文件对 TypeScript 来说是黑盒。

  3. 类型声明 vs 运行时声明各有适用场景:类型声明(defineProps<T>())提供最佳 TypeScript 体验,但无 validator;运行时声明提供完整校验,但类型推断较弱。大型应用通常结合使用:核心数据用运行时校验 + 类型注解,UI 配置用纯类型声明。

  4. InstanceType<typeof Component> + defineExpose 是组件间类型安全通信的标准模式:父组件通过 ref<InstanceType<typeof ChildComponent>>() 获得强类型的组件引用,通过 defineExpose 控制对外暴露的接口——这实现了真正的组件封装与类型安全。

  5. vue-tsc --noEmit 应纳入 CI 流水线:本地的 Volar 实时类型检查只在编辑器中有效;CI 环境需要 vue-tsc 在构建前执行全量类型检查,确保任何 .vue 文件的类型错误都不会溜进生产代码。

本章评分
4.7  / 5  (5 评分)

💬 留言讨论