下载工作台
前端框架精通

TypeScript + Vue 3 实战

试读上半部分 · 解锁后可读全文

第 9 章 · TypeScript + Vue 3 实战

本章目标:配置 vue-tsc、Volar、<script setup lang="ts">;掌握组件 props / emitsdefinePropsdefineEmits 类型写法;为 Pinia storeVue 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 会丢响应性。

以下内容需解锁后阅读

试读已结束。解锁本章 ¥5.00,或开通年度会员畅读全部教程。
年度会员 ¥199.00/年; 小紫 AI 工作台有效会员 ¥99.00/年

正文仅在服务端鉴权后下发,未付费无法获取下半部分内容。