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.ts
import MyGlobalComponent from './src/components/MyGlobalComponent.vue'
declare module 'vue' {
export interface GlobalComponents {
MyGlobalComponent: typeof MyGlobalComponent
}
}
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.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
// 定义用户接口
export interface User {
id: number
name: string
email: string
avatar?: string
}
// 定义 Store 状态接口
export interface UserState {
user: User | null
token: string | null
isLoggedIn: boolean
}
export const useUserStore = defineStore('user', () => {
// State
const user = ref<User | null>(null)
const token = ref<string | null>(localStorage.getItem('token'))
// Getters
const isLoggedIn = computed(() => !!token.value && !!user.value)
const userName = computed(() => user.value?.name || '访客')
// Actions
const setUser = (userData: User) => {
user.value = userData
}
const setToken = (newToken: string) => {
token.value = newToken
localStorage.setItem('token', newToken)
}
const login = async (credentials: { email: string; password: string }) => {
// 模拟 API 调用
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(credentials)
})
const data = await response.json() as { user: User; token: string }
setUser(data.user)
setToken(data.token)
}
const logout = () => {
user.value = null
token.value = null
localStorage.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.ts
import { ComponentCustomProperties } from 'vue'
import { Store } from 'pinia'
declare module '@vue/runtime-core' {
interface ComponentCustomProperties {
$store: Store
}
}
// 为全局属性添加类型
declare module 'vue' {
interface ComponentCustomProperties {
$http: typeof fetch
}
}
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 无法推断具体类型。需要显式声明:
const el = ref<HTMLDivElement | null>(null)
const user = ref<User | null>(null)
Q2:如何解决 defineProps 的复杂类型报错?
对于复杂类型(如从其他文件导入的接口),确保类型是字面量类型或接口:
// 推荐:使用接口
interface Props { ... }
const props = defineProps<Props>()
// 避免:使用复杂的类型工具
// const props = defineProps<ReturnType<typeof useProps>>() // 可能报错
Q3:TypeScript 严格模式下 ref 的 undefined 问题
// 在 strictNullChecks 模式下
const maybeUser = ref<User>() // Ref<User | undefined>
// 访问时需要判断
if (maybeUser.value) {
console.log(maybeUser.value.name)
}
// 或使用非空断言(谨慎使用)
console.log(maybeUser.value!.name)
Q4:如何为 Vue Router 添加类型支持?
// typed-router.d.ts
import 'vue-router'
declare module 'vue-router' {
interface RouteMeta {
requiresAuth?: boolean
title?: string
roles?: 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),确保事件名称和参数类型在发布和订阅时一致。
更多推荐
所有评论(0)