// Created At 2026-08-18// P5
// Vue · TypeScript · Engineering

Vue 3 组件库的 TypeScript Props 类型导出:一个看似简单的坑

本文基于 moongate-vue 组件库的实际排障经验,完整记录了从 import type { ButtonProps } from 'my-lib' 报错到最终解决方案的全过程。问题跨越 Vue SFC 编译器、TypeScript 模块解析、shims-vue.d.ts 机制和 npm 包发布结构四个知识域。

引言:一个”不可能”的报错

在构建 moongate-vue 组件库时,我们在组件内部定义了完善的 Props 类型并成功通过构建:

<!-- Button.vue -->
<script setup lang="ts">
export interface ButtonProps {
  label?: string
  variant?: "filled" | "outline"
  size?: "sm" | "md" | "lg"
}
defineProps<ButtonProps>()
</script>

index.ts 中正常导出:

export type { ButtonProps } from "./components/Button.vue"

pnpm build 一切正常,pnpm run verify:build 28 个组件全部通过。

但当消费者项目使用时:

import type { ButtonProps } from "moongate-vue"
// ❌ 模块 "moongate-vue" 没有导出的成员 "ButtonProps"

明明构建通过了,为什么消费者拿不到类型?

排障路径

这是我们在排障过程中走过的完整路径:

发现报错(消费者拿不到类型)


检查 dist/index.d.ts → 发现 from './components/Button.vue'(.vue 路径)


为什么 .vue 路径会导致失败?→ shims-vue.d.ts 的通配符拦截


自然方案:把 Props 移到 .ts 文件 → compiler-sfc 报 Unresolvable


尝试 vueCompilerOptions.types → 实测两个管道都不支持


最终方案:组件内同文件接口 + 独立 .ts 文件双份定义

接下来我们逐层拆解每个环节。


第一层:defineProps 的编译器限制

Vue 的 <script setup> 中的 defineProps<T>() 是一个编译宏——它不是运行时代码,而是由 @vue/compiler-sfc 在编译阶段解析的。

关键限制:defineProps 的类型参数必须在编译时可解析,且只能引用同文件定义的类型。

<!-- ✅ 可以:同文件定义的类型 -->
<script setup lang="ts">
interface Props {
  label?: string
}
defineProps<Props>()
</script>
<!-- ❌ 不可以:从外部文件导入的类型 -->
<script setup lang="ts">
import type { Props } from "./other-file"
defineProps<Props>() // Unresolvable type reference
</script>

这个限制源于 @vue/compiler-sfcresolveTypeElements 实现——它使用了一个简化的类型解析器,无法跨文件递归解析复杂的类型引用。当 Props 类型引用了其他模块的类型(如 ComponentSize 等),解析器会报 Unresolvable type reference

这意味着 Props 类型必须定义在 .vue 文件内才能被 defineProps 正确解析。但如果我们把 Props 类型放在 .vue 文件里,index.ts 就必须从 .vue 文件 re-export——这就引出了下一层问题。


第二层:shims-vue.d.ts 的通配符陷阱

每个 Vue 3 项目几乎都有一个 shims-vue.d.ts

// src/shims-vue.d.ts(消费者项目)
declare module "*.vue" {
  import { DefineComponent } from "vue"
  const component: DefineComponent<{}, {}, any>
  export default component
}

这个文件让 TypeScript 知道 .vue 文件是什么——它声明 *.vue 模块只导出 default

index.d.ts 中有:

export type { ButtonProps } from "./components/Button.vue"

TypeScript 解析 ./components/Button.vue 这个模块时:

  1. 如果消费者没有 shims-vue.d.ts:TS 会找到 Button.vue.d.ts(vue-tsc 生成的),里面有 ButtonProps,解析成功 ✅
  2. 如果消费者有 shims-vue.d.ts:TS 的模块解析会优先匹配通配符声明 declare module '*.vue',而不是查找具体文件。shim 只声明了 export default,没有 ButtonProps报错

这是 TypeScript 模块解析的一个反直觉行为:通配符声明(*.vue)的优先级高于文件路径解析。几乎所有 Vue 3 项目都需要 shims-vue.d.ts,因此.vue 文件导出具名类型在消费者端几乎一定会失败

                         ┌─────────────────────────────────┐
                         │      dist/index.d.ts             │
                         │  export type { ButtonProps }     │
                         │  from './components/Button.vue'  │
                         └──────────────┬──────────────────┘


                         ┌─────────────────────────────────┐
                         │  shims-vue.d.ts(消费者项目)      │
                         │  declare module '*.vue' {        │
                         │    export default component       │  ← 只有 default!
                         │  }                                │
                         └──────────────┬──────────────────┘


                               ❌ 没有 ButtonProps

第三层:为什么不能简单地”移到 .ts 文件”

自然的解决方案是:把 Props 类型移到独立的 .ts 文件,这样 index.d.ts 就不引用 .vue 了。

// types/props.ts
export interface ButtonProps {
  label?: string
  variant?: "filled" | "outline"
  size?: "sm" | "md" | "lg"
}
<!-- Button.vue -->
<script setup lang="ts">
import type { ButtonProps } from "../types/props"
defineProps<ButtonProps>()
</script>

这看起来完美——但 @vue/compiler-sfc 不允许。

compiler-sfc 的类型解析限制

通过最小复现测试确认(使用 vue-tsc 6.0.3 + compiler-sfc 3.5.35 + Vite 8):

场景 compiler-sfc (Vite 构建) vue-tsc (类型检查)
同文件 interface ✅ 通过 ✅ 通过
跨文件简单类型(仅 string/number) ✅ 通过 ❌ TS2305(注 1)
跨文件复杂类型(引用 Component/Size) ❌ Unresolvable ❌ TS2305

注 1:Vue 3.3+ 官方声明支持 defineProps 引用外部导入的类型。实测中,compiler-sfc(Vite 构建管道)确实能解析跨文件的基础类型。但 vue-tsc(类型检查管道)在带 "types" 字段的 tsconfig.json 下,走的是 @vue/language-core 的 SFC 模块解析路径,对跨文件类型导入一致地拒绝。由于真实项目几乎必然配置了 "types": ["vite/client", "node"] 等,这个差异在实践中意味着:消费者端的类型检查始终会失败

结论defineProps<T>()T 只能引用同文件定义的类型——即使 compiler-sfc 在构建时能编译通过,vue-tsc 在类型检查阶段也会报错。对于组件库而言,两道关卡都必须通过才算可用。

vueCompilerOptions.types 能绕过吗?

Vue 3.3+ 支持在 tsconfig.json 中配置:

{
  "vueCompilerOptions": {
    "types": ["/path/to/types/props.ts"],
  },
}

实测结论:不能。

场景 compiler-sfc (Vite 构建) vue-tsc (类型检查)
+ vueCompilerOptions.types ❌ 仍然失败 ❌ 仍然失败

vueCompilerOptions.types 主要影响 IDE 语言服务(Volar/TypeScript 服务器)层面,不影响 compiler-sfc 的编译管道或 vue-tsc 的类型检查管道。

defineProps 的两种声明模式

既然类型声明模式有此限制,Vue 的 defineProps 实际上还有另一种方式:

模式 语法 类型解析 适用场景
类型声明 defineProps<Props>() 需要 compiler-sfc 解析类型 简单组件
运行时声明 defineProps({ label: String, ... }) 不需要类型解析 复杂类型/大型库

运行时声明不走类型解析管道,从根本上绕开了限制。Element Plus、Naive UI 等大型组件库都采用这种模式:

// 运行时声明 + ExtractPropTypes
export const buttonProps = {
  label: { type: String, default: "" },
  variant: { type: String, default: "filled" },
} as const

export type ButtonProps = ExtractPropTypes<typeof buttonProps>

类型定义在纯 .ts 文件中,index.d.ts 不引用 .vue,消费者不会有 shim 问题。这是目前唯一能彻底避免类型导出问题的方案。


我们最终采用的方案

考虑到项目的规模(28 个组件)和改动成本,我们采用了折中方案

组件内保留同文件接口(供 defineProps 编译)

<!-- Button.vue -->
<script setup lang="ts">
export interface ButtonProps {
  label?: string
  variant?: "filled" | "outline"
}
defineProps<ButtonProps>()
</script>

同时在独立 .ts 文件中定义对外导出的类型

// types/props.ts
import type { Component } from "vue"
import type { Size, AddonColor } from "./components"

export interface ButtonProps {
  label?: string
  variant?: "filled" | "outline"
  size?: Size
  icon?: string | Component
}

index.ts 从 .ts 文件导出(不引用 .vue)

export type { ButtonProps } from "./types/props"

构建后 dist/index.d.ts 0 处 .vue 类型引用,消费者解析无碍。

代价:双份维护

组件内的 ButtonProps(供 defineProps 编译)和 types/props.ts 中的 ButtonProps(供对外导出)是两份独立的定义,需要手动保持一致。

决策参考:对于 50 个组件以内的库,手写双份的维护成本远低于将整个项目重构为运行时声明(ExtractPropTypes)的改造成本。如果库的规模预期超过 50 个组件,建议从一开始就采用运行时声明方案。


给组件库作者的建议

发布前的类型检查清单

  1. 验证 index.d.ts 不含 .vue 类型引用
   grep -c "\.vue" dist/index.d.ts  # 应为 0(仅注释中允许)
   grep "^import type.*\.vue" dist/index.d.ts  # 应为空
  1. shims-vue.d.ts 模拟消费者环境测试
   declare module "*.vue" {
     const c: DefineComponent<{}, {}, any>
     export default c
   }
  1. 验证 package.json exports 与组件清单一致:新增组件后容易遗漏 exports 子路径。

  2. 运行 verify:build 验证:检查所有 .js.d.ts 产物完整性。

新建项目推荐

如果你正在从头构建组件库,建议直接采用第三层介绍的运行时声明 + ExtractPropTypes 方案——它从根本上避免了所有类型导出问题,且是 Element Plus、Naive UI 等主流库验证过的成熟模式。


进阶思考:双份维护的自动化可能

折中方案解决了”能不能导出”的问题,但引入了双份维护的代价。当前 28 个组件规模尚可接受,扩展到 100+ 时手动同步两份类型定义会成为显著的维护负担。以下两个方向值得探索:

方向一:Post-build 自动生成

vue-tsc 会为每个组件生成 .vue.d.ts 文件(其中包含完整的 Props 接口定义)。能否写一个构建后脚本,自动从这些 .d.ts 文件中提取 Props 接口,生成 types/props.ts

// scripts/gen-props-types.mjs(概念示例)
import { readdirSync, readFileSync, writeFileSync } from "node:fs"

// 遍历 dist/components/*.vue.d.ts
// 提取 export interface XxxProps { ... } 块
// 写入 src/types/props.ts(或直接更新 dist/types/props.d.ts)

由于 defineProps 引用的是组件内的同文件类型(编译器能解析),而 .d.ts 产物是准确的类型快照,所以从产物提取可以保证单一来源——组件内的接口定义就是权威来源,types/props.ts 只是它的自动生成镜像。

挑战在于:vue-tsc 生成的 .vue.d.ts 是简化后的声明(可能丢失 JSDoc 注释、内部类型别名展开等),需要额外处理才能生成对消费者友好的类型文件。

方向二:vueCompilerOptions.types

Vue 3.3+ 支持在 tsconfig.json 中配置 vueCompilerOptions.types,让 @vue/language-core 在 IDE 语言服务中将指定模块加入 defineProps 的类型解析上下文。

然而正如前文实验所示,这个选项对 compiler-sfc(Vite 构建)和 vue-tsc(类型检查)都没有帮助。它只在 IDE 语言服务层面有效,不解决实际构建问题。

除非未来 Vue 工具链统一了这两个管道对跨文件类型解析的支持,否则这个方向的天花板已经很明确。

核心矛盾

实验表明:Vue 编译管道和类型检查管道对外部类型解析的限制是一致的——两者都不支持 defineProps 引用跨文件导入的类型。这不是某个工具的 bug,而是 Vue SFC 编译宏的设计约束。


总结

层级 问题 解决方案
Vue 编译器 defineProps 无法解析跨文件类型(compiler-sfc + vue-tsc 均如此) Props 类型放同文件(或用运行时声明)
TS 模块解析 .vue 导入被 shims 通配符拦截 index.d.ts 不引用 .vue 路径
构建产物 类型定义需要双份维护 独立 .ts 文件 + 组件内同文件接口(或运行时声明消除双份)
发布流程 exports 白名单遗漏 verify-build 增加一致性校验

这个问题的本质是 Vue SFC 编译器TypeScript 模块解析 两个独立系统之间的契约缺口——Vue 要求类型在同文件内,而 TS 的 shim 机制会拦截 .vue 路径的具名导出。理解了这个缺口,解决方案就清晰了。

如果这篇文章对你有帮助,可以请我喝杯咖啡 ☕️
Ali PayWechat Pay