【TS】工具类型实战:vue3“类型安全”

3344 字
17 分钟
【TS】工具类型实战:vue3“类型安全”

在 Vue3 项目中,我们常遇到类型约束不精准的问题:比如表单初始化时嵌套字段必须填、组件 Props 的非法组合、异步请求的回调类型不明确。TypeScript 的进阶工具类型能帮我们解决这些痛点——从“被动补类型”到“主动设计类型”,让 Vue3 的代码更安全、更易维护。

一、属性修饰进阶:从“浅层可选”到“Vue3 表单的深层灵活初始化”#

属性修饰工具类型的核心是修改对象属性的“访问性”(可选/必选、只读/可写)。内置的Partial仅能处理浅层属性,但 Vue3 的嵌套表单(如用户信息包含地址)需要深层可选才能实现“部分填充”。

1.1 深层属性修饰:DeepPartial(Vue3 场景:用户信息表单初始化)#

问题场景:Vue3 的用户信息表单包含嵌套的address字段,初始化时想只填nameaddress.city,但Partial<UserFormState>会要求address的所有字段(street/city/zipCode)必须完整——这显然不合理。

解决工具DeepPartial(递归标记所有嵌套属性为可选)

// types/user.ts:用户信息结构
export interface UserFormState {
name: string
age: number
address: {
street: string
city: string
zipCode: string
}
}
// utils/type-utils.ts:深层可选工具类型
export type DeepPartial<T extends object> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K]
}

Vue3 组件示例UserForm.vue):

<script setup lang="ts">
import { reactive } from 'vue'
import type { UserFormState, DeepPartial } from './types/user'
// 🔴 错误:ShallowPartial要求address的所有字段必须填
type ShallowPartialUser = Partial<UserFormState>
// const shallowInitial: ShallowPartialUser = reactive({
// name: 'Lin',
// address: { city: 'Shanghai' } // ❌ 缺少street和zipCode,类型报错!
// });
// 🟢 正确:DeepPartial允许嵌套字段可选
type DeepPartialUser = DeepPartial<UserFormState>
const deepInitial: DeepPartialUser = reactive({
name: 'Lin',
address: { city: 'Shanghai' }, // ✅ 嵌套字段可选,无报错
})
</script>
<template>
<form class="user-form">
<div class="form-item">
<label>姓名</label>
<input
v-model="deepInitial.name"
placeholder="请输入姓名"
/>
</div>
<div class="form-item">
<label>年龄</label>
<input
v-model.number="deepInitial.age"
type="number"
placeholder="请输入年龄"
/>
</div>
<div
class="form-item"
v-if="deepInitial.address">
<label>地址</label>
<input
v-model="deepInitial.address.street"
placeholder="街道" />
<input
v-model="deepInitial.address.city"
placeholder="城市" />
<input
v-model="deepInitial.address.zipCode"
placeholder="邮编" />
</div>
</form>
</template>

为什么DeepPartial更适合 Vue3?
Vue3 的响应式对象是嵌套的reactive会递归代理所有属性),浅层Partial无法处理嵌套字段的可选性。DeepPartial通过递归条件类型,让嵌套字段也能“按需填充”,完美匹配 Vue3 的响应式特性。

1.2 部分属性修饰:MarkPropsAsOptional(Vue3 场景:用户编辑组件)#

问题场景:Vue3 的用户编辑组件中,name/phone是必填字段,address是可选字段——我们需要仅将address标记为可选,其他字段保持必填。

解决工具MarkPropsAsOptional(仅修改指定属性的访问性)

// utils/type-utils.ts:部分可选工具类型
export type MarkPropsAsOptional<
T extends object,
K extends keyof T = keyof T
> = Partial<Pick<T, K>> & Omit<T, K>

Vue3 组件示例UserEdit.vue):

<script setup lang="ts">
import { defineProps, reactive } from 'vue'
import type { UserFormState, MarkPropsAsOptional } from './types/user'
// 🔧 将address字段标记为可选
type EditableUser = MarkPropsAsOptional<UserFormState, 'address'>
// 📝 组件Props定义:name/phone必填,address可选
const props = defineProps<EditableUser>()
// 🎯 响应式数据:初始化时可省略address
const form = reactive({
...props,
address: props.address || {},
})
// 🚩 提交逻辑:仅校验必填字段
const handleSubmit = () => {
if (!form.name || !form.phone) {
alert('姓名和手机号必填!')
return
}
console.log('提交数据:', form)
}
</script>
<template>
<form
class="user-edit-form"
@submit.prevent="handleSubmit">
<div class="form-item">
<label>姓名</label>
<input
v-model="form.name"
required
placeholder="请输入姓名" />
</div>
<div class="form-item">
<label>手机号</label>
<input
v-model="form.phone"
required
placeholder="请输入手机号" />
</div>
<div
class="form-item"
v-if="form.address">
<label>地址</label>
<input
v-model="form.address.street"
placeholder="街道" />
<input
v-model="form.address.city"
placeholder="城市" />
</div>
<button type="submit">保存修改</button>
</form>
</template>

MarkPropsAsOptional的价值

  • 避免重复声明类型:无需为“编辑场景”单独写一个UserEditState,只需基于UserFormState修改部分属性;
  • 类型约束精准:必填字段(name/phone)仍需校验,可选字段(address)按需填写。

二、结构工具进阶:从“键名裁剪”到“Vue3 组件的 Props 约束”#

结构工具类型的核心是修改对象的“结构”(保留/删除字段、合并/拆分)。内置的Pick仅能基于键名裁剪,但 Vue3 的组件 Props 常需要基于键值类型(如筛选事件回调)或属性互斥(如按钮类型)。

2.1 基于键值类型的裁剪:PickByValueType(Vue3 场景:表格组件的事件回调)#

问题场景:Vue3 的表格组件DataTable有多个事件回调(onRowClick/onPageChange),我们需要快速提取这些函数类型的 Props,用于事件绑定。

解决工具PickByValueType(筛选指定类型的属性)

// utils/type-utils.ts:基于值类型的Pick工具类型
type Func = (...args: any[]) => any
export type PickByValueType<T extends object, ValueType> = Pick<
T,
{ [K in keyof T]: T[K] extends ValueType ? K : never }[keyof T]
>

Vue3 组件示例DataTable.vue):

// types/table.ts:表格Props结构
export interface DataTableProps {
data: any[]
columns: string[]
onRowClick: (row: any) => void // 行点击回调(函数类型)
onPageChange: (page: number) => void // 分页回调(函数类型)
bordered: boolean // 非函数类型
}
<script setup lang="ts">
import { defineProps, computed } from 'vue'
import type { DataTableProps, PickByValueType } from './types/table'
import type { Func } from '../utils/type-utils'
// 🔧 提取所有函数类型的Props(事件回调)
type TableEvents = PickByValueType<DataTableProps, Func>
// 📝 组件Props:仅接受事件回调
const props = defineProps<TableEvents>()
// 🎯 计算属性:处理行点击逻辑
const handleRowClick = computed(() => (row: any) => {
console.log('行点击:', row)
props.onRowClick?.(row)
})
</script>
<template>
<div class="data-table">
<div class="table-header">
<div
v-for="col in columns"
:key="col">
{{ col }}
</div>
</div>
<div class="table-body">
<div
v-for="row in data"
:key="row.id"
class="table-row"
@click="handleRowClick(row)">
<div
v-for="col in columns"
:key="col">
{{ row[col] }}
</div>
</div>
</div>
<div class="table-pagination">
<button @click="onPageChange(1)">第一页</button>
<button @click="onPageChange(2)">第二页</button>
</div>
</div>
</template>

PickByValueType的优势

  • 自动筛选事件回调:无需手动罗列onRowClick/onPageChange,类型工具帮你提取;
  • 避免传递无关属性:组件仅接受函数类型的 Props,减少 Props 的冗余。

2.2 属性互斥:XOR(Vue3 场景:按钮组件的类型约束)#

问题场景:Vue3 的按钮组件Btn有两种类型(primary/secondary),但不能同时选——我们需要约束 Props,避免非法组合。

解决工具XOR(互斥类型,二选一)

// utils/type-utils.ts:互斥工具类型
export type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never }
export type XOR<T, U> = (Without<T, U> & U) | (Without<U, T> & T)

Vue3 组件示例Btn.vue):

// types/button.ts:按钮Props结构
export interface PrimaryBtnProps {
type: 'primary'
primaryColor: string
}
export interface SecondaryBtnProps {
type: 'secondary'
secondaryColor: string
}
// 🔧 互斥约束:只能是Primary或Secondary
export type BtnProps = XOR<PrimaryBtnProps, SecondaryBtnProps>
<script setup lang="ts">
import { defineProps, computed } from 'vue'
import type { BtnProps } from './types/button'
// 📝 组件Props:互斥约束
const props = defineProps<BtnProps>()
// 🎯 计算属性:根据类型设置样式
const btnStyle = computed(() => ({
backgroundColor:
props.type === 'primary' ? props.primaryColor : props.secondaryColor,
color: '#fff',
padding: '8px 16px',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}))
</script>
<template>
<button
class="btn"
:style="btnStyle"
@click="$emit('click')">
<slot></slot>
</button>
</template>

使用示例

<!-- ✅ 正确:Primary按钮 -->
<Btn type="primary" primary-color="#007bff">提交</Btn>
<!-- ✅ 正确:Secondary按钮 -->
<Btn type="secondary" secondary-color="#6c757d">取消</Btn>
<!-- ❌ 错误:同时包含primary和secondary属性 -->
<Btn type="primary" secondary-color="#6c757d">错误示例</Btn>

XOR的意义

  • 避免类型错误:组件 Props 的非法组合会直接报错,减少调试时间;
  • 类型提示更友好:IDE 会根据type自动提示对应的颜色属性(如type="primary"时提示primaryColor)。

三、集合工具进阶:从“一维联合”到“Vue3 中的对象合并”#

集合工具类型的核心是处理联合类型的“集合运算”(交集、并集)。内置的Extract仅能处理一维联合,但 Vue3 的项目常需要二维对象的集合运算(如合并用户与订单的共同字段)。

3.1 对象的交集:ObjectIntersection(Vue3 场景:用户与订单的共同字段)#

问题场景:Vue3 的用户详情页需要展示UserOrder的共同字段(如userId),我们需要快速提取这些字段,避免重复声明。

解决工具ObjectIntersection(提取两个对象的共同属性)

// utils/type-utils.ts:对象交集工具类型
export type ObjectIntersection<T extends object, U extends object> = Pick<
T,
{ [K in keyof T]: K extends keyof U ? K : never }[keyof T]
>

类型定义示例

// types/user.ts:用户信息结构
export interface User {
userId: number
name: string
email: string
}
// types/order.ts:订单信息结构
export interface Order {
orderId: number
userId: number
amount: number
}

Vue3 组件示例UserOrderDetail.vue):

<script setup lang="ts">
import { defineProps } from 'vue'
import type { User, Order, ObjectIntersection } from './types'
// 🔧 提取User和Order的共同字段(仅userId)
type CommonFields = ObjectIntersection<User, Order>
// 📝 组件Props:接受共同字段
const props = defineProps<CommonFields>()
</script>
<template>
<div class="user-order-detail">
<h3>用户ID:{{ userId }}</h3>
<div class="user-info">
<p>姓名:{{ name }}</p>
<p>邮箱:{{ email }}</p>
</div>
<div class="order-info">
<p>订单ID:{{ orderId }}</p>
<p>订单金额:{{ amount }}</p>
</div>
</div>
</template>

ObjectIntersection的价值

  • 避免重复声明:共同字段(如userId)只需声明一次,减少代码冗余;
  • 类型约束更精准:组件仅接受共同字段,避免传递无关属性。

四、模式匹配进阶:从“浅层提取”到“Vue3 中的请求回调”#

模式匹配工具类型的核心是基于infer的类型提取。内置的Parameters仅能提取浅层类型,但 Vue3 的异步请求常需要提取深层嵌套的类型(如成功回调的参数)。

4.1 提取函数的最后一个参数:LastParameter(Vue3 场景:请求回调)#

问题场景:Vue3 的请求函数fetchUser的最后一个参数是成功回调,我们需要提取这个回调的参数类型(如User),确保回调中data的类型明确,避免写any

解决工具LastParameter(提取函数的最后一个参数类型)

第一步:定义请求函数与类型#

// api/user.ts:请求函数与类型
import type { User } from '../types/user'
// 🔧 定义请求函数类型:最后一个参数是成功回调
export type FetchUser = (
url: string,
options: { method: string },
success: (data: User) => void // 成功回调:参数是User
) => void
// 📞 模拟异步请求(实际项目中用axios/fetch)
export const fetchUser: FetchUser = (url, options, success) => {
setTimeout(() => {
success({ userId: 123, name: 'Lin Budu', email: 'lin@example.com' })
}, 1000)
}

第二步:UserDetail.vue 组件实现#

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { fetchUser } from '../api/user'
import type { User } from '../types/user'
import type { LastParameter } from '../utils/type-utils'
import type { FetchUser } from '../api/user'
// 🔧 提取成功回调的类型:(data: User) => void
type SuccessCallback = LastParameter<FetchUser>
// 响应式数据:用户信息(初始为null)
const user = ref<User | null>(null)
// 加载状态(提升用户体验)
const isLoading = ref(true)
// 🚀 组件挂载时请求数据
onMounted(() => {
fetchUser(
'/api/user/123', // 请求URL
{ method: 'GET' }, // 请求配置
(data) => {
// ✅ 自动推导data的类型为User(无需写any)
user.value = data // 响应式更新用户信息
isLoading.value = false // 关闭加载状态
}
)
})
</script>
<template>
<div class="user-detail">
<!-- 加载中状态 -->
<div
v-if="isLoading"
class="loading">
<span class="spinner"></span> 加载中...
</div>
<!-- 用户信息展示 -->
<div
v-else-if="user"
class="user-info">
<h2>{{ user.name }}</h2>
<div class="info-item">
<label>用户ID:</label>
<span>{{ user.userId }}</span>
</div>
<div class="info-item">
<label>邮箱:</label>
<span>{{ user.email }}</span>
</div>
</div>
<!-- 无数据状态 -->
<div v-else class="error">
未找到用户信息
</div>
</div>
</template>

关键说明:#

  1. 类型提取LastParameter<FetchUser>自动提取成功回调的类型(data: User) => void,回调中data的类型无需手动声明,IDE 会自动提示User的属性(如name/userId)。
  2. 响应式处理:用ref<User | null>存用户信息,初始为null,请求完成后更新user.value,模板自动响应式渲染。
  3. 用户体验:加isLoading状态,避免页面“空白等待”,提升交互友好性。

五、总结:Vue3 项目中的类型编程心法#

通过以上 4 个实战场景,我们总结出Vue3 中类型编程的核心思路

1. 贴合 Vue3 特性#

  • 针对 Vue3 的响应式对象ref/reactive),使用递归工具类型(如DeepPartial)处理嵌套属性的可选性;
  • 针对 Vue3 的组件 Props,用互斥类型(XOR)约束非法组合,用部分可选(MarkPropsAsOptional)减少重复声明。

2. 工具类型的“组合思维”#

复杂的类型需求,往往可以拆分为基础工具类型的组合

  • DeepPartial = 递归 + Partial
  • MarkPropsAsOptional = Partial + Pick + Omit
  • XOR = Without + 交叉类型 + 联合类型。

3. 类型安全的“落地细节”#

  • 避免any:用模式匹配工具类型(LastParameter)提取回调类型,让异步请求的参数类型明确;
  • 精准约束:用集合工具类型(ObjectIntersection)提取共同字段,减少 Props 的冗余;
  • 用户体验:类型工具不仅是“约束”,更是“提效”——比如XOR让组件 Props 的提示更友好,DeepPartial让表单初始化更灵活。

最后的话:TypeScript 是 Vue3 的“隐形助手”#

很多 Vue3 开发者觉得 TypeScript“麻烦”,但类型安全不是“枷锁”,而是“保险”

  • 它能在你写user.value.name时,提前告诉你user可能为null
  • 它能在你传secondaryColorprimary按钮时,直接报错;
  • 它能在你写回调函数时,自动提示data的属性,不用翻文档找接口字段。

从“表单可选”到“组件互斥”,从“请求回调”到“对象合并”,TypeScript 的进阶工具类型让 Vue3 的代码更稳定、更易维护、更适合团队协作。掌握这些工具类型,你就能从“Vue3 开发者”升级为“有类型思维的 Vue3 开发者”——让代码不仅“能跑”,更“可靠”。

扩展阅读

通过这些进阶工具类型,你将彻底告别“类型补漏”的日子,让 TypeScript 成为 Vue3 项目的“隐形安全卫士”! 😊

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!

【TS】工具类型实战:vue3“类型安全”
https://blog.fridolph.top/posts/2025-06-10__ts06/
作者
Fridolph
发布于
2025-06-10
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
Fridolph
热爱 Coding、音乐和羽毛球的 90 后全栈工程师
公告
欢迎访问我的小站 ^_^ 我是昇哥,热爱Coding,喜爱音乐、羽毛球和摄影的 90后全栈工程师
分类
标签

文章目录