前几天有个后端转前端的朋友问我:“现在搞 Vue3 是不是必须用 TypeScript?” 我反问他:“你写 Java 的时候会故意不写类型吗?” 他笑了笑。实际开发里,TypeScript 确实不是 Vue3 的强制选项,但只要你打开 Vue3 官方文档看几页,所有示例几乎默认都是<script setup lang="ts">;只要你维护一个超过二十个组件的后台管理系统,类型就能像地图一样指路。我从 2021 年开始把公司项目从 Vue2 + JS 迁到 Vue3 + TS,踩过的坑不算少,但整体收益远大于成本。这篇博文不打算讲基础语法,而是把 TypeScript 在 Vue3 应用里的项目级经验拆开,围绕搭建、类型设计、组件通信、后台管理系统、声明文件、JSX 这些真实场景展开,适合准备把项目迁到 TS 的团队,也适合要应付 TypeScript 面试的人系统过一遍。
1. Vue3 与 TypeScript 的化学反应,藏在三个细节里
1.1 Vue3 源码就带着 TS 的基因
Vue3 的内部实现本身就是用 TypeScript 写的,类型定义散落在源码的各个包中,而不是像 Vue2 那样靠vue-class-component这类第三方库强行补充。这一点决定了整个生态的类型基调:Pinia、Vue Router、Element Plus 这些配套库基本都有完整的.d.ts文件,你拿到手就是类型齐全的。
组合式 API 把状态和逻辑放进setup作用域后,TypeScript 的上下文类型推断能力被彻底激活。举个例子:
const count = ref(0) const state = reactive({ name: '张三', age: 30 }) console.log(count.value) // number console.log(state.name) // stringref(0)会自动推导成Ref<number>,reactive({ name: '张三', age: 30 })会自动推导成{ name: string; age: number }。如果哪一行不小心给count.value赋了个字符串,编辑器立刻标红,不用等运行到那一行才崩溃。这个体验比 Vue2 时代this.foo被当成any的时代舒服太多了。
computed和watch也一样。computed(() => props.count + 1)返回的是ComputedRef<number>;watch(source, (newVal, oldVal) => {})的两个回调参数会自动跟着source的类型走。这些看起来不起眼的推导,恰恰是减少“低级报错”最有效的武器。
1.2 响应式 API 的类型推导差异
ref和reactive是白天黑夜的关系,类型上也有明显区别。我见过很多刚开始用 TS 的朋友在两个 API 之间反复横跳,结果把代码写得很拧巴。两者在类型上的关键差异可以列个表:
| 场景 | ref | reactive |
|---|---|---|
| 支持原始值 | 支持 | 不支持 |
| TS 类型 | Ref<T> | T本身 |
| 模板中访问 | 自动解包 | .xxx直接访问 |
| JS 中访问 | ref.value | obj.xxx |
| 整体替换 | ref.value = newObj | 不能直接替换,只能Object.assign或重建 |
| 解构 | 每个属性带.value,但可toRefs | 直接解构会丢失响应性,需toRefs |
reactive的返回类型是UnwrapNestedRefs<T>,也就是说它会把嵌套的ref解包成原始类型。知道这个原理后,遇到“为什么reactive里放了ref,访问时不用.value”就不会懵了。
但我在实际项目里更推荐这样一个原则:页面业务状态优先用ref,对象形态的全局状态优先用reactive配合toRefs。原因是ref在类型上更简单,替换状态的时候不会遇到reactive不能直接赋新对象的问题。你写const user = ref<User | null>(null),后面拿到接口数据直接user.value = res.data,整个过程类型清晰,逻辑也顺。
1.3 面试里围绕“TS + Vue3”的高频考点
准备面试的时候,光会写代码不够,得能解释清楚背后的类型设计。几个我真实遇到过的题:
ref和reactive在类型推导上有什么区别?为什么reactive不能接收原始值?Ref<T>和UnwrapNestedRefs<T>有什么关联?withDefaults(defineProps<T>(), ...)背后是怎么把运行时默认值和类型声明合并的?- 一个组合式函数返回多个响应式变量时,怎么让调用方拿到精确类型而不是
any?
这些问题的答案其实都在 Vue3 的类型源码里。面试官不要求你背源码,但至少要知道ref有解包,reactive会递归处理,UnwrapNestedRefs是为了让模板和 JS 里的访问体验一致。把这一层想明白,项目里的类型问题会少一半。
2. 搭建 TS 版 Vue3 项目的关键决策:别等写了一半才回头改
2.1 用 Vite 脚手架还是手动搭?
现在新建 Vue3 + TS 项目,我基本是无脑选 Vite。创建命令很简单:
npm create vue@latest交互式选项里选择TypeScript,会同时生成tsconfig.app.json、tsconfig.node.json、env.d.ts这些基础设施。vue-tsc也会被加进package.json的build脚本里。这一步能帮你省掉后续很多手动配置工作。
如果你团队还在用 Vue CLI,也不是不行,但要注意 Vue CLI 的 Webpack 链路和 TS 的moduleResolution默认值不一样,容易出现“编辑器不报错,vue-tsc疯狂报错”的分裂现象。新项目老老实实用 Vite 是当下最稳的选择。
2.2 tsconfig 里这些开关决定了体验
tsconfig.app.json里有几个字段值得重点关注:
strict: true:必开。strictNullChecks、noImplicitAny等全开才能把类型安全落到实处。moduleResolution: "bundler":Vite 项目推荐这个,兼容import的很多新写法。baseUrl和paths:用来配置@/别名,避免组件里到处都是../../../../types。jsx: "preserve":如果你准备用 JSX。types: []:数组里写什么,决定了哪些全局类型包会被加载。
paths配别名之后,记得tsconfig.app.json和 Vite 的resolve.alias要同步。我见过不少项目因为两边不一致,IDE 里所有@/开头都标红,实际上运行是好的,体验极差。
2.3 types 文件夹和 .d.ts 声明文件到底怎么用
很多刚接触 TS 的人对“types 文件夹”有误解,以为只要建个文件夹放.d.ts就自动生效。实际上.d.ts文件的加载取决于 tsconfig 的include和文件的模块形态。
通常项目里会有两种需求:
一是声明全局类型,比如window上的自定义字段:
// src/types/global.d.ts export {} declare global { interface Window { trackingId?: string } }因为文件里有export {},它变成了一个模块,所以declare global才有意义;如果文件里没有任何import/export,那写declare interface就默认是全局的。
二是声明模块类型,比如给某个没有类型的库补类型:
declare module 'offline-map' { export function createMap(el: HTMLElement): void export interface MapOptions { center: [number, number] zoom: number } }这两种声明在后台管理系统里非常常见,尤其是当你把全局用户信息、权限点、字典数据挂到window或全局自定义属性上时。
2.4 interface 继承、type 组合与最佳实践
“TypeScript interface 怎么继承”是搜索热词,也是项目里不可避免的操作。其实非常简单:
interface BaseModel { id: string createdAt: string } interface User extends BaseModel { name: string email?: string } interface Admin extends User { role: 'admin' permissions: string[] }interface可以多继承,比如interface A extends B, C {}。而type没有extends关键字,但它可以用交叉类型模拟:
type Product = BaseModel & { price: number }我的经验是:数据模型、组件 props、接口返回结构这类对象结构优先用interface,因为后续可以被扩展、被继承;联合类型、条件类型、工具类型这种类型计算用type。两者能解决的问题有重合,但背后理念不同。硬记一个规则:能interface就interface,需要联合/交叉再换type。
3. 组件类型化的“三板斧”:props、事件与插槽
3.1 defineProps 类型声明与默认值
Vue3.3 以后,defineProps支持完全用类型声明来定义 props,代码会干净很多:
<script setup lang="ts"> interface CardProps { title: string count?: number tags: string[] } const props = withDefaults(defineProps<CardProps>(), { count: 0, tags: () => [] }) </script>这里有两个坑:
withDefaults给引用类型默认值时必须用函数返回,否则多个组件实例会共享同一个数组,改了一个全变了。- 如果只为了在模板里使用
props,其实不需要把返回值赋给变量;但如果要在<script setup>里引用,就必须const props = ...。
有人习惯用运行时声明defineProps({ title: { type: String, required: true } }),也能跑,但类型推导不如类型声明细腻。比如tags作为数组,运行时声明只能限定它是Array,没法限定数组里每个元素是 string。类型声明可以直接tags: string[],把粒度做得更细。
3.2 defineEmits 让事件参数不再裸奔
子组件向外抛事件,最容易出现“名字拼错但没人提醒”的问题。defineEmits加类型后,父组件监听时参数也能自动对齐:
const emit = defineEmits<{ 'update:page': [page: number] 'change': [value: string, old?: string] }>() emit('update:page', 1) // 类型正确 emit('update:page', '1') // TS 报错:number 不能给 stringVue3.3 之后的新语法更直观,事件名写在 key 上,参数列表写在 tuple 里。这样实现v-model:page之类的自定义事件时,父组件模板里写@update:page="handler",handler的第一个参数会自动识别成number,不用再翻代码看子组件到底发射的是什么。
3.3 模板 ref 与组件实例类型
父组件想调用子组件方法,要用ref拿到子组件实例。类型上最标准的写法是:
<script setup lang="ts"> import { ref } from 'vue' import ChildComp from './ChildComp.vue' const childRef = ref<InstanceType<typeof ChildComp> | null>(null) const callChild = () => { childRef.value?.sayHello() } </script> <template> <ChildComp ref="childRef" /> </template>InstanceType<typeof ChildComp>会推导成子组件通过defineExpose暴露出的公开类型。在子组件里:
defineExpose<{ sayHello: () => void }>({ sayHello: () => console.log('hello') })注意<script setup>本质上是闭包,组件实例默认不开放内部方法,必须defineExpose主动暴露。类型和运行时行为是一致的,不存在“类型说能调但实际没暴露”的偏差。
3.4 插槽作用域的类型写法
插槽的类型一直被人忽略,但遇到复杂列表组件时特别有用。Vue3.3+ 提供defineSlots:
defineSlots<{ default: (props: { item: User; index: number }) => any }>()父组件写作用域插槽时,v-slot="{ item, index }"里的item会被推导成User,不需要在父组件里再声明一次类型。这对维护大型后台系统的“列表+筛选+插槽”模式帮助不小。
4. 后台管理系统里,TS 最能体现价值的地方
4.1 把 API 返回的数据模型“钉死”
后台管理系统最怕的就是接口数据结构变了,页面多个地方没同步改。用 TS 先把每个接口的入参和出参定义出来,能提前拦住大部分问题。
我的习惯是在src/api目录下按业务模块维护类型:
// src/api/user.ts export interface PageResult<T> { list: T[] total: number } export interface User { userId: string name: string deptId?: string status: 0 | 1 } export function fetchUserList(params: { page: number; size: number }): Promise<PageResult<User>> { return http.get('/api/user/list', { params }) }status: 0 | 1比number精确得多,接口文档里写“0 正常 1 停用”,代码里直接就能表达。如果再配合后端联调时的 JSON Schema 生成工具,类型和接口字段几乎零成本对齐。
4.2 扩展路由 meta 类型
后台系统几乎都要在路由的meta上挂标题、图标、权限码、KeepAlive 这些字段。但 Vue Router 默认的RouteMeta是空对象,直接写meta.keepAlive会报“属性不存在”。解决方式就是模块扩展:
// src/types/router.d.ts import 'vue-router' declare module 'vue-router' { interface RouteMeta { title?: string icon?: string keepAlive?: boolean permission?: string } }这个文件只要被 tsconfiginclude,所有route.meta.xxx都能获得类型提示。若依这类开源后台模板里经常报的Property 'meta' does not exist on type ...,八成就是缺少这个扩展。
4.3 Pinia store 的类型化写法
Pinia 的类型推导很聪明,但新手会在state初始值这里翻车。比如 token 初始值是null,如果不给类型,this.token = 'xxx'的时候可能被推断成null。正确写法是这样:
import { defineStore } from 'pinia' interface UserInfo { id: string name: string avatar: string } export const useUserStore = defineStore('user', { state: () => ({ token: null as string | null, userInfo: null as UserInfo | null, }), actions: { async login(params: { username: string; password: string }) { // 这里 this.token 会被推导为 string | null }, }, })关键点在于null as string | null这种显式标注。如果你只写token: null,TS 会认为它永远是null,后续赋值全部报错。
4.4 动态增删表单行的类型设计
后台系统经常有“动态添加删除表单一行数据”的需求,也就是搜索热词里那个场景。没有类型的时候,reactive([])可能被推导成never[],导致 push 任何对象都报错。正确做法是定义一个行模型:
interface CartItem { key: string name: string price: number } const formItems = ref<CartItem[]>([]) const addRow = () => { formItems.value.push({ key: crypto.randomUUID(), name: '', price: 0, }) } const removeRow = (key: string) => { formItems.value = formItems.value.filter(item => item.key !== key) }给ref<CartItem[]>加上泛型后,v-model="item.name"才能正确识别出name是 string。别看这个点简单,项目里大量“no implicit any”错误就是从这里冒出来的。
4.5 若依 Vue3 项目常见 TS 报错定位
若依的前端工程是基于 Vue3 + TS + Vite 的重型模板,很多人会直接拉下来用,然后遇到一堆 TS 报错。我见过的高频错误有三类:
Cannot use namespace 'X' as a type:多半是import { X } from '...'把值和类型混在一起,改成import type { X }就行。Binding element 'xxx' implicitly has an 'any' type:解构出来的变量没有类型。严格模式下noImplicitAny会直接报错,解决办法要么给函数参数加类型,要么用泛型约束。Cannot find module '@/api/xxx':tsconfig 的paths没配或者没重启 TS Server,检查paths是否对应@别名。
排查链路一般是:先看 Vite 或 IDE 终端里的完整错误信息,再定位到具体文件;如果是模块解析类错误,先看 tsconfig;如果是类型不匹配,优先查接口类型定义。不要看到报错就: any糊过去,不然类型系统最终会失去资金。
5. 进阶:声明文件、JSX 与泛型在业务中的实战
5.1 手写 .d.ts 的规范
很多人搜索“TypeScript 类型声明文件怎么编写”,说明这确实是实际需求。.d.ts的核心作用有两个:描述已有 JS 模块的对外类型,以及补充全局环境类型。
给第三方库补模块类型:
declare module 'some-lib' { export function run(options: { mode?: 'dev' | 'prod' }): void }给全局变量补类型:
declare const __BUILD_TIME__: string如果要给window挂自定义属性,但文件里又有import/export,就必须用declare global包裹,否则声明不会生效。这是最容易踩的坑。
5.2 第三方库没有类型时怎么“补课”
比如项目里接离线地图打点,用的库只有 JS 版本,没有类型文件。这时候不要写export default any糊弄,因为那等于放弃类型检查。诚实地按库的文档写一份最小声明:
declare module 'offline-map-lib' { export interface MapInstance { destroy(): void setCenter(lng: number, lat: number): void } export function initMap(el: HTMLElement, options: { center: [number, number]; zoom: number }): MapInstance }这样补出来的类型虽然不全,但后续调用map.destroy()这类方法时,至少有自动补全和参数检查,比any有用得多。
5.3 Vue3 中使用 JSX 的类型配置
不是所有场景都用 SFC,偶尔会遇到需要写.tsx的情况。Vue3 的 JSX 转换依赖@vitejs/plugin-vue-jsx,tsconfig 里需要把jsx设为preserve,再配合 TS 类型推导:
import { defineComponent, ref } from 'vue' export default defineComponent({ setup() { const count = ref(0) return () => ( <button onClick={() => count.value++}>{count.value}</button> ) }, })注意在 JSX 里响应式变量必须写.value,模板语法会自动解包,但 JSX 本质是 render 函数,不会给你解包。类型上count.value是 number,页面也就能正确渲染。
5.4 泛型和条件类型在业务里的一个真实用例
很多人对泛型停留在“定义函数时用 T”,但业务里最有价值的两个场景是:Promise 包装和嵌套类型提取。
比如封装请求方法时,我希望传入的是一个类型,返回的是解包后的数据类型:
type Unwrap<T> = T extends Promise<infer U> ? U : T async function request<T>(url: string): Promise<T> { const res = await http.get(url) return res.data as T } type UserPromise = ReturnType<typeof request<User>> type UserResult = Unwrap<UserPromise> // Userinfer U的意思是“我在这里占个位置,等 TS 从Promise<User>里把User推导出来”。这个技巧在写通用表格组件、通用弹窗组件时特别常见。面试时能讲清楚infer,基本就是中级以上的 TS 水准。
6. 类型检查解决不了的那些坑:兼容性、性能与边界情况
6.1 一个被误认为 TS 问题的浏览器兼容案例
热搜里有条提到“Vue3 项目在 Edge 浏览器中有时候无法关闭浏览器右上角的最小化按钮”。我当时第一反应是类型或布局 bug,结果排查了大半天,最后发现是浏览器开发者工具扩展拦截了快捷键,跟 Vue3、TS 毫无关系。这个案例给我的教训是:不是所有运行时报错都能靠类型系统兜住,但类型系统至少能把“代码本身的问题”和“外部环境的问题”快速分开。
如果在 Edge 里遇到页面表现异常,先看控制台有没有明确的 JS 报错,再试无痕模式排除扩展,最后才是检查代码逻辑。团队里有人习惯一出问题就甩锅框架,其实环境因素占比不低。
6.2 vue-tsc 类型检查与构建性能的取舍
vue-tsc做类型检查很强大,但项目大了之后,每次npm run build都先跑一遍vue-tsc --noEmit会很耗时。我的做法是:本地开发用vite-plugin-checker异步检查,CI 里再跑完整的vue-tsc --noEmit拦截错误。
import checker from 'vite-plugin-checker' export default defineConfig({ plugins: [ checker({ vueTsc: true }), ], })不过这个插件默认会吃不少内存,如果开发机内存不大,建议只在函数里搭一个手动触发的npm run type:check,而不是一直跑在后台。类型检查是给发布质量兜底的,不是为了天天打断你写代码。
6.3 2026 年新建 Vue3 项目的选型建议
如果现在要开一个全新后台管理项目,我的默认组合是:Vue3 + Vite + TS + Pinia + Vue Router + Element Plus,构建脚本里加上type:check,tsconfig 全开 strict。自动导入插件能省事,但一定要让它们生成对应的.d.ts文件,否则模板里看起来没 import 的组件类型会变成any,等于白开 TS。
另外,如果项目会用到离线地图、OFD 查看这类更偏边缘的功能,先把第三方库的类型声明文件写好再动业务。否则后续每个人都可能在“这个库到底有没有这个方法”上反复翻源码。
如果让我从零开始搭一个 Vue3 项目,我会把类型设计放在功能开发之前,尤其是types目录和 API 数据定义。这条习惯我在几个后台管理系统里反复验证过——前两周多花半天写类型,后面每个迭代能省下好几个小时。TypeScript 给你带来的不是“不能写错”的束缚,而是把错误提前到编译期的安全感。你在实际项目里遇到的报错,九成都能在 tsconfig 或.d.ts里找到答案。