14-TypeScript 与 Vue3
TypeScript 与 Vue3
Vue3 从底层重构了类型系统,配合
<script setup lang="ts">让 TypeScript 开发体验达到全新高度。
一、前言
TypeScript 为 JavaScript 提供了静态类型检查,能够在编译阶段发现潜在错误,提升代码的可维护性和开发效率。Vue3 使用 TypeScript 完全重写了核心源码,提供了开箱即用的类型支持。
相比 Vue2 需要依赖vue-class-component或复杂的类型声明,Vue3 的 Composition API 与 TypeScript 结合更加自然。本文将系统讲解 Vue3 + TypeScript 的开发实践。
二、<script setup lang="ts">基础
2.1 启用 TypeScript 支持
在 Vue3 单文件组件中,只需添加lang="ts"即可使用 TypeScript:
<script setup lang="ts"> import { ref } from 'vue' // TypeScript 会自动推断类型 const count = ref(0) // Ref<number> const message = ref('hello') // Ref<string> const isShow = ref(true) // Ref<boolean> </script>2.2 类型推断与显式声明
Vue3 的响应式 API 具有良好的类型推断能力,但在复杂场景下建议显式声明类型:
<script setup lang="ts"> import { ref, reactive } from 'vue' // 自动推断 const autoCount = ref(0) // Ref<number> // 显式声明(推荐用于复杂类型) const count = ref<number>(0) const name = ref<string>('Vue3') const items = ref<string[]>(['a', 'b', 'c']) // 接口定义 interface User { id: number name: string email?: string // 可选属性 } const user = ref<User>({ id: 1, name: '张三' }) // reactive 的类型推断 const state = reactive({ count: 0, user: { name: '李四' } as User }) </script>三、Props 类型声明
3.1 使用类型字面量
<script setup lang="ts"> // 简单类型声明 const props = defineProps<{ title: string count?: number // 可选 items: string[] user: { name: string; age: number } }>() </script>3.2 使用接口定义
<script setup lang="ts"> // 接口定义(推荐,可复用) interface Props { title: string count?: number disabled?: boolean } const props = defineProps<Props>() </script>3.3 带默认值的 Props
使用withDefaults编译器宏设置默认值:
<script setup lang="ts"> interface Props { title: string count?: number disabled?: boolean tags?: string[] } const props = withDefaults(defineProps<Props>(), { count: 0, disabled: false, tags: () => ['default'] // 对象/数组默认值需用工厂函数 }) </script>3.4 复杂的 Props 类型
<script setup lang="ts"> // 定义枚举类型 type ButtonType = 'primary' | 'success' | 'warning' | 'danger' type ButtonSize = 'small' | 'medium' | 'large' interface Props { type?: ButtonType size?: ButtonSize loading?: boolean // 函数类型 Props onClick?: (event: MouseEvent) => void // 对象数组 options: Array<{ label: string value: string | number disabled?: boolean }> } const props = withDefaults(defineProps<Props>(), { type: 'primary', size: 'medium', loading: false }) </script>四、Emits 类型声明
4.1 基本用法
<script setup lang="ts"> // 声明 emits 及其参数类型 const emit = defineEmits<{ // 无参数事件 click: [] // 单参数事件 update: [value: string] // 多参数事件 submit: [data: FormData, callback: (result: boolean) => void] // 可选参数事件 change: [value?: number] }>() const handleClick = () => { emit('click') } const handleUpdate = (value: string) => { emit('update', value) } </script>4.2 与 v-model 配合
<!-- InputField.vue --> <template> <input :value="modelValue" @input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)" /> </template> <script setup lang="ts"> const props = defineProps<{ modelValue: string }>() const emit = defineEmits<{ 'update:modelValue': [value: string] }>() </script>4.3 多个 v-model
<script setup lang="ts"> interface Props { title: string content: string } const props = defineProps<Props>() const emit = defineEmits<{ 'update:title': [value: string] 'update:content': [value: string] }>() </script> <template> <div> <input :value="title" @input="emit('update:title', ($event.target as HTMLInputElement).value)" /> <textarea :value="content" @input="emit('update:content', ($event.target as HTMLTextAreaElement).value)" /> </div> </template>五、响应式 API 的类型
5.1 ref 的类型
<script setup lang="ts"> import { ref, Ref } from 'vue' // 基本类型 const count: Ref<number> = ref(0) // 对象类型 interface User { name: string age: number } const user = ref<User>({ name: '张三', age: 25 }) // user.value 的类型为 User // 可能为 null 的引用(常用于 DOM 引用) const inputRef = ref<HTMLInputElement | null>(null) // 数组类型 const list = ref<number[]>([1, 2, 3]) // 联合类型 const status = ref<'idle' | 'loading' | 'success' | 'error'>('idle') </script>5.2 computed 的类型
<script setup lang="ts"> import { ref, computed } from 'vue' const firstName = ref('张') const lastName = ref('三') // 自动推断返回类型为 string const fullName = computed(() => `${firstName.value} ${lastName.value}`) // 显式声明类型 const age = ref<string | number>(25) const ageDisplay = computed<string>(() => `${age.value} 岁`) // 可写 computed const count = ref(0) const doubleCount = computed({ get: (): number => count.value * 2, set: (val: number) => { count.value = val / 2 } }) </script>5.3 reactive 的类型
<script setup lang="ts"> import { reactive } from 'vue' // 接口定义 interface FormState { username: string password: string remember: boolean errors: Record<string, string[]> } const form = reactive<FormState>({ username: '', password: '', remember: false, errors: {} }) // 使用类型断言处理可选属性 interface Config { apiUrl: string timeout?: number } const config = reactive<Config>({ apiUrl: '/api' // timeout 是可选的,可以不提供 }) </script>六、组件类型
6.1 组件实例类型
<script setup lang="ts"> import { ref } from 'vue' import ChildComponent from './ChildComponent.vue' // 获取子组件实例类型 const childRef = ref<InstanceType<typeof ChildComponent> | null>(null) const callChildMethod = () => { // TypeScript 知道 childRef.value 上有哪些方法 childRef.value?.someMethod() } </script> <template> <ChildComponent ref="childRef" /> </template>6.2 事件类型
<script setup lang="ts"> // 模板引用事件处理 const handleInput = (event: Event) => { const target = event.target as HTMLInputElement console.log(target.value) } // 键盘事件 const handleKeydown = (event: KeyboardEvent) => { if (event.key === 'Enter') { console.log('按下了回车键') } } // 鼠标事件 const handleMouseMove = (event: MouseEvent) => { console.log(event.clientX, event.clientY) } </script> <template> <input @input="handleInput" @keydown="handleKeydown" /> <div @mousemove="handleMouseMove">移动鼠标</div> </template>6.3 全局组件类型声明
在components.d.ts中声明全局组件:
// components.d.tsimportMyGlobalComponentfrom'./src/components/MyGlobalComponent.vue'declaremodule'vue'{exportinterfaceGlobalComponents{MyGlobalComponent:typeofMyGlobalComponent}}export{}七、TSX / JSX 在 Vue3 中的使用
7.1 基本 TSX 组件
// HelloWorld.tsx import { ref, defineComponent } from 'vue' interface Props { name: string count?: number } export default defineComponent({ props: ['name', 'count'] as const, setup(props: Props) { const internalCount = ref(props.count || 0) const increment = () => { internalCount.value++ } return () => ( <div class="hello"> <h1>Hello, {props.name}!</h1> <p>Count: {internalCount.value}</p> <button onClick={increment}>Increment</button> </div> ) } })7.2 使用<script setup>风格的 TSX
// Counter.tsx import { ref } from 'vue' interface Props { initial?: number step?: number } const props = withDefaults(defineProps<Props>(), { initial: 0, step: 1 }) const emit = defineEmits<{ change: [value: number] }>() const count = ref(props.initial) const increment = () => { count.value += props.step emit('change', count.value) } export default () => ( <div class="counter"> <span>{count.value}</span> <button onClick={increment}>+{props.step}</button> </div> )7.3 TSX 类型配置
在tsconfig.json中配置 JSX:
{"compilerOptions":{"jsx":"preserve","jsxImportSource":"vue"}}八、类型安全的 Pinia Store
8.1 定义类型安全的 Store
// stores/user.tsimport{defineStore}from'pinia'import{ref,computed}from'vue'// 定义用户接口exportinterfaceUser{id:numbername:stringemail:stringavatar?:string}// 定义 Store 状态接口exportinterfaceUserState{user:User|nulltoken:string|nullisLoggedIn:boolean}exportconstuseUserStore=defineStore('user',()=>{// Stateconstuser=ref<User|null>(null)consttoken=ref<string|null>(localStorage.getItem('token'))// GettersconstisLoggedIn=computed(()=>!!token.value&&!!user.value)constuserName=computed(()=>user.value?.name||'访客')// ActionsconstsetUser=(userData:User)=>{user.value=userData}constsetToken=(newToken:string)=>{token.value=newToken localStorage.setItem('token',newToken)}constlogin=async(credentials:{email:string;password:string})=>{// 模拟 API 调用constresponse=awaitfetch('/api/login',{method:'POST',body:JSON.stringify(credentials)})constdata=awaitresponse.json()as{user:User;token:string}setUser(data.user)setToken(data.token)}constlogout=()=>{user.value=nulltoken.value=nulllocalStorage.removeItem('token')}return{user,token,isLoggedIn,userName,setUser,setToken,login,logout}})8.2 在组件中使用
<script setup lang="ts"> import { useUserStore } from '@/stores/user' const userStore = useUserStore() // TypeScript 提供完整的类型提示 console.log(userStore.userName) // string console.log(userStore.isLoggedIn) // boolean const handleLogin = async () => { await userStore.login({ email: 'user@example.com', password: 'password123' }) } </script>九、常见类型问题与解决方案
9.1 ref 的解包问题
<script setup lang="ts"> import { ref, unref } from 'vue' const count = ref(0) // 在模板中 ref 会自动解包 // 在 JS 中需要 .value console.log(count.value) // 使用 unref 处理可能是 ref 的值 function useMaybeRef(maybeRef: number | Ref<number>) { const value = unref(maybeRef) console.log(value) // number } </script>9.2 响应式对象解构丢失响应性
<script setup lang="ts"> import { reactive, toRefs } from 'vue' interface State { count: number name: string } const state = reactive<State>({ count: 0, name: 'Vue' }) // 错误:解构会丢失响应性 // const { count, name } = state // 正确:使用 toRefs const { count, name } = toRefs(state) // 现在 count 和 name 都是 Ref console.log(count.value) console.log(name.value) </script>9.3 模板引用类型
<script setup lang="ts"> import { ref, onMounted } from 'vue' // 元素引用 const inputRef = ref<HTMLInputElement | null>(null) // 组件引用 const childRef = ref<{ focus: () => void } | null>(null) onMounted(() => { // TypeScript 会提示可能为 null inputRef.value?.focus() childRef.value?.focus() }) </script> <template> <input ref="inputRef" /> <ChildComponent ref="childRef" /> </template>9.4 第三方库类型扩展
// types/shims.d.tsimport{ComponentCustomProperties}from'vue'import{Store}from'pinia'declaremodule'@vue/runtime-core'{interfaceComponentCustomProperties{$store:Store}}// 为全局属性添加类型declaremodule'vue'{interfaceComponentCustomProperties{$http:typeoffetch}}9.5 泛型组件
<!-- GenericList.vue --> <script setup lang="ts" generic="T extends { id: number }"> interface Props { items: T[] keyProp?: keyof T } const props = defineProps<Props>() const emit = defineEmits<{ select: [item: T] }>() </script> <template> <ul> <li v-for="item in items" :key="item.id" @click="emit('select', item)" > <slot :item="item" /> </li> </ul> </template>使用泛型组件:
<script setup lang="ts"> import GenericList from './GenericList.vue' interface User { id: number name: string age: number } const users: User[] = [ { id: 1, name: '张三', age: 25 }, { id: 2, name: '李四', age: 30 } ] const handleSelect = (user: User) => { console.log(user.name) } </script> <template> <GenericList :items="users" @select="handleSelect"> <template #default="{ item }"> {{ item.name }} - {{ item.age }}岁 </template> </GenericList> </template>十、类型系统架构图
十一、常见问题
Q1:为什么ref(null)推断为Ref<any>?
当没有提供初始值或初始值为null时,TypeScript 无法推断具体类型。需要显式声明:
constel=ref<HTMLDivElement|null>(null)constuser=ref<User|null>(null)Q2:如何解决defineProps的复杂类型报错?
对于复杂类型(如从其他文件导入的接口),确保类型是字面量类型或接口:
// 推荐:使用接口interfaceProps{...}constprops=defineProps<Props>()// 避免:使用复杂的类型工具// const props = defineProps<ReturnType<typeof useProps>>() // 可能报错Q3:TypeScript 严格模式下ref的undefined问题
// 在 strictNullChecks 模式下constmaybeUser=ref<User>()// Ref<User | undefined>// 访问时需要判断if(maybeUser.value){console.log(maybeUser.value.name)}// 或使用非空断言(谨慎使用)console.log(maybeUser.value!.name)Q4:如何为 Vue Router 添加类型支持?
// typed-router.d.tsimport'vue-router'declaremodule'vue-router'{interfaceRouteMeta{requiresAuth?:booleantitle?:stringroles?:string[]}}十二、总结
Vue3 的 TypeScript 支持是框架的核心优势之一:
| 特性 | Vue2 | Vue3 |
|---|---|---|
| 源码语言 | Flow / JavaScript | TypeScript |
| Props 类型 | 运行时校验 | 编译时类型 + 运行时校验 |
| Emits 类型 | 无 | 完整类型支持 |
| 响应式类型 | 有限 | 完整泛型支持 |
| TSX 支持 | 需额外配置 | 原生支持 |
| 类型推断 | 较弱 | 优秀 |
核心要点:
- 使用
lang="ts"启用 TypeScript 支持 - 用接口定义 Props 类型,提高可复用性
defineEmits使用元组语法声明事件参数- 复杂类型使用
withDefaults设置默认值 - Pinia 配合 TypeScript 提供完整的 Store 类型安全
- 善用泛型组件处理列表等通用场景
十三、练习题
将现有的一个 Vue3 组件改写为完整的 TypeScript 版本,包括 Props、Emits、模板引用的类型声明。
使用 TypeScript 定义一个表单验证 Hook,要求支持:
- 泛型表单数据类型
- 类型安全的验证规则
- 自动推断错误信息类型
创建一个类型安全的 Event Bus(或使用 mitt),确保事件名称和参数类型在发布和订阅时一致。
