Vue 基础体系 · 第 13/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。
Vue 与 TypeScript:组件类型、泛型、模板检查和 API 契约
在 Vue 3 项目中,TypeScript 的价值不只是给变量补充类型。更重要的是,它可以把组件之间的数据流表达成可检查的契约:
- 父组件传给子组件的 Props 是否完整、类型是否正确;
- 子组件触发的 Emits 是否使用了正确的事件名和参数;
v-model的值与更新事件是否匹配;- 插槽参数、模板引用和组件公开方法是否有明确类型;
- 一个泛型组件是否能在不同数据类型下保持类型安全;
- 模板中的表达式是否真的经过 TypeScript 检查;
- 来自 HTTP、存储或第三方库的运行时数据是否被错误地“假设”为某种类型。
Vue 的模板最终会被编译成 JavaScript。TypeScript 负责静态检查,而不是运行时校验。要建立可靠的组件 API,必须同时理解这两个边界:
外部运行时数据
│
├─ JSON 解析、表单输入、用户操作、URL 参数
│
▼
运行时校验与转换
│
▼
应用状态
│
▼
组件 Props / Emits / Slots / v-model
│
▼
模板类型检查
│
▼
编译后的 JavaScript
如果只写 TypeScript 类型而不做运行时校验,类型可能从一开始就是错误的;如果只依赖运行时行为而没有模板检查,组件重构时又容易出现大量隐蔽错误。
一、先明确 TypeScript 在 Vue 中检查什么
TypeScript 的类型系统主要在编译期间工作。下面的代码中,User 和 user 的类型会被 TypeScript 使用,但类型本身不会出现在最终 JavaScript 中:
interface User {
id: number
name: string
}
const user: User = {
id: 1,
name: 'Ada',
}
编译后运行时并不会自动检查:
user.name = 123
如果这段代码绕过了 TypeScript,或者数据来自 JSON,JavaScript 仍然可能运行到这里。TypeScript 类型是静态约束,不是运行时验证器。
在 Vue 中,这个区别尤其重要:
interface User {
id: number
name: string
}
const response = await fetch('/api/user')
const user = await response.json() as User
as User 只是告诉 TypeScript“把这个值当成 User”。它不会确认返回值真的拥有数字类型的 id 和字符串类型的 name。
因此,下面两种情况需要区分:
// 编译时类型断言:不会产生运行时检查
const user = responseData as User
// 运行时检查:需要实际执行验证逻辑
function isUser(value: unknown): value is User {
if (typeof value !== 'object' || value === null) {
return false
}
const record = value as Record<string, unknown>
return (
typeof record.id === 'number' &&
typeof record.name === 'string'
)
}
类型谓词 value is User 的含义是:如果函数返回 true,TypeScript 可以在后续代码中把 value 缩小为 User。
const data: unknown = await response.json()
if (!isUser(data)) {
throw new Error('服务端返回了无效用户数据')
}
console.log(data.name) // data 在这里被缩小为 User
这里的 unknown 比 any 更安全。any 会关闭大部分检查:
const value: any = await response.json()
value.notExist.call() // TypeScript 通常不会阻止
而 unknown 强制调用者先判断:
const value: unknown = await response.json()
// value.notExist.call() // 编译错误
if (typeof value === 'string') {
console.log(value.toUpperCase())
}
组件 API 的类型设计应该尽量避免让 any 穿过边界。一个组件一旦把输入或输出声明成 any,父子组件之间的静态契约就会出现缺口。
二、组件 API 契约是什么
可以把一个 Vue 组件抽象成一个带多个边界的函数:
组件 = 输入 Props
+ 外部事件 Emits
+ 插槽 Slots
+ 双向绑定 v-model
+ 透传属性 Attrs
+ 对外暴露的实例方法
如果只把组件看成“接收 Props、渲染模板”,就会遗漏事件、插槽和实例 API。
更形式化地说,一个组件可以近似表示为:
C : (P, M, S, A) -> (V, E, X)
其中:
P:Props,父组件传入的数据;M:v-model对应的模型值;S:Slots,父组件提供的渲染函数;A:透传属性和监听器;V:组件渲染出的视图;E:组件触发的事件;X:通过模板引用暴露给父组件的实例 API。
所谓 API 契约,就是约束这些输入和输出:
- 父组件传入的 Props 必须满足
P的类型; - 子组件触发事件时,事件名和参数必须属于
E; - 插槽接收的参数必须和
S的声明一致; v-model的值和更新事件必须符合约定;- 父组件只能调用
X中明确暴露的方法; - 未声明的属性是否透传到根节点,必须符合组件实现和使用预期。
这个模型可以帮助解释一个常见问题:某个 Prop 类型写得很严格,并不意味着整个组件 API 已经严格。一个组件可能 Props 很安全,但 Emits 使用了字符串拼写,Slots 没有类型,或者通过 defineExpose 暴露了未声明的内部状态。
三、defineProps:声明组件输入
在 <script setup> 中,defineProps 是编译器宏。它不需要导入,编译后会被 Vue 编译器转换成组件的 Props 配置和对应的访问逻辑。
3.1 类型声明方式
最直接的方式是使用基于 TypeScript 类型的声明:
<script setup lang="ts">
interface User {
id: number
name: string
disabled?: boolean
}
const props = defineProps<{
user: User
dense?: boolean
}>()
console.log(props.user.name)
</script>
<template>
<article :class="{ dense }">
<h2>{{ user.name }}</h2>
<span v-if="user.disabled">不可用</span>
</article>
</template>
这里的含义是:
user是必填 Prop;dense是可选 Prop;user必须符合User;- 在
<script setup>模板中,顶层绑定会自动暴露给模板,因此可以直接使用user和dense。
父组件中的错误会被模板检查工具报告:
<UserCard
:user="{ id: '1', name: 'Ada' }"
:dense="true"
/>
id 声明为 number,但这里传入了 string,因此应当报错。
3.2 运行时声明与类型声明的区别
也可以使用运行时声明:
import { PropType } from 'vue'
const props = defineProps({
user: {
type: Object as PropType<User>,
required: true,
},
dense: {
type: Boolean,
default: false,
},
})
这种方式直接提供了 Vue 运行时可以使用的构造器和校验信息。
两种方式的职责不同:
| 写法 | 主要优势 | 主要边界 |
|---|---|---|
defineProps<Type>() |
TypeScript 类型表达自然,适合复杂类型 | 复杂运行时校验能力有限,类型会被擦除 |
defineProps({ ... }) |
Vue 运行时能获得 required、default、构造器等信息 |
复杂 TypeScript 类型表达更繁琐 |
在现代 Vue 编译器中,基于类型的 Props 声明会尝试从类型中推导部分运行时 Props 信息,但这不是完整的运行时数据验证。尤其是接口、条件类型和外部复杂类型,不能简单理解为会被转换成完整的运行时校验器。
如果数据来源不可信,应该在进入组件之前或组件边界处进行显式校验,而不是依赖 defineProps 替代后端数据验证。
四、可选 Props、默认值与空值语义
“可选”并不总是等同于“有默认值”。
const props = defineProps<{
pageSize?: number
}>()
这里 pageSize 可能是:
number | undefined
如果组件内部要求它始终是数字,就需要提供默认值或显式处理:
const props = withDefaults(
defineProps<{
pageSize?: number
}>(),
{
pageSize: 20,
},
)
props.pageSize // number
withDefaults 的作用是让 TypeScript 知道默认值会填补可选字段。它并不是把传入的非法值转换成合法值。例如:
<Pagination :page-size="'20'" />
字符串并不会因为存在默认值而自动变成数字。
还要区分 undefined 和 null:
const props = defineProps<{
avatarUrl?: string | null
}>()
这里允许三种状态:
- 没传:
undefined; - 明确传空:
null; - 有 URL:
string。
如果业务上只允许“有值或没有值”,可以只选择一种表示方式,避免组件内部同时处理两套空值语义。
五、defineEmits:声明组件输出
Props 是父组件传入的数据,Emits 是子组件向外发送的事件。事件契约至少包括事件名和参数列表。
<script setup lang="ts">
const emit = defineEmits<{
save: [title: string]
cancel: []
delete: [id: number, reason?: string]
}>()
function save() {
emit('save', '新标题')
}
function cancel() {
emit('cancel')
}
</script>
这段代码具有以下约束:
emit('save', '新标题') // 正确
emit('cancel') // 正确
emit('delete', 10) // 正确,reason 可选
// emit('save', 123) // 错误
// emit('cancel', 1) // 错误
// emit('unknown') // 错误
Vue 3.3 及更高版本支持这种基于元组的 Emits 类型写法。较早版本也常见下面的写法:
const emit = defineEmits<{
(event: 'save', title: string): void
(event: 'cancel'): void
}>()
二者表达的是同一类契约;项目应根据实际 Vue 版本选择语法。
父组件监听事件时,模板检查工具也可以检查事件参数:
<Editor
@save="handleSave"
@cancel="handleCancel"
/>
function handleSave(title: string) {
console.log(title.toUpperCase())
}
function handleCancel() {
console.log('cancelled')
}
如果把 handleSave 声明成不接受参数,是否报错还与函数类型兼容规则和模板检查工具的具体处理有关。因此,不应把“回调函数少写了参数”作为完整的事件契约验证。更可靠的错误通常是事件名错误、参数数量错误或参数类型明显不匹配。
Emits 不是事件总线
组件事件只用于子组件向直接父组件报告状态变化:
子组件 emit
↓
直接父组件监听
它不会自动像全局事件总线一样被任意组件接收。跨层级通信应使用明确的状态管理、依赖注入或其他数据流方案,而不是把组件 Emits 当作全局消息系统。
六、v-model 是一组 Props 与 Emits 的组合
组件上的:
<CustomInput v-model="name" />
在默认模型名下,等价于近似:
<CustomInput
:model-value="name"
@update:model-value="name = $event"
/>
因此,组件要支持默认 v-model,通常需要同时声明:
<script setup lang="ts">
const props = defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
function updateValue(event: Event) {
const target = event.target as HTMLInputElement
emit('update:modelValue', target.value)
}
</script>
<template>
<input
:value="modelValue"
@input="updateValue"
>
</template>
父组件:
<script setup lang="ts">
import { ref } from 'vue'
import CustomInput from './CustomInput.vue'
const name = ref('Ada')
</script>
<template>
<CustomInput v-model="name" />
</template>
数据流是:
父组件 name
│
├─ :model-value
▼
子组件 props.modelValue
│
├─ 用户输入
▼
emit('update:modelValue', newValue)
│
▼
父组件 name 更新
Vue 3.4 引入了 defineModel 宏,可以简化这个模式:
<script setup lang="ts">
const model = defineModel<string>()
</script>
<template>
<input v-model="model">
</template>
defineModel 是版本敏感能力。使用它前,应确认项目 Vue 版本和工具链支持情况。为了兼容旧版本,显式声明 modelValue 和 update:modelValue 仍然是稳定、清晰的写法。
命名模型:
<FilterPanel v-model:keyword="keyword" />
对应的组件契约近似为:
const props = defineProps<{
keyword: string
}>()
const emit = defineEmits<{
'update:keyword': [value: string]
}>()
如果组件只更新了本地变量,却没有发送 update:modelValue,父组件状态就不会改变。模板表面上可能没有错误,但运行时数据流已经断裂。
七、泛型解决什么问题
泛型不是“让类型更复杂”,而是表达多个值之间的类型关系。
普通类型声明只能表达:
function first(value: unknown[]): unknown {
return value[0]
}
调用者无法知道返回值和输入数组元素之间的关系。
泛型可以表达:
function first<T>(value: T[]): T | undefined {
return value[0]
}
推导过程是:
输入:number[]
确定:T = number
返回:number | undefined
输入:User[]
确定:T = User
返回:User | undefined
泛型的核心不是“允许任意类型”,而是“对某个具体类型保持一致的关系”。
7.1 Vue 组件中的泛型
假设要实现一个列表组件:
- 接收
T[]; - 使用
(item: T) => string生成文本; - 选中时发出
T; - 父组件可以使用具体的
User类型接收选中项。
在支持泛型 <script setup> 的 Vue 版本中,可以写成:
<script setup lang="ts" generic="T extends { id: string | number }">
const props = defineProps<{
rows: T[]
label: (row: T) => string
}>()
const emit = defineEmits<{
select: [row: T]
}>()
</script>
<template>
<ul>
<li
v-for="row in rows"
:key="row.id"
>
<button type="button" @click="emit('select', row)">
{{ label(row) }}
</button>
</li>
</ul>
</template>
这里的约束 T extends { id: string | number } 表示:泛型项至少必须拥有可作为键的 id。
父组件:
<script setup lang="ts">
import GenericList from './GenericList.vue'
interface User {
id: number
name: string
email: string
}
const users: User[] = [
{ id: 1, name: 'Ada', email: 'ada@example.com' },
{ id: 2, name: 'Lin', email: 'lin@example.com' },
]
function showUser(user: User) {
console.log(user.email)
}
</script>
<template>
<GenericList
:rows="users"
:label="user => user.name"
@select="showUser"
/>
</template>
理想的类型推导路径是:
rows: User[]
↓
推导 T = User
↓
label: (row: User) => string
↓
select 事件参数: User
这比把组件写成 rows: unknown[] 更有用,因为 select 事件仍然保留了具体元素类型。
7.2 泛型约束的必要性
下面的组件没有约束:
<script setup lang="ts" generic="T">
const props = defineProps<{
rows: T[]
}>()
</script>
<template>
<div v-for="row in rows" :key="row.id">
{{ row }}
</div>
</template>
T 可以是 string、number 或任意对象。模板使用 row.id 没有类型依据,因此这是错误的设计。
有两种修复方向:
一是增加约束:
<script setup lang="ts" generic="T extends { id: string | number }">
二是不要求组件内部知道 ID,而由调用者提供键函数:
<script setup lang="ts" generic="T">
const props = defineProps<{
rows: T[]
getKey: (row: T) => string | number
}>()
</script>
<template>
<div
v-for="row in rows"
:key="getKey(row)"
>
{{ row }}
</div>
</template>
两种设计分别表达不同的业务契约:
- 所有数据类型都保证有
id; - 数据类型没有统一字段,组件通过函数获得键。
不要为了让模板通过检查而随意使用断言:
const id = (row as { id: number }).id
这个断言没有证明 row 真的有 id,只是关闭了当前检查。
7.3 泛型组件的边界
泛型组件适合表达以下关系:
输入项类型 = T
标签函数参数类型 = T
选中事件参数类型 = T
插槽 item 参数类型 = T
但它不是运行时类型系统。组件运行时不会知道 T 是 User 还是 Product,因为 TypeScript 类型会被擦除。
此外,模板中的泛型推导依赖 Vue 语言服务和 vue-tsc 的支持。编辑器能推导,不代表所有手写的 h()、动态组件或复杂包装器也能保持同等精度。将泛型组件经过 defineAsyncComponent、自定义高阶组件或不带类型的动态包装后,类型信息可能丢失。
八、模板检查不是 Vite 默认完成的工作
Vite 的主要职责是开发服务器、模块转换和生产构建。对 TypeScript 文件,Vite 通常进行转译,而不是完整执行 TypeScript 类型检查。生产构建成功不等于类型检查成功。
典型项目可以这样配置:
{
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"typecheck": "vue-tsc --noEmit",
"preview": "vite preview"
}
}
执行:
npm run typecheck
vue-tsc 会读取 .vue 文件中的:
<script setup lang="ts">;- Props 和 Emits;
- 模板表达式;
- 组件属性;
- 事件监听器;
- 部分插槽和模板引用信息。
例如:
<script setup lang="ts">
const count = 1
</script>
<template>
<button @click="count.toUpperCase()">
{{ count }}
</button>
</template>
count 是 number,模板中调用 toUpperCase() 应当被报告为错误。
如果只执行:
vite build
构建过程可能仍然成功,因为这一步不一定执行完整的 TypeScript 语义检查。要把类型检查放入 CI,至少应运行:
npm run typecheck
npm run build
这两个命令检查的对象不同:
vue-tsc --noEmit
├─ TypeScript 类型
├─ Vue 单文件组件类型
└─ 模板表达式和组件使用关系
vite build
├─ 模块解析
├─ SFC 编译
├─ 资源处理
├─ 打包
└─ 生产构建产物
一个通过而另一个失败,并不矛盾。
8.1 编辑器中的语言服务
VS Code 等编辑器通常通过 Vue 官方语言工具提供 .vue 文件的类型提示和模板诊断。应使用与项目 Vue 版本匹配的 Vue Language Tools。
实践中可能遇到:
- 编辑器显示无错误,但 CI 中
vue-tsc报错; - CI 类型检查报错,但编辑器没有及时更新;
- 依赖升级后模板推导行为变化;
tsconfig没有把.vue文件或src目录纳入检查范围。
诊断时先确认:
npx vue-tsc --noEmit
再检查 tsconfig 的 include、项目引用配置和依赖版本,而不是直接在模板中添加 as any。
九、类型检查的对象:组件使用者和组件实现者
组件类型安全有两个方向。
9.1 检查组件使用者
<UserCard
:user="user"
:compact="true"
@select="onSelect"
/>
这里检查:
user是否符合 Props;compact是否是声明的 Prop;onSelect是否能接收事件参数;- 是否使用了不存在的事件名。
9.2 检查组件实现者
<script setup lang="ts">
interface User {
id: number
name: string
}
const props = defineProps<{
user: User
}>()
const emit = defineEmits<{
select: [id: number]
}>()
function selectUser() {
emit('select', props.user.id)
}
</script>
这里检查:
props.user.id是否确实为number;emit的参数是否为number;- 模板是否访问了不存在的字段。
只有两个方向都检查,组件契约才完整。单独给父组件代码加类型,不能保证子组件内部没有发出错误事件;单独检查子组件,也不能保证调用方传入了正确数据。
十、插槽也是组件 API
插槽不仅是“插入一段模板”,还可能向父组件提供作用域数据。
子组件:
<script setup lang="ts">
interface User {
id: number
name: string
}
defineProps<{
users: User[]
}>()
defineSlots<{
default?: (props: { user: User }) => any
empty?: () => any
}>()
</script>
<template>
<ul v-if="users.length > 0">
<li v-for="user in users" :key="user.id">
<slot :user="user">
{{ user.name }}
</slot>
</li>
</ul>
<slot v-else name="empty">
暂无用户
</slot>
</template>
父组件:
<UserList :users="users">
<template #default="{ user }">
<strong>{{ user.name }}</strong>
</template>
<template #empty>
没有可显示的用户
</template>
</UserList>
defineSlots 用于给插槽声明类型。它主要依赖 Vue 语言工具进行模板推导;如果使用的 Vue 版本或工具链较旧,可能只能获得部分检查能力。
插槽契约的价值在于避免父组件假设错误:
<template #default="{ user }">
{{ user.email.toUpperCase() }}
</template>
如果 User 没有 email,模板检查应当报告错误。插槽参数不是组件内部私有变量,它属于组件公开 API。
十一、透传属性与声明边界
Vue 会把未被组件声明为 Props 或 Emits 的属性,通常透传到组件根节点。比如:
<MyButton
id="save-button"
class="primary"
data-test="save"
/>
如果 MyButton 没有声明这些属性,它们可能被应用到根元素。
可以使用 $attrs 或 useAttrs 控制透传:
<script setup lang="ts">
defineOptions({
inheritAttrs: false,
})
</script>
<template>
<div class="button-wrapper">
<button class="button" v-bind="$attrs">
<slot />
</button>
</div>
</template>
这里的行为是:
父组件属性
│
├─ 已声明 Prop:进入 props
├─ 已声明组件事件:作为监听器处理
└─ 其他属性:进入 $attrs
透传属性的类型边界没有 Props 那么严格。$attrs 是动态属性集合,适合处理 class、style、id、data-* 和 ARIA 属性等 DOM 边界数据,但不应把它当成任意业务 API 的类型声明。
例如,一个按钮组件如果要求:
variant: 'primary' | 'danger'
就应把 variant 声明为 Props,而不是依靠 $attrs.variant。否则:
- 拼写错误无法被组件契约捕获;
- 业务属性可能被错误地透传给 DOM;
- 原生元素不会自动理解这个属性。
多根节点组件的透传行为也需要显式处理,因为 Vue 无法总是推断应该把未声明属性放到哪个根节点。此时应在目标节点上使用 v-bind="$attrs"。
十二、公开实例 API 与 defineExpose
<script setup> 中声明的变量默认不会作为组件实例的公开 API 暴露给父组件。若父组件需要通过模板引用调用子组件方法,应显式使用 defineExpose:
子组件:
<script setup lang="ts">
import { ref } from 'vue'
const input = ref<HTMLInputElement | null>(null)
function focus() {
input.value?.focus()
}
defineExpose({
focus,
})
</script>
<template>
<input ref="input">
</template>
父组件:
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import SearchInput from './SearchInput.vue'
const searchInput = ref<InstanceType<typeof SearchInput> | null>(null)
onMounted(() => {
searchInput.value?.focus()
})
</script>
<template>
<SearchInput ref="searchInput" />
</template>
这里需要区分两个引用:
searchInput
└─ 子组件实例引用
input
└─ 子组件内部的原生 input 引用
父组件只能调用 defineExpose 暴露的 focus,不能直接访问子组件内部的 input。
如果组件没有暴露 focus:
searchInput.value?.focus()
应当无法通过公开实例类型检查。
模板引用的自动推导能力会随 Vue Language Tools 和 Vue 版本变化。Vue 3.5 引入了 useTemplateRef,可以在部分场景下改善模板引用类型推导;若项目需要兼容较早版本,使用显式的 ref<... | null>(null) 是更通用的写法。
模板引用在挂载前为 null,组件卸载后也可能再次失效。因此不能写成:
searchInput.value.focus()
除非已经通过生命周期或条件判断证明它不为空。即使 TypeScript 被断言压制,运行时仍可能因为组件尚未挂载而抛出异常。
十三、组件契约中的状态与单向数据流
Props 应当被视为父组件拥有的数据。子组件可以读取,但不应直接修改:
const props = defineProps<{
count: number
}>()
// props.count++ // 错误:Props 是只读的
正确的数据流是:
父状态 count
│
├─ :count
▼
子组件 props.count
│
├─ 用户操作
▼
emit('update:count', nextCount)
│
▼
父状态 count 更新
完整示例:
<script setup lang="ts">
const props = defineProps<{
count: number
}>()
const emit = defineEmits<{
'update:count': [value: number]
}>()
function increment() {
emit('update:count', props.count + 1)
}
</script>
<template>
<button type="button" @click="increment">
{{ count }}
</button>
</template>
父组件:
<script setup lang="ts">
import { ref } from 'vue'
import Counter from './Counter.vue'
const count = ref(0)
</script>
<template>
<Counter
:count="count"
@update:count="count = $event"
/>
</template>
如果子组件同时维护一个与 Prop 同步的本地副本,就引入了两个状态源:
父组件 count
子组件 localCount
此时需要定义同步规则:
- 父组件变化时是否覆盖本地值;
- 子组件修改时何时通知父组件;
- 异步保存失败时回滚哪一个值;
- 组件卸载后是否取消未完成操作。
类型系统只能检查值的类型,不能自动证明状态同步逻辑正确。双向绑定越复杂,越应该明确谁拥有状态、谁只负责派发意图。
十四、异步状态和错误类型
组件通常不仅有“数据”,还有加载、成功和失败状态。一个常见错误是只声明成功数据:
const user = ref<User | null>(null)
这无法表达请求正在加载还是已经失败。更明确的状态模型是:
type LoadState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error }
使用:
const state = ref<LoadState<User>>({ status: 'idle' })
async function loadUser() {
state.value = { status: 'loading' }
try {
const response = await fetch('/api/user')
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const data: unknown = await response.json()
if (!isUser(data)) {
throw new Error('响应数据格式无效')
}
state.value = {
status: 'success',
data,
}
} catch (error) {
state.value = {
status: 'error',
error: error instanceof Error
? error
: new Error('未知错误'),
}
}
}
模板中可以根据判别字段缩小类型:
<template>
<p v-if="state.status === 'idle'">
尚未加载
</p>
<p v-else-if="state.status === 'loading'">
加载中
</p>
<p v-else-if="state.status === 'error'">
{{ state.error.message }}
</p>
<p v-else>
{{ state.data.name }}
</p>
</template>
这里的 status 是判别字段。TypeScript 根据它推导当前分支中有哪些字段可用。相比大量非空断言:
state.data!.name
判别联合更能表达真实状态,错误路径也不会被隐藏。
并发请求还会引入竞态:
请求 A 开始
请求 B 开始
请求 B 先返回,状态变为 B
请求 A 后返回,状态又被覆盖为 A
类型检查无法解决“旧请求覆盖新请求”的时序问题。需要使用请求序号、AbortController 或请求库提供的取消机制。例如:
let requestId = 0
async function loadUser() {
const currentId = ++requestId
state.value = { status: 'loading' }
try {
const response = await fetch('/api/user')
const data: unknown = await response.json()
if (!isUser(data)) {
throw new Error('无效用户数据')
}
if (currentId !== requestId) {
return
}
state.value = {
status: 'success',
data,
}
} catch (error) {
if (currentId !== requestId) {
return
}
state.value = {
status: 'error',
error: error instanceof Error ? error : new Error('加载失败'),
}
}
}
requestId 只解决当前函数实例中的结果覆盖问题;如果组件卸载后仍有异步任务,还需要在适当时机取消请求或忽略结果。
十五、一个完整的泛型组件示例
下面把 Props、Emits、泛型和模板检查放在一个可运行的组件中。
DataTable.vue:
<script setup lang="ts" generic="T extends { id: string | number }">
const props = defineProps<{
rows: T[]
columns: Array<{
key: string
title: string
render: (row: T) => string
}>
loading?: boolean
}>()
const emit = defineEmits<{
rowClick: [row: T]
}>()
</script>
<template>
<div class="data-table">
<p v-if="loading">加载中...</p>
<table v-else>
<thead>
<tr>
<th
v-for="column in columns"
:key="column.key"
>
{{ column.title }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in rows"
:key="row.id"
@click="emit('rowClick', row)"
>
<td
v-for="column in columns"
:key="column.key"
>
{{ column.render(row) }}
</td>
</tr>
</tbody>
</table>
</div>
</template>
父组件:
<script setup lang="ts">
import DataTable from './DataTable.vue'
interface Product {
id: number
name: string
price: number
}
const products: Product[] = [
{ id: 1, name: 'Keyboard', price: 99 },
{ id: 2, name: 'Mouse', price: 49 },
]
const columns = [
{
key: 'name',
title: '名称',
render: (product: Product) => product.name,
},
{
key: 'price',
title: '价格',
render: (product: Product) => `¥${product.price}`,
},
]
function openProduct(product: Product) {
console.log(product.id)
}
</script>
<template>
<DataTable
:rows="products"
:columns="columns"
@row-click="openProduct"
/>
</template>
类型关系如下:
products: Product[]
↓
泛型组件推导 T = Product
↓
columns.render: (product: Product) => string
↓
rowClick: (product: Product) => void
错误示例:
const invalidColumns = [
{
key: 'price',
title: '价格',
render: (product: Product) => product.price.toUpperCase(),
},
]
price 是 number,不能调用 toUpperCase。这是组件外部配置的错误,模板检查和普通 TypeScript 检查都应该能够发现。
另一个错误:
function openProduct(id: string) {
console.log(id)
}
如果语言工具正确推导了泛型事件,row-click 传入的是 Product,而不是 string。在复杂包装组件或动态组件场景中,如果推导没有保留下来,应检查组件导出方式、语言工具版本和是否引入了 any。
十六、常见失败方式及其真实原因
16.1 把所有数据写成 any
const props = defineProps<{
data: any
}>()
短期内模板错误减少,长期结果却是:
- 字段拼写错误不再报告;
- 事件参数失去具体类型;
- 重构无法得到可靠反馈;
- 外部数据的非法形状直接进入视图。
如果暂时无法描述完整数据,优先使用 unknown,然后在边界进行收窄:
const props = defineProps<{
data: unknown
}>()
这会迫使组件明确处理数据形状。
16.2 用断言替代转换
const value = input.value as number
这不会把字符串转换成数字。正确的转换应显式进行:
const value = Number(input.value)
if (!Number.isFinite(value)) {
throw new Error('输入不是有效数字')
}
断言适用于 TypeScript 已经无法从控制流推断、但程序员确实掌握额外事实的场景;它不应被当成运行时转换函数。
16.3 只检查 .ts,不检查 .vue
如果脚本执行:
tsc --noEmit
但项目配置没有通过 Vue 语言工具处理 .vue 文件,模板中的错误可能不会被检查。Vue 项目通常应使用:
vue-tsc --noEmit
而不是假设普通 tsc 会自动理解单文件组件模板。
16.4 认为 Vite 构建成功就代表类型正确
Vite 关注构建,不负责替代完整类型检查。应在 CI 中分别验证:
npm run typecheck
npm run build
若类型检查失败:
- 先读取第一个真实错误;
- 确认错误发生在 Props、Emits、模板还是依赖类型;
- 检查是否存在
any、断言或错误的tsconfig排除规则; - 不要先通过关闭检查解决问题。
16.5 把组件事件写成字符串
emit('svae', value)
如果 Emits 没有类型声明,事件名拼写错误可能只能在人工测试中发现。声明事件后,事件名本身也成为可检查的联合类型。
16.6 误以为 Props 类型能验证后端数据
const props = defineProps<{
user: User
}>()
这只能检查父组件传入的表达式在 TypeScript 中是否被认为是 User。如果父组件先做了:
const user = response.json() as User
那么错误数据已经绕过了类型系统。API 响应仍需要运行时校验、后端契约生成或可靠的解析器。
十七、如何验证一个组件契约是否真的成立
可以按照数据流而不是文件数量进行验证。
第一步:确认输入边界
列出组件接收的所有数据:
Props
v-model
Slots
Attrs
检查每个业务字段是否有明确类型,是否区分了可选、null 和默认值。
第二步:确认输出边界
列出组件可能产生的外部影响:
Emits
v-model 更新
defineExpose 暴露方法
为每个事件声明事件名和参数元组,避免通过无类型字符串发送事件。
第三步:检查模板
运行:
npx vue-tsc --noEmit
确认以下错误可以被捕获:
- 不存在的 Prop;
- 错误的 Prop 类型;
- 不存在的事件;
- 错误的事件参数;
- 模板访问不存在的字段;
null或undefined未处理;- 插槽参数使用错误。
第四步:检查运行时边界
对以下数据不要只依赖 TypeScript:
fetch().json();localStorage.getItem();- URL 查询参数;
- 用户输入;
postMessage;- 第三方 SDK 返回值;
- 服务端注入数据。
这些值进入应用后,应先转成可信的内部类型。
第五步:检查生产脚本
推荐至少保证:
{
"scripts": {
"typecheck": "vue-tsc --noEmit",
"build": "npm run typecheck && vite build"
}
}
如果团队不希望 build 隐式包含类型检查,也可以在 CI 中按顺序执行两个独立步骤,但不能只依赖开发服务器反馈。
十八、规范保证、实现能力与工程取舍
需要明确区分三个层次。
Vue 和 TypeScript 能保证的部分
在类型工具正确配置、代码没有绕过检查的前提下,可以静态检查:
- Props 的声明和使用;
- Emits 的事件名和参数;
v-model的值及更新事件;- 组件公开实例类型;
- 许多模板表达式;
- 泛型参数之间的静态关系。
不能仅靠类型保证的部分
TypeScript 不会自动保证:
- 后端返回数据真实符合接口;
- 用户输入内容合法;
- 异步请求不会竞态;
- 组件卸载后异步回调不会执行;
- 事件一定被父组件监听;
- 业务状态转换一定完整;
- DOM 运行时行为符合设计。
版本敏感能力
以下能力应根据项目实际版本确认:
<script setup>的泛型组件支持;defineSlots;defineModel;- Vue 3.5 中的
useTemplateRef; - Vue Language Tools 与
vue-tsc对模板和泛型推导的具体支持。
不要仅因为编辑器当前能推导,就假设旧版本构建链也具备相同能力。升级 Vue、vue-tsc 或语言工具后,应重新运行类型检查和构建,并覆盖泛型组件、插槽、模板引用和 v-model 等关键契约。
一个可靠的 Vue TypeScript 组件,不是“所有地方都写上类型”,而是让数据从运行时边界进入可信状态,再通过 Props、Emits、Slots、v-model 和公开实例形成明确的数据流,并让 vue-tsc 持续检查这些关系。泛型用于保留多个输入输出之间的类型关联,模板检查用于覆盖 .vue 文件中的表达式,而运行时校验负责弥补 TypeScript 类型擦除后的现实边界。只有三者同时存在,组件 API 才真正具有可维护的契约。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue 异步数据与请求状态:取消、竞态、缓存、分页和错误恢复
- 下一篇:Vue 测试体系:Vitest、Vue Test Utils、组件测试和端到端测试
- 延伸:Vue 项目与 Vite 工具链:创建、环境变量、构建、代理和依赖治理
- 延伸:Vue 组件契约:Props、Emits、v-model、透传属性与边界
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论