14-TypeScript 与 Vue3

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>

十、类型系统架构图

Vue3 + TypeScript

组件类型

响应式类型

Props/Emits 类型

Store 类型

TSX/JSX 类型

InstanceType

模板引用类型

全局组件声明

Ref<T>

Reactive<T>

ComputedRef<T>

defineProps<Props>

defineEmits<Events>

withDefaults

Pinia Store

State 接口

Action 类型

defineComponent

JSX.Element

泛型组件

十一、常见问题

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 严格模式下refundefined问题

// 在 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 支持是框架的核心优势之一:

特性Vue2Vue3
源码语言Flow / JavaScriptTypeScript
Props 类型运行时校验编译时类型 + 运行时校验
Emits 类型完整类型支持
响应式类型有限完整泛型支持
TSX 支持需额外配置原生支持
类型推断较弱优秀

核心要点

  1. 使用lang="ts"启用 TypeScript 支持
  2. 用接口定义 Props 类型,提高可复用性
  3. defineEmits使用元组语法声明事件参数
  4. 复杂类型使用withDefaults设置默认值
  5. Pinia 配合 TypeScript 提供完整的 Store 类型安全
  6. 善用泛型组件处理列表等通用场景

十三、练习题

  1. 将现有的一个 Vue3 组件改写为完整的 TypeScript 版本,包括 Props、Emits、模板引用的类型声明。

  2. 使用 TypeScript 定义一个表单验证 Hook,要求支持:

    • 泛型表单数据类型
    • 类型安全的验证规则
    • 自动推断错误信息类型
  3. 创建一个类型安全的 Event Bus(或使用 mitt),确保事件名称和参数类型在发布和订阅时一致。