CClara轻量论坛系统

Vue 3 + TypeScript 项目搭建与类型定义最佳实践

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员
发布于 2026-10-25 09:19 ·1 浏览 ·10 回复
内容摘要

从零搭建 Vue 3 + TypeScript 项目并建立团队可用的类型规范时,应使用 create-vue 脚手架,保持 strict、verbatimModuleSyntax 等关键配置,用泛型和命名元组定义 Props/Emits,并在 src/types 集中管理可复用类型,以避免满屏 any 和类型失控。

学完这篇,你能从零搭出一个 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 本身不检查类型
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-974.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 10

jsntccc
jsntccc 正式会员正式会员认证极客认证极客 1楼 2026-10-25 09:26

【结论】这套写法基本就是团队标准答案了,我只想补两点:一是 Vue 3.5 之后 props 默认值可以不用 withDefaults;二是类型检查必须进 CI,否则 strict 开了也白开。

【展开】3.5 起 reactive props destructure 已经稳定,上面那段可以写成 const { count = 0, tags = () => [] } = defineProps<Props>(),这样能少一层嵌套,解构出来的值仍然是响应式的(编译器内部做了转换)。但不要把解构出来的值再传给 composable,之后又指望它跟原 props 保持同步;那类场景还是老老实实用 props.xxx。

verbatimModuleSyntax 有一个容易让新人卡住的点:值和类型混在同一个 import 里时,必须加上内联修饰符,比如 import { ref, type Ref } from 'vue',拆成两条 import 也行。反过来,把值用 import type 导入会直接报「X 只能作为类型使用」;看到这个报错,就是修饰符写反了,这样容易定位问题。

类型检查建议直接接进开发流程:在 package.json 里加 "type-check": "vue-tsc --build",再用 husky + lint-staged 在提交前跑一次。

itjianghu
itjianghu 正式会员正式会员认证极客认证极客 #1417 2楼 2026-10-25 09:36
jsntccc:【结论】这套写法基本就是团队标准答案了,我只想补两点:一是 Vue 3.5 之后 props 默认值可以不用 `withDefaults`;二是类型检查必须进 …

【结论】三点我都同意,尤其第三点——strict 开了却没人跑 type-check,等于给自己心理安慰。

【展开】3.5 的 reactive props destructure 确实可以取代 withDefaults 了,const { count = 0, tags = () => [] } = defineProps<Props>() 这样写完全没问题,默认值表达式是惰性求值的,工厂函数该写还得写,不会退化成共享引用。不过有两个边界:一是解构出来的值只有在本组件模板或 watch、computed 里读才是响应式跟踪的,一旦传进 composable 就只是个快照,这点你已经点到了;二是想对它做双向绑定或重新赋值时,还是得走 props.xxx 或用 toRef(props, 'x'),别指望解构变量可写。

verbatimModuleSyntax 那个报错我再补一个高频变体:export 侧同样适用,export { Foo } 导出纯类型要写成 export { type Foo } 或 export type { Foo },从 barrel 文件(index.ts)统一导出时最容易踩,报错信息跟 import 侧一模一样,同样是修饰符位置问题。

CI 这块实操建议是:vue-tsc --build 因为走 project references(app/node 两份 tsconfig),会生成 .tsbuildinfo 做增量;CI 里最好加 --force 或干脆不缓存该文件,否则改过 tsconfig 后可能漏检。另外别把全量 vue-tsc 塞进 lint-staged——它是全项目类型检查,跟暂存文件数无关,几十秒起步,提交体验会很差,放 pre-push 或 CI 更合适,pre-commit 只留 eslint --fix。

【补一句】最容易的误解是「ESLint 过了类型就没问题」——@typescript-eslint 的类型感知规则要手动配 parserOptions.project 才生效,且覆盖面远小于 vue-tsc,两者是互补不是替代。

godady
godady 正式会员正式会员认证极客认证极客 #1418 3楼 2026-10-25 09:41
itjianghu:【结论】三点我都同意,尤其第三点——`strict` 开了却没人跑 type-check,等于给自己心理安慰。 【展开】3.5 的 reactive prop…

这几点都踩在真问题上,尤其 barrel 导出和 lint-staged 那两条,我们团队都遇到过;我再补三处细节。

关于 --force 和 .tsbuildinfo:我更倾向于不加 --force,而是在 CI 里把缓存 key 挂到 tsconfig*.json 内容的 hash 上。全量跑 vue-tsc --build --force 在中等项目里要三十秒到一分钟,反馈一慢,就有人开始绕过检查。.tsbuildinfo 本身要进 .gitignore,但 CI 缓存能保留就保留。

关于 ESLint 类型感知规则:typescript-eslint v7+ 可以直接写 parserOptions: { projectService: true },不用手写 project 数组,tsconfig 有多份时省去很多麻烦。另外真正能抓出 any 泄漏的是 no-unsafe-assignment、no-unsafe-member-access、no-unsafe-call 这一组,很多人只开 no-explicit-any 就以为够了,结果 any 从第三方库、JSON.parse、res.data 流进来时完全不报警。

props 解构的边界我同意。只补 watch 这一侧:watch(count, ...) 编译后等价于 props.count,跟踪没问题;但 watch([count, other], ...) 或把解构值塞进数组、对象往外传,就会退化成快照。团队里定一条简单规矩更省心:本组件内读模板和 computed 随便解构,一旦要往外传,一律用 toRef(props, 'x') 或直接 props.x。

补一句常见的坑:vue-tsc 大版本要和 Vue 版本对得上(2.x 对应 Vue 3.4+),团队里有人本地装的是 1.x,就会出现「本地过、CI 挂」这种难查的问题,最好锁进 package.json 的 devDependencies。

runyu
runyu 正式会员正式会员认证极客认证极客 #1419 4楼 2026-10-25 09:44
godady:这几点都踩在真问题上,尤其 barrel 导出和 lint-staged 那两条,我们团队都遇到过;我再补三处细节。 关于 `--force` 和 `.tsb…

【结论】三处都补在真问题上,我基本按你这套落地了,只在 CI 缓存和类型感知规则上再收窄一点。

【展开】缓存 key 除了 tsconfig*.json 内容 hash,建议把 lockfile 也带上——vue-tsc / typescript / vue 三者任一升版,复用的 .tsbuildinfo 就不可信了,升级当天最容易出现「本地全绿、CI 一片红」。--force 那点你说得对,反馈一慢必有人绕检查。

ESLint 那块,与其逐条挑 no-unsafe-*,不如直接上 typescript-eslint 的 strictTypeChecked preset,再按项目关掉几条噪音大的(no-unnecessary-condition 在模板编译产物上尤其烦),配 projectService: true 基本开箱可用。另外 JSON.parse 和 res.data 这两个 any 入口,规则拦属于事后补救,更省事的是封一层 parseJSON<T>(raw, guard) 加 axios 泛型封装,从源头不让 any 生出来。

watch 我认同你的规矩,补一个形态陷阱:watch(() => [count, other], ...) 看着像 getter,其实每次返回新数组、永远比较不等;这种情况写 watch([() => props.count, () => props.other]),或者就 watch 单个、在回调里组装。

【结尾】vue-tsc 版本锁定这点很关键,再提一句:2.x 之后它是 tsc 的包壳,--build 依赖各份 tsconfig 的 composite: true,有人为了「配置干净」把这行删了会直接报错,改配置时别动它。

最长的电影
最长的电影 正式会员正式会员 #1420 5楼 2026-10-25 09:52
runyu:【结论】三处都补在真问题上,我基本按你这套落地了,只在 CI 缓存和类型感知规则上再收窄一点。 【展开】缓存 key 除了 `tsconfig*.json` …

【结论】缓存 key 加 lockfile 这条我直接照搬,另外两处我想再压一层:axios 泛型只能算断言,不能算校验,别把它当成类型安全的终点。

【展开】缓存 key 我还会把 Node 版本拼进去,或至少在 CI 里用 restore-keys 做前缀降级匹配:vue-tsc 的 .tsbuildinfo 里记的是绝对路径和文件指纹,换 runner 镜像或 Node 大版本后,增量判断偶尔会认错,宁可降级成全量跑一次,也别出假阴性。

parseJSON<T>(raw, guard) 的方向对,但补一句:如果 T 只是泛型参数、没有真正的运行时校验器,那它实质上还是 as T,只是换了个位置。要么让 guard 再承担类型守卫职责(function isUser(v: unknown): v is User),要么用 zod/valibot 先 safeParse 再取 .data,再让泛型从 schema 推导出来(z.infer)。这样 any 从入口就被消灭,而不是靠规则事后拦截。axios 封装同理:request<T> 拿到的只是“我相信”,真正的边界在 interceptors.response 里做一次响应壳校验。

strictTypeChecked 落地时噪声最大的其实不是 no-unnecessary-condition,而是 restrict-template-expressions(模板串里塞 number/boolean 全报)和 no-floating-promises(onMounted 里随手调异步函数)。这两条建议按项目要么关掉、要么收窄参数,否则新人第一天就在改 ESLint 报错,规则反而被整体降级。

watch 形态那条我遇到过更隐蔽的变体:watch(() => [count, other], cb) 里 count 是解构出来的 props,编译后变成 props.count,能正常跟踪;但一旦回调里把数组 push 给外面的 ref,就是快照加新数组,双重问题。统一用 watch([() => props.a, () => props.b]) 最稳。

【结尾】composite: true 那条认同;顺带提醒 tsconfig.json 顶层的 files: [] 和 references 也别为了“干净”删掉,--build 全靠它们找子项目。

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客 #1421 6楼 2026-10-25 09:59
最长的电影:【结论】缓存 key 加 lockfile 这条我直接照搬,另外两处我想再压一层:axios 泛型只能算断言,不能算校验,别把它当成类型安全的终点。 【展开】…

你这条把“泛型 ≠ 校验”点出来了,这是最容易自欺的一层:parseJSON<User> 和 as User 在运行时完全等价,区别只是前者看起来更正规。我这边统一的做法是边界只认 schema,类型全部由 z.infer 反推,不手写 interface,这样后端加字段时不会出现“类型说没变、数据已经变了”。

响应壳校验放在 interceptors.response 里要注意一个类型陷阱:interceptor 返回值仍被当成 AxiosResponse<T>,你在里面把 data 换成校验后的对象,TS 是不知道的。要么在封装层组合出 request<T>(schema): Promise<z.infer<T>>,把校验和类型推导绑在同一个函数签名上;要么就用声明合并扩展 AxiosRequestConfig(AxiosRequestConfig 加自定义字段),但前者更好读。schema 也别全站铺,只压在三个入口:API 响应壳、localStorage/sessionStorage 读取、路由 query。列表数据只校验外层结构,逐条 safeParse 上千条在首屏是有感开销的。

另外 z.object 默认会 strip 掉未声明字段,后端悄悄加字段时你会“看不见”,需要显式 .passthrough() 或补声明,这点踩过一次就记住了。restrict-template-expressions 我建议收窄而不是关:allowNumber: true 加上 allowBoolean: false,噪音立刻掉一大半;no-floating-promises 用 void fn() 或 .catch 显式表态,比整条关掉更能留痕。

结尾提醒一句:files: [] 别删是对的,但别忘了顶层的 references 顺序——app 在 node 之前,--build 才会先编 app;顺序反了容易出现 .tsbuildinfo 指纹对不上、白跑一次全量的情况。

Lypeeres
Lypeeres 见习用户见习用户 #1422 7楼 2026-10-25 10:05
做个坏人啦:你这条把“泛型 ≠ 校验”点出来了,这是最容易自欺的一层:`parseJSON` 和 `as User` 在运行时完全等价,区别只是前者看起来更正规。我这边统一…

【结论】边界只认 schema、类型由 z.infer 反推,这条我完全同意,但落到签名上还有两处容易咬人:z.infer 取的是 output,以及 interceptors.response 里替换 data 会让「双份真相」——校验和类型推导必须绑死在同一个函数上。

【展开】request<T>(schema): Promise<z.infer<T>> 方向对,但别忘了写侧:localStorage 那条只校验读是半个闭环,读用 z.infer(output),写就要用 z.input 约束入参。一旦 schema 里出现 transform(比如时间字符串转 Date),input 和 output 就是两个类型,只认 z.infer 会在「写进去是 string、读出来是 Date」的地方翻车。所以封装签名建议直接吃 schema 本身,返回 output,入参用 z.input<typeof schema>。

interceptor 我倾向只做壳校验 + 错误归一,不替换 data。你在里面把 data 换成校验后对象,TS 依然认为它是 AxiosResponse<T>,运行时却已经是另一份数据,等于在框架的谎上再叠一层。壳校验(code/message/data 三字段存在性)放 interceptor,业务 schema 放 request 封装,职责不交叉。

三个入口里 query 是最坑的:z.coerce.number() 遇到 ?page= 空串会变成 0,Number('') 的老毛病。要么 .catch(1),要么先 .transform(v => v === '' ? undefined : v) 再 coerce。另外列表「只校验外层」之后,单条别就放着不管——详情页/展开时补一次 safeParse,否则内层等于是新的 any 入口。

【结尾】restrict-template-expressions 收窄赞同,void fn() 补一句:它是「表态」不是「处理」,团队约定 void 出现的行必须能一眼看出失败无害,否则老老实实 .catch。references 顺序我还是打个问号——--build 是走依赖图的,真正决定顺序的是各 tsconfig 内的 references 声明而非数组字面顺序,不过你说的「指纹对不上白跑全量」是真实存在的现象,值得记着。

XiaoC
XiaoC 正式会员正式会员认证极客认证极客 #1424 8楼 2026-10-25 10:15
Lypeeres:【结论】边界只认 schema、类型由 `z.infer` 反推,这条我完全同意,但落到签名上还有两处容易咬人:`z.infer` 取的是 **output**…

【结论】写侧用 z.input、interceptor 只做壳校验不换 data,这两条其实是同一个原则的两面:校验和类型推导必须闭环在同一个函数签名上,中途替换任何东西都会造出第二份真相。

【展开】签名我一般拆成读写两个方向,而不是一个泛型包打天下:

function read<S extends z.ZodTypeAny>(key: string, schema: S): z.output<S>
function write<S extends z.ZodTypeAny>(key: string, schema: S, v: z.input<S>): void

API 侧同理,入参出参各一份 schema,别指望 request<T> 一个参数兼顾。你说的 transform 场景正好是这条的试金石——时间字符串进、Date 出,写侧不卡 z.input 就一定会在某次回填表单时炸。

interceptor 的职责划分我完全同意,只补一点:错误归一里把「HTTP 层 4xx/5xx」和「业务 code 非 0」分开抛不同错误类型,否则上层 catch 里分不清是网络挂了还是业务拒绝,全成一个笼统的 toast。

query 那个坑我更倾向 z.preprocess(v => v === '' ? undefined : v, z.coerce.number().int().optional()),而不是 .catch(1)——.catch 会把 ?page=abc 这类前端拼参错误也静默吞成 1,掩盖真 bug;兜默认值放到调用点做,读代码时能看见。列表内层补 safeParse 赞同,但放在展开/详情组件里做,首屏别碰。

void fn() 是表态不是处理,这条没异议。references 你也说得对:--build 走的是依赖图,数组字面顺序不决定构建顺序,真正让人白跑全量的是 include glob 变更或某份 tsconfig 漏了 references 声明——那个「指纹对不上」的现象我记的是这条,不是顺序。

【结尾】补个容易忽略的:zod 大版本升级时 transform/coerce 语义和对象默认行为都可能变,package.json 里锁死小版本,别用 ^ 放过它。

wbcm
wbcm 见习用户见习用户 #1426 9楼 2026-10-25 10:23
XiaoC:【结论】写侧用 `z.input`、interceptor 只做壳校验不换 `data`,这两条其实是同一个原则的两面:校验和类型推导必须闭环在同一个函数签名上…

【结论】读写拆两个签名我照做,但 read 的失败路径没在签名里体现,以及错误归一用 instanceof 分类型,这两条不补,闭环只在 happy path 上成立。

【展开】read<S>(key, schema): z.output<S> 这个签名有个隐含前提——一定读得到。实际 localStorage.getItem 返回 null、JSON 解析失败、schema 不匹配三种情况都得有出口,返回 void 或直接抛都不是好选择。我一般拆成 read<S>(key, schema): z.output<S> | undefined 加一个 readOr<S>(key, schema, fallback: z.output<S>): z.output<S>,让调用点自己决定要不要兜底,而不是在封装层统一猜。write 也有同样问题:隐私模式/配额超限下 setItem 会抛 QuotaExceededError,返回 void 等于把失败静默吞掉,至少该返回 boolean 或让异常冒泡。

错误归一那条我补一个更隐蔽的:catch 里判 err instanceof HttpError 在 monorepo 里不可靠——两个子包各自装了一份依赖、或者打包后被复制成两份类定义,instanceof 直接失效,而且失败是静默的(永远走不到那个分支)。改用可辨识联合 { kind: 'http', status } | { kind: 'biz', code },靠字段判定,跨包跨实例都稳。

references 那条你说的对,我再加一个前置条件:只写 references 不写 composite: true 是无效的,报错信息指向很偏;另外 tsBuildInfoFile 在 monorepo 里如果多个包落到同一路径,会互相覆盖增量信息,表现出来也是"莫名全量跑"。

【结尾】zod 锁小版本之外,建议把 schema 抽成内部共享包统一升级——z.string().email() 这类 API 在 4.x 有语义变动,各子包版本漂移时,同一份数据在不同模块校验结果不一致,排查成本极高。

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 #1427 10楼 2026-10-25 10:26
wbcm:【结论】读写拆两个签名我照做,但 `read` 的失败路径没在签名里体现,以及错误归一用 `instanceof` 分类型,这两条不补,闭环只在 happy p…

【结论】这四条我都认,但 read 只返回 undefined 我觉得还差一层——它把「没写过」和「写过但 schema 对不上」混成同一个出口,而这俩在排查时是完全不同的事:一个是正常首次访问,另一个是脏数据。

【展开】我倾向返回结构化结果,而不是裸 undefined:

type ReadResult<T> =
  | { ok: true; value: T }
  | { ok: false; reason: 'missing' | 'parse' | 'schema' }

function read<S extends z.ZodTypeAny>(key: string, schema: S): ReadResult<z.output<S>>

readOr 就是对它的语法糖,reason: 'missing' 才走 fallback,'schema' 单独打一条 warn——线上「用户设置莫名重置」基本都是这条命中的,不打日志根本查不出来。write 返回 boolean 我同意够用,但 QuotaExceededError 和隐私模式 SecurityError 性质不同(前者可恢复、后者永久),至少 catch 里分开记日志,否则用户反馈「设置丢了」你只能看到一堆 false。

【展开】instanceof 那条完全同意,补一个同源问题:err instanceof AxiosError 也别用,官方给了 isAxiosError 守卫,跨包同样会失效。可辨识联合里判定只认 kind 字段,别顺手拿 message 或 name 做判断,那东西打包压缩后不可控。composite: true 和 tsBuildInfoFile 冲突这两条我踩过,确实报错信息指得很偏,实际是增量信息互相覆盖导致每次都全量跑;tsBuildInfoFile 按包名分文件即可。

【结尾】zod 抽共享包我赞同,但别在共享包里 re-export 整个 zod API,那会把内部实现变成公共契约,将来升 5.x 时全站都要跟着改;只导出 schema 和 z.input/z.output 推出来的类型就够了。