学完这篇,你能从零搭出一个 Vue 3 + TypeScript 项目,并掌握一套可以直接用在团队里的类型定义写法,避免出现满屏 any 以及类型写着写着就崩掉的情况。
第一步:用 create-vue 生成项目骨架
不要手动 npm init 再逐个安装依赖,直接用官方脚手架:
npm create vue@latest my-app
cd my-app
npm install
npm run dev
执行后会进入交互式选项,建议这样勾选:
- TypeScript:Yes
- JSX:按需
- Router / Pinia:Yes
- Vitest / ESLint / Prettier:Yes(团队项目建议全勾)
生成的项目里已经有 tsconfig.json、tsconfig.app.json、tsconfig.node.json 三份配置,vite.config.ts 里也配好了 @ 别名指向 src。
注意:tsconfig.app.json 里的 strict 默认是开的,别为了省事关掉。TS 在 Vue 里最容易出问题的地方就是 strict 一关,Props 传错、undefined 没判断全都放过,等到运行时才出错。
第二步:确认 tsconfig 的关键项
打开 tsconfig.app.json,检查以下几项:
{
"compilerOptions": {
"strict": true,
"noUnusedLocals": true,
"verbatimModuleSyntax": true,
"paths": { "@/*": ["./src/*"] },
"types": ["vite/client"]
}
}
verbatimModuleSyntax 打开后,导入纯类型必须写成 import type { Foo } from './types',否则打包后会残留无用的 import。这个习惯越早养成越好。
第三步:组件 Props 与 Emits 的类型
这是 Vue + TS 最高频的写法,务必用泛型而不是 defineProps({ ... }) 运行时声明:
<script setup lang="ts">
interface Props {
title: string
count?: number
status: 'draft' | 'published' | 'archived'
tags?: string[]
}
const props = withDefaults(defineProps<Props>(), {
count: 0,
tags: () => [],
})
const emit = defineEmits<{
change: [id: number]
submit: [payload: { title: string }]
}>()
</script>
两点要记牢:
- 对象/数组类型的默认值必须用工厂函数(
() => []),直接写 [] 会被多个实例共享同一份引用。
- Emits 用 Vue 3.3+ 的命名元组写法
{ 事件名: [参数列表] },比老式的可调用签名清晰得多。
注意:withDefaults 只对顶层属性生效。如果默认值需要依赖其他 Props 计算,请改用 computed,不要硬塞进 withDefaults。
第四步:给接口响应和业务实体单独建类型目录
在 src/types/ 下集中管理跨组件复用的类型,不要在每个 .vue 里重复定义同一份数据结构:
src/types/
api.ts // 通用响应包装
user.ts // 业务实体
index.ts // 统一导出
api.ts 写一个泛型包装:
export interface ApiResult<T> {
code: number
message: string
data: T
}
export interface PageResult<T> {
list: T[]
total: number
page: number
}
请求函数直接泛型化:
export async function getUser(id: number): Promise<ApiResult<User>> {
return request.get(`/api/user/${id}`)
}
这样调用方拿到的 res.data.nickname 是有完整补全的,改字段时编译期就会报错,不会等到线上才发现少了个字母。
第五步:全局属性与第三方模块的类型扩展
给 app.config.globalProperties 挂东西,或者给 vue-router 的 RouteMeta 加字段,都要用模块扩展(module augmentation),写在 src/types/global.d.ts:
export {}
declare module 'vue-router' {
interface RouteMeta {
title?: string
requiresAuth?: boolean
}
}
declare module 'vue' {
interface ComponentCustomProperties {
$http: typeof import('@/utils/request').default
}
}
注意:文件里必须有 export {} 或者 import 语句,否则会被当成全局脚本,declare module 会失效并报重复声明。
第六步:Composable 也要带类型
自己封装的组合式函数别偷懒,泛型参数写清楚:
export function useList<T>(fetcher: (page: number) => Promise<PageResult<T>>) {
const list = ref<T[]>([]) as Ref<T[]>
...
return { list, load }
}
ref<T[]>([]) 会推断成 Ref<never[]>,要么显式加 as Ref<T[]>,要么用 ref([] as T[])。
第七步:把类型检查放进 CI
开发时 Vite 只做转译、不做类型检查,报错全靠编辑器。所以必须单独加一条命令:
"scripts": {
"type-check": "vue-tsc --build --force",
"lint": "eslint . --ext .vue,.ts --fix"
}
本地提交前或 CI 里跑一次 npm run type-check,才能拦住类型错误。
小结
- 用
npm create vue@latest 起步,别手搓配置
strict 和 verbatimModuleSyntax 保持开启,类型导入一律 import type
- Props 用
defineProps<Props>() + withDefaults,Emits 用命名元组写法
- 接口响应统一走
ApiResult<T> / PageResult<T> 泛型包装,业务类型集中在 src/types/
- 全局扩展写在
.d.ts 里且文件内必须有 export {},否则扩展无效
vue-tsc --build 单独跑,Vite 本身不检查类型