Vue 3 模板类型检查的工程化落地:用 vue-tsc 把模板错误拦截在 CI 门前
Vue 3 模板类型检查的工程化落地:用 vue-tsc 把模板错误拦截在 CI 门前
很多人以为 Vue 3 有了 <script setup lang="ts"> 就算"类型安全"了。实际上,脚本块里的类型检查只能覆盖一半问题——模板里写下错误的 prop 名、传错 emit 事件参数、漏掉必填 slot,vite build 照样能编译通过,直到运行时才报错。
这篇文章聚焦一个被严重低估的工具:vue-tsc。它是 Vue 官方生态里唯一能做模板层类型检查的方案,配合 CI 门禁,能把一大类运行时错误前移到提交阶段。
一、为什么 vue-tsc 前移错误,而 vite build 不会
Vite 的转译是用 esbuild 做的,esbuild 只做语法转换、不做类型检查。所以下面这段代码:
<script setup lang="ts">
defineProps<{ title: string; count: number }>()
</script>
<template>
<!-- 拼错属性名:title 写成 titel -->
<h1>{{ titel }}</h1>
</template>
执行 vite build 完全不会报错,产物里 titel 会变成 undefined,页面静默渲染出空标题。而 vue-tsc 会在编译期直接报:
error TS18048: 'titel' is possibly 'undefined'.
关键区别在于:esbuild 丢掉类型,vue-tsc 保留类型。后者通过 @vue/language-core 把 SFC 编译成虚拟的 TS 模块,再交给完整的 TypeScript checker 做全量类型推导。
二、三件套建立完整组件契约
vue-tsc 的真正威力,在于让下面三个 API 的类型约束贯通脚本和模板:
1. defineProps:模板里引用必须有类型
<script setup lang="ts">
interface CardProps {
title: string
count: number
tags?: string[]
}
const props = defineProps<CardProps>()
</script>
类型声明一旦建立,模板里访问 props.title 是精确的 string;拼错属性名、访问不存在的字段,都会在类型检查阶段暴露。
2. defineEmits:事件参数也能被模板约束
<script setup lang="ts">
const emit = defineEmits<{
// 正例:用元组精确描述参数
(e: 'select', id: number): void
(e: 'close'): void
}>()
</script>
<template>
<!-- 正例:参数类型匹配 -->
<button @click="emit('select', 42)">选我</button>
<!-- 反例:少传参数,vue-tsc 直接报错 -->
<button @click="emit('select')">选我</button>
</template>
这段反例会报 Expected 2 arguments, but got 1。相比之下,emit 型别写成 (e: string, ...args: any[]) => void 的老写法,会让所有参数检查形同虚设。
3. defineSlots:约束插槽传递的数据形状
<script setup lang="ts">
defineSlots<{
// 具名插槽,暴露一个类型化作用域
header(props: { title: string }): any
default(): any
}>()
</script>
<template>
<slot name="header" :title="title" />
</template>
父组件在使用时:
<Card>
<template #header="{ title }">
<!-- title 被推导为 string,拼错就报错 -->
<h2>{{ title }}</h2>
</template>
</Card>
如果把 #header 解构错字段,vue-tsc 会因为作用域对象的类型不匹配而报错——这正是运行时手动防御做不到的。
三、正反例对照:同一个 bug,两种结局
假设我们要重构一个 UserCard 组件,把 user.name 改成 user.displayName。
反例:只改脚本不改模板
<script setup lang="ts">
interface User { displayName: string; id: number }
const props = defineProps<{ user: User }>()
</script>
<template>
<!-- 模板里还引用旧的 name 字段 -->
<p>{{ user.name }}</p>
</template>
没有 vue-tsc 的项目,vite build 通过、上线后用户卡片姓名全部空掉,靠线上报警才发现。有 vue-tsc 的项目,CI 里一步就拦截:
error TS2339: Property 'name' does not exist on type 'User'.
同一个 bug,后者成本接近于零。这就是"编译期契约"的意义:把分散在运行时的手工防御,收敛成一条机器强制的类型边界。
四、把 vue-tsc 接入 CI:一条命令 + 两个细节
package.json 里加一个脚本:
{
"scripts": {
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
}
}
CI 流程里,在 vite build 之前先跑 npm run typecheck:
# .github/workflows/ci.yml 片段
- name: Type check
run: npm run typecheck
- name: Build
run: npm run build
两个容易踩的细节:
细节一:--noEmit 不能省。 否则 vue-tsc 会尝试产出 .d.ts,在 monorepo 或 lib 模式下容易产生一堆意想不到的文件。
细节二:tsconfig 要包含 .vue 文件的虚拟模块。 官方模板通常会配好 vueCompilerOptions,但自定义 tsconfig 时记得确认 include 覆盖到 src/**/*.vue,否则 vue-tsc 会静默跳过模板检查——这是它最隐蔽的"假绿"陷阱。
{
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"],
"compilerOptions": { "noEmit": true }
}
五、性能代价与正确预期
vue-tsc 因为要做完整类型推导,速度远慢于 esbuild,大型项目全量检查可能耗时数十秒到几分钟。合理的做法是:
- 本地开发不跑、只在 CI 和 PR 提交前跑;
- 增量场景用
--incremental+tsBuildInfoFile缓存; - 不要把 vue-tsc 塞进
vite build的热路径,转译与类型检查本来就该分离(这与 vite 的设计哲学一致)。
小结
脚本块的类型安全只覆盖了一半,模板层才是 Vue 组件最容易"灯下黑"的地方。
vue-tsc 不是锦上添花的工具,而是把 Vue 3 的 defineProps / defineEmits / defineSlots 三件套从"文档里的最佳实践"变成"CI 强制的编译期契约"的关键一环。把类型检查放进提交门禁,你就能用一条命令,吃掉一大类会在生产环境才爆炸的模板错误。
评论区
登录 后参与评论