前端中级实战:用 Vite + TypeScript 搭建可扩展的组件库工程化方案
做业务项目时,很多团队都会经历一个阶段:组件越写越多,样式越来越乱,复制粘贴也越来越频繁。最开始可能只是想“封装几个按钮和弹窗”,但随着项目增多,你会发现没有一套像样的组件库工程,后面维护的成本会非常高。
这篇文章我不打算只讲“怎么初始化一个仓库”,而是带你从工程化可扩展的角度,搭一套适合中级前端继续演进的组件库方案。目标是:
- 用 Vite 作为开发与构建基础
- 用 TypeScript 保证类型体验
- 支持 组件按需导出
- 支持 样式打包
- 支持 类型声明生成
- 支持 本地调试 + 文档演示
- 具备后续接入测试、CI、发布的扩展空间
如果你之前只做过业务型前端项目,这篇会帮你把“组件库”从一个代码目录,提升成一个真正可以交付和迭代的工程。
前置知识
建议你已经具备以下基础:
- 会使用 Vue 3 或 React 中的一种(本文示例使用 Vue 3)
- 理解 ESModule、npm 包结构
- 会写基础 TypeScript
- 知道
package.json、tsconfig.json的基本作用
如果这些你都接触过,那就可以直接开始。
背景与问题
很多人第一次做组件库,通常是这样起步的:
- 建一个
components文件夹 - 每个组件一个
.vue文件 - 写一个
index.ts全量导出 - 用打包工具输出一个
dist
看起来没问题,但很快会遇到几个现实问题:
1. 类型声明不完整
你在业务项目里引入组件时,IDE 提示不准确,甚至没有 props 类型推断。
这时组件“能跑”,但开发体验很差。
2. 样式与构建耦合混乱
有的组件样式丢失,有的组件样式重复打包;有的全量引入正常,按需引入就失效。
3. 导出结构不可扩展
起初你只导出 Button、Input,后面要加 hooks、utils、主题变量、图标系统时,目录结构就开始变形。
4. 本地开发体验差
如果每次改组件都要重新 build、再去业务项目验证,效率非常低。
一个好组件库工程,应该有独立的 playground/demo 环境。
5. 发布后兼容性问题
例如:
- ESM/CJS 产物不兼容
types路径错误exports配置不规范- 外部依赖被错误打包进去
这些问题在本地不明显,一发包就出事。
核心原理
在正式动手前,先把这套方案的核心思路讲清楚。你理解了原理,后面就不会只是“照抄配置”。
组件库工程的目标分层
可以把组件库工程拆成四层:
- 源码层:组件、hooks、工具函数、样式变量
- 开发层:本地调试、示例页、热更新
- 构建层:ESM 产物、类型声明、样式产物
- 发布层:npm 包入口、导出字段、依赖边界
对应关系如下:
flowchart TD
A[源码层 src/components hooks styles utils] --> B[开发层 playground / demo]
A --> C[构建层 Vite build + d.ts]
C --> D[发布层 package.json exports]
D --> E[业务项目消费]
为什么选 Vite
Vite 很适合组件库场景,原因有三个:
- 本地开发体验非常好,冷启动快
- 基于 Rollup,做库模式构建天然合适
- 生态里对 Vue、TS、PostCSS、Sass 的支持比较成熟
为什么 TypeScript 是“必需品”
组件库不同于业务项目,它是会被别人消费的。
所以 TypeScript 的价值不仅是“自己写着舒服”,而是:
- 约束组件 props/events 暴露面
- 生成声明文件给下游使用
- 帮助维护者控制 API 变更
组件库的关键工程点
一套可扩展方案,通常要解决这几件事:
- 统一目录规范
- 统一组件导出方式
- 生成
.d.ts - 正确配置
exports - 外部依赖不重复打包
- 支持单组件导入与全量导入
- 保持样式产物可控
下面这张图可以帮助你理解从源码到消费端的流向:
sequenceDiagram
participant Dev as 开发者
participant Src as 组件源码
participant Vite as Vite/Rollup
participant DTS as 类型声明生成
participant Dist as dist 产物
participant App as 业务项目
Dev->>Src: 编写 Button/Input 组件
Dev->>Vite: 执行 build
Vite->>Dist: 输出 ESM/CJS/样式
Vite->>DTS: 触发 d.ts 生成
DTS->>Dist: 输出类型声明
App->>Dist: 引入组件与类型
环境准备
本文示例环境:
- Node.js >= 18
- pnpm >= 8
- Vue 3
- Vite 5
- TypeScript 5
初始化项目:
mkdir my-ui && cd my-ui
pnpm init
pnpm add vue
pnpm add -D vite @vitejs/plugin-vue typescript vue-tsc sass vite-plugin-dts
项目结构设计
先给出一版比较稳的目录结构:
my-ui
├─ package.json
├─ tsconfig.json
├─ tsconfig.build.json
├─ vite.config.ts
├─ src
│ ├─ components
│ │ └─ button
│ │ ├─ src
│ │ │ └─ button.vue
│ │ └─ index.ts
│ ├─ styles
│ │ ├─ index.scss
│ │ └─ variable.scss
│ ├─ utils
│ │ └─ install.ts
│ └─ index.ts
├─ playground
│ ├─ App.vue
│ └─ main.ts
这套结构的核心思想是:
- 组件源码和导出入口放在一起
- 公共样式独立管理
- 库入口统一汇总
- playground 专门用于开发调试
我个人比较推荐这种结构,因为后面加 input、select、form、message 都不会乱。
实战代码(可运行)
下面我们直接搭一个最小可用组件库:包含 Button 组件、全量安装入口、Vite 构建配置、类型声明输出,以及本地调试环境。
第一步:编写 Button 组件
src/components/button/src/button.vue
<template>
<button
class="my-button"
:class="[`my-button--${type}`, { 'is-disabled': disabled }]"
:disabled="disabled"
@click="handleClick"
>
<slot />
</button>
</template>
<script setup lang="ts">
interface Props {
type?: 'default' | 'primary' | 'success' | 'danger'
disabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
type: 'default',
disabled: false
})
const emit = defineEmits<{
(e: 'click', evt: MouseEvent): void
}>()
const handleClick = (evt: MouseEvent) => {
if (props.disabled) return
emit('click', evt)
}
</script>
<style scoped lang="scss">
.my-button {
appearance: none;
border: 1px solid #dcdfe6;
background: #fff;
color: #606266;
border-radius: 6px;
padding: 10px 16px;
cursor: pointer;
transition: all 0.2s ease;
}
.my-button:hover {
opacity: 0.9;
}
.my-button--primary {
background: #409eff;
border-color: #409eff;
color: #fff;
}
.my-button--success {
background: #67c23a;
border-color: #67c23a;
color: #fff;
}
.my-button--danger {
background: #f56c6c;
border-color: #f56c6c;
color: #fff;
}
.is-disabled {
cursor: not-allowed;
opacity: 0.6;
}
</style>
第二步:为组件增加导出入口
src/components/button/index.ts
import type { App } from 'vue'
import Button from './src/button.vue'
Button.install = (app: App) => {
app.component('MyButton', Button)
}
export { Button }
export default Button
这里做了两件事:
- 支持
import { Button } from 'my-ui' - 支持
app.use(Button)这种单组件安装方式
第三步:实现统一安装工具
src/utils/install.ts
import type { App, Plugin } from 'vue'
export const withInstall = <T>(component: T, name: string) => {
;(component as T & Plugin).install = (app: App) => {
app.component(name, component as any)
}
return component as T & Plugin
}
如果你组件多了,推荐统一走 withInstall,避免每个组件都手写一遍 install。
不过为了便于理解,本文先保留显式写法。
第四步:库总入口
src/index.ts
import type { App } from 'vue'
import Button from './components/button'
import './styles/index.scss'
export { Button }
const components = [Button]
const install = (app: App) => {
components.forEach((component) => {
app.use(component)
})
}
export default {
install
}
这样消费者就有两种使用方式:
- 全量引入:
app.use(MyUI) - 按需引入:
app.use(Button)
第五步:公共样式
src/styles/index.scss
@use './variable.scss';
:root {
font-family:
Inter, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
src/styles/variable.scss
$color-primary: #409eff;
$color-success: #67c23a;
$color-danger: #f56c6c;
这里先只做演示。真正做大后,你可以继续拆成:
reset.scssmixins.scsstokens.scsstheme-dark.scss
第六步:Vite 构建配置
vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import dts from 'vite-plugin-dts'
import { resolve } from 'path'
export default defineConfig({
plugins: [
vue(),
dts({
tsconfigPath: './tsconfig.build.json',
outDir: 'dist/types',
insertTypesEntry: true
})
],
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'MyUI',
fileName: (format) => `index.${format}.js`,
formats: ['es', 'umd']
},
rollupOptions: {
external: ['vue'],
output: {
globals: {
vue: 'Vue'
},
assetFileNames: 'style/[name][extname]'
}
},
sourcemap: true,
emptyOutDir: true
}
})
这份配置的关键点:
lib.entry:指定库入口formats:输出es和umdexternal: ['vue']:不把 Vue 打进产物,避免重复打包vite-plugin-dts:生成类型声明
如果你的组件库只面向现代前端项目,很多时候只保留 es 也可以,没必要强上多格式。
第七步:TypeScript 配置
tsconfig.json
{
"compilerOptions": {
"target": "ES2019",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2019", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"noEmit": true,
"strict": true,
"jsx": "preserve",
"types": ["vite/client"]
},
"include": ["src", "playground", "vite.config.ts"]
}
tsconfig.build.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"emitDeclarationOnly": true,
"noEmit": false,
"outDir": "dist/types"
},
"include": ["src"]
}
为什么单独拆一个 tsconfig.build.json?
因为开发时你可能不希望 TS 真正 emit 文件,但构建类型声明时需要开启 declaration。
这就是“开发配置”和“构建配置”分离的意义。
第八步:本地调试 playground
playground/App.vue
<template>
<div style="padding: 40px">
<h2>My UI Playground</h2>
<div style="display: flex; gap: 12px">
<Button type="default">Default</Button>
<Button type="primary" @click="onClick">Primary</Button>
<Button type="success">Success</Button>
<Button type="danger" :disabled="true">Disabled</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { Button } from '../src'
const onClick = () => {
console.log('button clicked')
}
</script>
playground/main.ts
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
为了让 Vite 在开发时跑 playground,你还需要一个 HTML 入口:
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My UI Playground</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/playground/main.ts"></script>
</body>
</html>
第九步:package.json 配置
package.json
{
"name": "my-ui-demo",
"version": "0.1.0",
"type": "module",
"main": "./dist/index.umd.js",
"module": "./dist/index.es.js",
"types": "./dist/types/src/index.d.ts",
"exports": {
".": {
"import": "./dist/index.es.js",
"require": "./dist/index.umd.js",
"types": "./dist/types/src/index.d.ts"
}
},
"files": ["dist"],
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build"
},
"peerDependencies": {
"vue": "^3.4.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.0",
"sass": "^1.77.0",
"typescript": "^5.6.0",
"vite": "^5.4.0",
"vite-plugin-dts": "^4.2.0",
"vue": "^3.4.0",
"vue-tsc": "^2.1.0"
}
}
这里有一个常见但很重要的点:Vue 要放在 peerDependencies。
因为组件库通常不应该自带一份 Vue,否则业务项目容易出现多实例问题。
第十步:运行与构建
开发调试:
pnpm dev
构建产物:
pnpm build
构建完成后,理论上你会得到:
dist
├─ index.es.js
├─ index.umd.js
├─ style
│ └─ style.css
└─ types
└─ src
├─ index.d.ts
└─ ...
逐步验证清单
你做完后,建议不要只看“能不能 build 成功”,而是按下面顺序验证:
1. 本地组件是否正常渲染
- Button 样式是否生效
- disabled 是否可用
- click 事件是否能触发
2. TypeScript 提示是否正常
type是否只允许联合类型disabled是否是 boolean- 事件类型是否能推断
3. 构建产物是否合理
- 是否生成
.d.ts vue是否没有被打包进 dist- 样式文件是否输出成功
4. 模拟业务消费
可以用 pnpm link 或者 npm pack 到另一个项目里测试:
pnpm pack
然后在另一个项目中安装生成的 tgz 包,检查:
- 组件是否能导入
- 样式是否正常
- 类型是否正常
- 是否出现多份 Vue
核心原理再深入一点:为什么这套方案可扩展
当组件库从 1 个组件变成 20 个组件时,最怕的是前面图省事,后面没法扩展。
这套方案之所以适合继续做大,关键在于下面几点。
1. 入口分层清晰
- 单组件入口:
src/components/button/index.ts - 总入口:
src/index.ts
这意味着你后面可以同时支持:
- 全量注册
- 单组件注册
- 子路径导入
例如未来扩展成:
{
"exports": {
".": {
"import": "./dist/index.es.js",
"types": "./dist/types/src/index.d.ts"
},
"./button": {
"import": "./dist/button/index.js",
"types": "./dist/types/src/components/button/index.d.ts"
}
}
}
2. 样式层有独立边界
公共样式不直接散落在业务逻辑里,后面做主题系统就容易很多。
3. TS 构建与运行时构建解耦
很多初学者把“JS 构建”和“类型生成”混在一起,一旦出问题就难排查。
拆开以后,问题更容易定位:
- JS 构建错,看 Vite/Rollup
- 类型错,看 TS/dts 插件
Mermaid:工程模块关系图
下面用类图表示组件库几个关键模块的职责关系:
classDiagram
class LibraryEntry {
+install(app)
+export components
}
class ButtonComponent {
+props: type, disabled
+emit click
}
class BuildSystem {
+vite build
+rollup external
+output esm/umd
}
class TypeSystem {
+tsconfig.build.json
+generate d.ts
}
class Playground {
+local debug
+hot reload
}
LibraryEntry --> ButtonComponent
BuildSystem --> LibraryEntry
TypeSystem --> LibraryEntry
Playground --> ButtonComponent
常见坑与排查
这部分非常重要。我自己做组件库时,真正花时间的往往不是“搭起来”,而是“为什么它在别人项目里不正常”。
坑 1:类型声明生成了,但 IDE 没提示
现象
- 包安装成功
- 组件能运行
- 但 TS 提示缺失或不准确
排查点
- 检查
package.json的types路径是否正确 - 检查
exports中是否声明了types - 检查
vite-plugin-dts输出目录是否与你填写的一致 - 检查
.vue组件的类型是否被正确处理
建议
先直接打开 dist/types 看文件真实路径,不要凭感觉写。
坑 2:业务项目用了组件,但样式没生效
常见原因
- 样式没有在库入口引入
- 样式被
scoped限制后无法作用到预期结构 - 构建后 CSS 文件没有被正确输出
- 消费方没有处理样式资源
排查方式
- 看
dist里有没有 CSS - 打开浏览器 DevTools,看样式是否真正加载
- 检查入口是否
import './styles/index.scss'
我之前就踩过一个坑:组件本地调试正常,发包后样式全没了。最后发现是入口没引公共样式,playground 里因为单独引了样式所以掩盖了问题。
坑 3:打包后出现两份 Vue
现象
- 组件无法正常渲染
instanceof判断异常- 插件安装行为不一致
原因
组件库把 Vue 当成普通依赖打进产物里了。
解决方案
rollupOptions.external: ['vue']package.json中把vue放到peerDependencies
坑 4:按需引入不好做
原因
你可能只做了总入口,没有保留组件级入口。
建议
从一开始就按“组件目录 + 独立入口”的方式组织。
即使暂时没做自动按需,也要给未来留结构空间。
坑 5:vite-plugin-dts 输出路径混乱
现象
产物里出现多层嵌套路径,比如:
dist/types/src/components/button/src/button.vue.d.ts
处理建议
- 用更清晰的源码目录结构
- 在构建前确认
include范围 - 必要时配合
beforeWriteFile做路径修正
如果你的库规模不大,先接受一版可用的声明结构也没问题,不必过度追求“目录绝对漂亮”。
坑 6:UMD 其实根本不需要
有些教程默认要产出 ESM + CJS + UMD 全家桶。
但如果你的组件库只给现代 Vue 项目用,往往只要 ESM 就够了。
建议边界
- 内部中后台组件库:优先 ESM
- 需要 CDN/script 标签直出:再考虑 UMD
- Node 侧兼容要求高:再考虑 CJS
工程化不是格式越多越专业,而是刚好满足消费场景。
安全/性能最佳实践
组件库虽然主要是 UI 层,但也不是没有安全和性能问题。
1. 不要滥用 v-html
如果你的组件支持富文本内容,直接暴露 v-html 是有 XSS 风险的。
建议:
- 默认只接受普通文本
- 如必须支持 HTML,明确注明由调用方负责内容安全
- 更稳妥的做法是接入白名单过滤
2. 控制包体积
建议关注这几件事:
- 把大依赖设为 external
- 避免把工具库整包引入
- 样式变量与组件逻辑分离
- 减少重复的运行时代码
3. 组件 props 设计尽量稳定
性能问题有时不是“渲染慢”,而是“API 设计导致不可控使用”。
例如:
- props 命名含糊,后面频繁改
- 一个组件同时承担太多职责
- 通过对象 props 传入巨大配置,导致 diff 成本高
建议优先保证:
- props 简单、清晰、可预测
- 事件语义明确
- 默认值稳定
4. 提供 sourcemap 方便定位问题
在库构建时保留 sourcemap,对于排查线上组件问题很有帮助。
尤其是团队内部组件库,收益非常明显。
5. 样式命名避免污染
如果你不使用 CSS Modules 或原子化方案,至少要做前缀约束:
my-buttonmy-inputmy-dialog
避免和业务项目类名冲突。
6. 对外暴露面尽量收敛
不是所有内部工具函数都应该 export。
只暴露稳定 API,能减少未来维护负担。
进阶扩展建议
如果你打算继续把这个库做成团队级方案,后续可以按这个顺序扩展:
1. 自动化测试
推荐至少补:
- 组件单测:Vitest
- DOM 交互测试:Testing Library
- 关键样式快照测试
2. 文档站点
可以接入:
- VitePress
- Storybook
如果你是团队内部使用,我更推荐 VitePress,轻量、够用、维护成本低。
3. 自动发布
常见组合:
- Changesets
- GitHub Actions
- npm publish
4. 主题系统
后续可基于 CSS 变量或 Sass Token 扩展:
- 亮色/暗色主题
- 品牌色切换
- 尺寸体系统一
5. 按需加载
可以继续演进到:
- 子路径导出
- 自动样式引入
- 配套 unplugin 插件
不过这里要提醒一句:
按需加载不是第一阶段的重点。
先把结构、类型、发布链路做好,后面再优化导入体验,性价比更高。
一份更贴近生产的建议清单
如果你准备把这套方案真正用于团队,我建议最低做到这些:
- 组件目录独立
- 全量安装入口
- 类型声明生成
- Vue external + peerDependencies
- playground 本地调试
- 样式统一入口
- 构建产物可被真实业务项目消费
如果是团队共享库,再加上:
- 单元测试
- 文档站点
- 版本变更记录
- 自动发布流程
- eslint + prettier + commitlint
总结
用 Vite + TypeScript 搭组件库,真正的重点从来不是“把一个 Button 打包出来”,而是让这套工程在未来继续长大时,依然不崩。
这篇文章带你完成了一套中级前端可落地的组件库工程方案,核心包括:
- 用 Vite 的库模式完成构建
- 用 TypeScript +
vite-plugin-dts输出类型声明 - 用清晰目录支持组件扩展
- 用 playground 提升本地开发效率
- 用
peerDependencies + external处理依赖边界 - 用统一入口支持全量注册
如果你问我最实用的建议是什么,我会给三个:
-
先做稳定结构,再追求高级特性
不要一开始就把精力全花在自动按需、主题切换、脚手架上。 -
一定要做真实消费验证
build 成功不代表可用,最好用另一个业务项目安装测试。 -
把组件库当产品维护,而不是代码仓库
它有用户、有 API、有兼容性成本,也需要文档、版本和质量保障。
如果你现在正在从“会封装组件”往“能搭工程体系”迈一步,这套方案就是一个很好的起点。