第 9 章 · TypeScript + Vue 3 实战
本章目标:配置 vue-tsc、Volar、<script setup lang="ts">;掌握组件 props / emits 的 defineProps、defineEmits 类型写法;为 Pinia store 与 Vue Router 补全类型;按步骤将 academy-demo-vue 从 JS 渐进迁移到 TS。
学时建议:4~5 小时(含 2 小时迁移跟练)
前置:本模块 ch01~ch03、ch08;academy-demo-vue 已能 npm run build。
9.1 工具链:Volar 与 vue-tsc
| 工具 | 作用 |
|---|---|
| Volar(VS Code / Cursor 扩展) | .vue 单文件组件 TS 支持、模板类型检查 |
| vue-tsc | 包装 tsc,理解 Vue SFC 与模板 |
| @vue/tsconfig | 官方推荐 tsconfig 基座 |
禁用 Vetur(Vue 2 时代扩展),与 Volar 冲突。
cd academy-demo-vue
npm install -D typescript vue-tsc @vue/tsconfig
package.json:
{
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"typecheck": "vue-tsc --noEmit"
}
}
tsconfig.json(精简推荐):
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"compilerOptions": {
"baseUrl": ".",
"paths": { "@/*": ["src/*"] },
"strict": true,
"types": ["vite/client"]
}
}
vite.config.ts(由 .js 改名):
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})
src/vite-env.d.ts:
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}
行业案例 · 贤紫优选商城:商户后台 2025 起 CI 流水线增加vue-tsc --noEmit门禁;合并前类型失败则不可发布,与 ESLint 并列。Volar「接管模式」下模板内course.title自动推断,减少 Props 传错。
9.2 script setup lang="ts"
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { Course } from '@/types/course'
import { filterCourses } from '@/utils'
const courses = ref<Course[]>([])
const keyword = ref<string>('')
const filtered = computed(() =>
filterCourses(courses.value, keyword.value)
)
onMounted(async () => {
const res = await fetch('/mock/courses.json')
courses.value = await res.json()
})
</script>
<template>
<input v-model="keyword" type="search" aria-label="搜索课程" />
<p v-if="filtered.length === 0">暂无匹配课程</p>
<ul>
<li v-for="c in filtered" :key="c.id">{{ c.title }}</li>
</ul>
</template>
| 要点 | 说明 |
|---|---|
lang="ts" | 启用 TS 解析 |
ref<Course[]> | 显式泛型,空数组推断更准确 |
import type | 仅类型导入,打包可擦除 |
| 模板 | Volar 将模板当作 TS 检查 |
main.ts 入口:
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './assets/main.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
9.3 defineProps 与 defineEmits
9.3.1 基于类型的 Props(推荐)
<script setup lang="ts">
import type { Course } from '@/types/course'
const props = defineProps<{
course: Course
compact?: boolean
}>()
const emit = defineEmits<{
select: [id: string]
favorite: [id: string, value: boolean]
}>()
function onClick() {
emit('select', props.course.id)
}
function onFav() {
emit('favorite', props.course.id, true)
}
</script>
<template>
<article
class="course-card"
:class="{ compact: compact }"
@click="onClick"
>
<h3>{{ course.title }}</h3>
<button type="button" @click.stop="onFav">收藏</button>
</article>
</template>
9.3.2 默认值:withDefaults
<script setup lang="ts">
withDefaults(
defineProps<{
course: Course
compact?: boolean
tagLimit?: number
}>(),
{
compact: false,
tagLimit: 3
}
)
</script>
9.3.3 运行时声明(兼容 JS 迁移)
<script setup lang="ts">
import type { PropType } from 'vue'
import type { Course } from '@/types/course'
defineProps({
course: {
type: Object as PropType<Course>,
required: true
},
compact: { type: Boolean, default: false }
})
</script>
迁移期:老组件用 PropType,新组件用类型 defineProps<{}>。
9.4 组件 ref 与模板 ref
<script setup lang="ts">
import { ref } from 'vue'
import SearchInput from './SearchInput.vue'
const searchRef = ref<InstanceType<typeof SearchInput> | null>(null)
function focusSearch() {
searchRef.value?.focus()
}
</script>
<template>
<SearchInput ref="searchRef" />
</template>
InstanceType<typeof Component> 是获取组件实例类型的标准写法。
9.5 Pinia Store 类型
// src/stores/courses.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Course } from '@/types/course'
import { filterCourses } from '@/utils'
export const useCoursesStore = defineStore('courses', () => {
const list = ref<Course[]>([])
const keyword = ref('')
const activeTag = ref<string | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const filtered = computed(() =>
filterCourses(list.value, keyword.value, activeTag.value ?? undefined)
)
async function fetchCourses() {
loading.value = true
error.value = null
try {
const res = await fetch('/mock/courses.json')
if (!res.ok) throw new Error(res.statusText)
list.value = await res.json()
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
function setKeyword(v: string) {
keyword.value = v
}
function setTag(tag: string | null) {
activeTag.value = tag
}
return {
list,
keyword,
activeTag,
loading,
error,
filtered,
fetchCourses,
setKeyword,
setTag
}
})
组件中使用:
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCoursesStore } from '@/stores/courses'
const store = useCoursesStore()
const { filtered, loading, error } = storeToRefs(store)
</script>
storeToRefs 保持 ref 响应性;直接解构 store 会丢响应性。