基于 tsdown 构建 Vue 组件库:从 SFC 编译、类型声明到 npm 发布的完整方案
本篇技术指南讲解如何用 tsdown(基于 Rolldown/Oxc 的高性能 JS/TS 库打包器)构建可发布的 Vue 组件库:从 unplugin-vue 编译单文件组件、到 vue-tsc 生成组件级 .d.ts 类型声明,再到 package.json 的现代 exports 配置。它面向在当前项目中维护共享 Vue 组件/Composable 的开发者,读完后你将掌握一套可直接复制运行的 tsdown + Vue 配置模板,并理解 SFC、JSX、多入口、Monorepo 等场景下的底层原理与常见坑。该指南对应仓库中 tsdown 技能包(.agents/skills/tsdown)里的 Vue 配方文档,正文会同时结合仓库内其他 tsdown 配置(如 packages/stream-kit/tsdown.config.ts、packages/plugin-sdk/tsdown.config.ts)佐证说明。
概述:tsdown 为何能一等公民式支持 Vue
tsdown 是面向 npm 库开发的"优雅打包器",其 Vue 支持通过两件事实现一等公民体验:
unplugin-vue:负责把.vue单文件组件(SFC)编译为可在浏览器/Node 中运行的 JavaScript;rolldown-plugin-dts(dts 选项):负责类型声明的生成与打包,其中针对 Vue 有专门的vue: true开关。
在 airi 仓库中,tsdown 被大量用于库类包(通过根 package.json 的 pnpm catalog 锁定 tsdown: ^0.22.14,可查看 pnpm-workspace.yaml),例如 core-agent、plugin-sdk、stream-kit 都直接依赖 tsdown 出 ESM 产物与 .d.ts。而本仓库大量 Vue 组件库(如 packages/stage-ui、packages/ui)则使用 @vitejs/plugin-vue + vue-tsc 这一同源工具链。tsdown 提供的就是把"Vite 开发体验"与"库打包 + 类型发布"统一进一份配置的方案。
需要注意的运行时前提:根据 tsdown 技能说明,tsdown 本体需要 Node.js 22.18.0+ 才能运行(仅构建期),但通过 target 选项可以让产物面向更低版本 Node,构建出的库并不会被锁定在 Node 22。
快速开始
使用官方 Starter 模板
最省事的方式是直接以 Vue 模板初始化项目:
npx create-tsdown@latest -t vue
安装依赖
在已有工程中手动接入则需要安装编译与类型生成所需的两个依赖:
pnpm add -D unplugin-vue vue-tsc
最小化配置
// tsdown.config.ts
import { defineConfig } from 'tsdown'
import Vue from 'unplugin-vue/rolldown'
export default defineConfig({
entry: ['./src/index.ts'],
format: ['esm', 'cjs'],
platform: 'neutral',
deps: {
neverBundle: ['vue'],
},
plugins: [
Vue({ isProduction: true }),
],
dts: {
vue: true, // Enable Vue type generation
},
})
逐项说明其含义:
entry: ['./src/index.ts']:入口指向聚合导出组件的入口文件,而不是直接指.vue文件(多入口场景见下文);format: ['esm', 'cjs']:同时产出 ESM(.mjs)与 CJS(.cjs)双格式,兼顾现代打包器与老式require环境;platform: 'neutral':产物不绑定特定运行时平台,保证最大兼容性;deps: { neverBundle: ['vue'] }:强制不打包 vue,让它作为外部依赖交给使用方的应用提供;plugins: [Vue({ isProduction: true })]:挂载unplugin-vue的 Rolldown 适配,isProduction: true走生产级 SFC 编译;dts: { vue: true }:开启 Vue 类型生成,这也是与普通 TS 库配置最大的不同点。
原理剖析:SFC 如何被编译并生成类型
unplugin-vue:SFC 编译阶段
unplugin-vue 在 Rolldown 构建管线中拦截 .vue 请求并完成三件核心事:
- 将 template 编译为 render 函数:模板在构建期被静态分析并生成为
_createElementVNode等渲染代码,浏览器端无需再带运行时编译器; - 处理 scoped 样式:为
<style scoped>中的选择器注入data-v-xxx属性哈希,同时负责v-bind的 CSS 变量注入等编译期工作; - 处理 script setup:将
<script setup>中声明的顶层绑定提升为组件实例属性,编译 macro(defineProps/defineEmits/defineModel等)。
vue-tsc:类型生成阶段
类型声明交给 vue-tsc 完成,当 dts.vue: true 开启时它负责:
- 对 Vue 组件做类型检查(等价于
vue-tsc --noEmit的库场景子集); - 生成
.d.ts声明文件:把每个.vue文件视为一个模块输出声明; - 保留组件 props 类型:
defineProps<Props>()中以泛型传入的接口会原样进入声明,消费方按字面类型获得智能提示; - 导出组件类型:让
export type { ButtonProps } from './Button.vue'这类"从.vue文件导出纯类型"的写法成立。
关于类型生成的更多背景可参考技能包中的 类型声明文档:dts 生成由 rolldown-plugin-dts 负责,且只要 package.json 中存在 types 或 typings 字段,dts 就会自动开启;开启 vue: true 的前提是项目里安装并配置了 vue-tsc。此外,tsdown 从配置到产物还遵循两条管线规则:ESM 格式下 .js 与 .d.ts 同批生成,CJS 格式下 .d.ts 走独立生成流程。
组件示例
一个标准 SFC 组件
以下是一个典型的 Vue 3 <script setup lang="ts"> 组件,覆盖了 Props 泛型、Emits 声明、动态 class、插槽与 scoped 样式:
<!-- src/Button.vue -->
<script setup lang="ts">
interface Props {
type?: 'primary' | 'secondary'
disabled?: boolean
}
defineProps<Props>()
defineEmits<{
click: []
}>()
</script>
<template>
<button
:class="['btn', `btn-${type}`]"
:disabled="disabled"
@click="$emit('click')"
>
<slot />
</button>
</template>
<style scoped>
.btn {
padding: 8px 16px;
border-radius: 4px;
}
.btn-primary {
background: blue;
color: white;
}
</style>
要点:defineProps<Props>() 使用本地 interface Props 而非 type 别名 + defineProps,可保证 vue-tsc 能在 .d.ts 中原样保留联合字面量类型;具名导出组件类型时要求 props 接口可被类型生成器解析到。
从入口导出组件
// src/index.ts
export { default as Button } from './Button.vue'
export { default as Input } from './Input.vue'
export { default as Modal } from './Modal.vue'
// Re-export types
export type { ButtonProps } from './Button.vue'
注意最后一行:在 dts.vue: true 与 vue-tsc 的共同作用下,'./Button.vue' 模块既能提供默认组件值导出,也能提供以文件命名的 Props 类型导出。若你习惯将 Props 类型单独定义在 src/types.ts 并在组件内 import type 复用,同样可行,但务必保证所有导出成员都有显式类型——因为推荐开启的 isolatedDeclarations(见下文 tsconfig 一节)要求每个导出都有可推断的显式标注。
常用配置模式
组件库标准模板
面向真实组件库的推荐配置,在最小化配置基础上补了样式压缩与构建前清理:
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
platform: 'neutral',
deps: {
neverBundle: ['vue'],
},
plugins: [
Vue({
isProduction: true,
style: {
trim: true,
},
}),
],
dts: {
vue: true,
},
clean: true,
})
style.trim: true:产物中的样式会移除多余的空白与换行,压缩体积;clean: true:每次构建前自动清空输出目录(dist),避免历史产物残留。
多入口:让每个组件可单独深链导入
若希望使用方既能 import { Button } from 'my-lib',也能 import Button from 'my-lib/Button' 实现按需引入与更好的 tree-shaking,可以用对象形式声明多入口:
export default defineConfig({
entry: {
index: 'src/index.ts',
Button: 'src/Button.vue',
Input: 'src/Input.vue',
Modal: 'src/Modal.vue',
},
format: ['esm', 'cjs'],
deps: {
neverBundle: ['vue'],
},
plugins: [Vue({ isProduction: true })],
dts: { vue: true },
})
产物会对应生成 dist/index.*、dist/Button.* 等独立文件(d.ts 同理)。若配合 exports: true 等字段生成能力,每个子路径都能获得独立的条件导出。
组件库内混入 Composition 工具函数
组件库经常同时发布可复用的组合式函数,它们与组件共享同一份 vue 外部依赖与类型管线:
// src/composables/useCounter.ts
import { ref } from 'vue'
export function useCounter(initial = 0) {
const count = ref(initial)
const increment = () => count.value++
const decrement = () => count.value--
return { count, increment, decrement }
}
配置上并不需要任何额外处理,只需在入口中导出:
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
deps: {
neverBundle: ['vue'],
},
plugins: [Vue({ isProduction: true })],
dts: { vue: true },
})
vue 依然保持在 neverBundle 名单中,因为 ref/computed 等 API 必须与使用方应用共享同一个 Vue 运行时实例,否则会出现双实例导致的响应性失效问题。
TypeScript 配置
组件库推荐使用如下 tsconfig.json。其中 isolatedDeclarations: true 是性能关键项:tsdown 会利用 oxc-transform 快速生成声明,规避完整 TypeScript 编译的耗时;未开启时则回退到 TypeScript 编译器(可靠但更慢)。
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"jsx": "preserve",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"strict": true,
"isolatedDeclarations": true,
"skipLibCheck": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
字段速记:
jsx: "preserve":Vue 生态中 TSX 代码通常交给 unplugin-vue/JSX 编译,TS 本身不吞 JSX;moduleResolution: "bundler":适配现代打包器式裸模块解析;allowImportingTsExtensions: true:允许带.ts后缀导入(仅供开发期,产物由打包器归一化);isolatedDeclarations: true:走 oxc 快速声明路径的前提。
package.json 配置
库的发布元数据遵循"双格式 + 类型 + 对等依赖"的现代规范:
{
"name": "my-vue-library",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
},
"files": ["dist"],
"peerDependencies": {
"vue": "^3.0.0"
},
"devDependencies": {
"tsdown": "^0.9.0",
"typescript": "^5.0.0",
"unplugin-vue": "^5.0.0",
"vue": "^3.4.0",
"vue-tsc": "^2.0.0"
}
}
三点解释:
types字段会让 dts 自动开启(见 option-dts 文档 的 auto-enabled 规则),但仍建议在tsdown.config.ts中显式dts: { vue: true };exports的条件导出顺序应为types在前、import/require在后;vue必须放在peerDependencies(而不是dependencies),并在构建侧加入neverBundle,二者共同保证"库不携带 Vue、与宿主共享一个 Vue"。
(上表版本号为文档示例值。airi 仓库当前 catalog 中实际锁定为 tsdown: ^0.22.14、vue: ^3.5.41、vue-tsc: ^3.3.11,见 pnpm-workspace.yaml,新项目可直接以 catalog 版本为准。)
进阶模式
接入 Vite 生态插件(unplugin 系)
得益于 unplugin 的跨构建器设计,多数 Vite Vue 插件能以同构方式接入 Rolldown。例如自动按需导入组件的 unplugin-vue-components:
import Vue from 'unplugin-vue/rolldown'
import Components from 'unplugin-vue-components/rolldown'
export default defineConfig({
entry: ['src/index.ts'],
deps: {
neverBundle: ['vue'],
},
plugins: [
Vue({ isProduction: true }),
Components({
dts: 'src/components.d.ts',
}),
],
dts: { vue: true },
})
Components({ dts: 'src/components.d.ts' }) 会在开发/构建期自动扫描并注册用到的组件,同时生成供编辑器感知的类型声明文件。
JSX / TSX 支持
Vue 组件库同样可以发布 .tsx 组件或 render 函数。关键在于两处:inputOptions.transform 中把 JSX 运行时指向 'vue',并在 Vue() 插件里开启 props 解构等新语法支持:
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
deps: {
neverBundle: ['vue'],
},
plugins: [
Vue({
isProduction: true,
script: {
propsDestructure: true,
},
}),
],
inputOptions: {
transform: {
jsx: 'automatic',
jsxImportSource: 'vue',
},
},
dts: { vue: true },
})
jsx: 'automatic'+jsxImportSource: 'vue':使用vue/jsx-runtime的自动导入模式,无需手动import { h };script.propsDestructure: true:允许const { msg } = defineProps<{ msg: string }>()式的解构语法,同时保持响应性(Vue 3.5+ 特性)。
Monorepo 内构建 Vue 包
在 workspace 中,一个配置即可构建多个 Vue 包,并且用正则把同组织的工作区包与 vue 一起外部化:
export default defineConfig({
workspace: 'packages/*',
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
deps: {
neverBundle: ['vue', /^@mycompany\//],
},
plugins: [Vue({ isProduction: true })],
dts: { vue: true },
})
workspace: 'packages/*' 让 tsdown 以子包为单位分别构建;/^@mycompany\// 正则将同组织的兄弟包一律视为 external,避免把其它 workspace 源码打进每个子包。这与 airi 仓库在 pnpm-workspace.yaml 里集中 catalog 管理依赖、各 包级 tsdown.config.ts 独立声明 entry 的模式互为补充。依赖外部化 / 打包的完整决策规则(默认 dependencies/peerDependencies/optionalDependencies 全部 external,devDependencies 仅被引用时打包)见 依赖选项文档。
unplugin-vue 插件选项速查
Vue({
isProduction: true,
script: {
defineModel: true,
propsDestructure: true,
},
style: {
trim: true,
},
template: {
compilerOptions: {
isCustomElement: (tag) => tag.startsWith('custom-'),
},
},
})
| 选项 | 作用 |
|---|---|
isProduction |
生产模式编译(如对模板做更积极的优化),库构建推荐为 true |
script.defineModel |
支持 defineModel() 宏(Vue 3.4+) |
script.propsDestructure |
支持 defineProps 解构赋值并保持响应性 |
style.trim |
移除产物样式的多余空白,压缩输出 |
template.compilerOptions.isCustomElement |
将匹配 tag 当作自定义元素,跳过组件解析 |
实践要点清单
- 始终外部化 Vue——不要打包 Vue 本身,加到
deps.neverBundle并声明为peerDependencies; - dts 开启
vue: true——没有它.vue模块无法产出可用的组件类型声明; - 使用
platform: 'neutral'——让库同时适配浏览器与各类运行时,兼容性最大化; - 安装
vue-tsc——Vue 类型生成的前提依赖,缺了它dts.vue会失败; Vue({ isProduction: true })——按生产标准优化 SFC 编译结果;- 把 Vue 放进 peerDependencies——与使用方共享同一个 Vue 运行时,避免双实例问题。
疑难排查
类型生成失败
先确认 vue-tsc 已安装:
pnpm add -D vue-tsc
再确认配置中已开启 Vue 类型开关:
dts: { vue: true }
组件类型缺失 / Props 提示丢失
检查 tsconfig 中 JSX 与解析器相关配置是否正确(缺少时 vue-tsc 可能无法为 .vue 模块产出有效声明):
{
"compilerOptions": {
"jsx": "preserve",
"moduleResolution": "bundler"
}
}
若第三方依赖的类型较复杂导致 dts 解析失败,还可以参考 option-dts 把模块解析器切回兼容性更高的 dts: { resolver: 'tsc' },代价是生成更慢。另外请确保所有导出都带显式类型——这是启用 isolatedDeclarations 快速路径的前提。
Vue 未被外部化(被打进了产物)
在 deps.neverBundle 显式声明:
deps: {
neverBundle: ['vue'],
}
同时也建议把 @vue/runtime-core、@vue/reactivity 等 Vue 附属包一并外部化,并确认它们存在于 peerDependencies(参见 依赖处理文档)。
SFC 编译报错
通常是 unplugin-vue 与正在使用的 Vue 版本不匹配,升级到最新版重试:
pnpm add -D unplugin-vue@latest
延伸阅读
- 插件系统(advanced-plugins):Rolldown / Rollup / unplugin 插件的接入方式与执行顺序;
- 依赖选项(option-dependencies):
neverBundle/alwaysBundle/onlyBundle与自动外部化的完整规则; - 类型声明选项(option-dts):
vue、oxc、resolver、tsconfig等全部 dts 选项; - React 配方(recipe-react):对照阅读 React 组件库的 JSX/编译器接入方式,便于理解框架配方的共性设计;
- 本仓库真实 tsdown 配置示例:stream-kit、core-agent、plugin-sdk;
- 本仓库 Vue 组件库侧的工具链参考:packages/stage-ui(
@vitejs/plugin-vue+vue-tsc+vue: ^3.5.41)。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00