前端开发··2 阅读·预计 8 分钟

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 强制的编译期契约"的关键一环。把类型检查放进提交门禁,你就能用一条命令,吃掉一大类会在生产环境才爆炸的模板错误。

0 评论

评论区

登录 后参与评论