跳转到内容
123xiao | 无名键客

《前端中级实战:用 Vite + TypeScript 搭建可扩展的组件库工程化方案》

字数: 0 阅读时长: 1 分钟

前端中级实战:用 Vite + TypeScript 搭建可扩展的组件库工程化方案

做业务项目时,很多团队都会经历一个阶段:组件越写越多,样式越来越乱,复制粘贴也越来越频繁。最开始可能只是想“封装几个按钮和弹窗”,但随着项目增多,你会发现没有一套像样的组件库工程,后面维护的成本会非常高。

这篇文章我不打算只讲“怎么初始化一个仓库”,而是带你从工程化可扩展的角度,搭一套适合中级前端继续演进的组件库方案。目标是:

  • Vite 作为开发与构建基础
  • TypeScript 保证类型体验
  • 支持 组件按需导出
  • 支持 样式打包
  • 支持 类型声明生成
  • 支持 本地调试 + 文档演示
  • 具备后续接入测试、CI、发布的扩展空间

如果你之前只做过业务型前端项目,这篇会帮你把“组件库”从一个代码目录,提升成一个真正可以交付和迭代的工程


前置知识

建议你已经具备以下基础:

  • 会使用 Vue 3 或 React 中的一种(本文示例使用 Vue 3
  • 理解 ESModule、npm 包结构
  • 会写基础 TypeScript
  • 知道 package.jsontsconfig.json 的基本作用

如果这些你都接触过,那就可以直接开始。


背景与问题

很多人第一次做组件库,通常是这样起步的:

  1. 建一个 components 文件夹
  2. 每个组件一个 .vue 文件
  3. 写一个 index.ts 全量导出
  4. 用打包工具输出一个 dist

看起来没问题,但很快会遇到几个现实问题:

1. 类型声明不完整

你在业务项目里引入组件时,IDE 提示不准确,甚至没有 props 类型推断。
这时组件“能跑”,但开发体验很差。

2. 样式与构建耦合混乱

有的组件样式丢失,有的组件样式重复打包;有的全量引入正常,按需引入就失效。

3. 导出结构不可扩展

起初你只导出 ButtonInput,后面要加 hooks、utils、主题变量、图标系统时,目录结构就开始变形。

4. 本地开发体验差

如果每次改组件都要重新 build、再去业务项目验证,效率非常低。
一个好组件库工程,应该有独立的 playground/demo 环境。

5. 发布后兼容性问题

例如:

  • ESM/CJS 产物不兼容
  • types 路径错误
  • exports 配置不规范
  • 外部依赖被错误打包进去

这些问题在本地不明显,一发包就出事。


核心原理

在正式动手前,先把这套方案的核心思路讲清楚。你理解了原理,后面就不会只是“照抄配置”。

组件库工程的目标分层

可以把组件库工程拆成四层:

  1. 源码层:组件、hooks、工具函数、样式变量
  2. 开发层:本地调试、示例页、热更新
  3. 构建层:ESM 产物、类型声明、样式产物
  4. 发布层: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 专门用于开发调试

我个人比较推荐这种结构,因为后面加 inputselectformmessage 都不会乱。


实战代码(可运行)

下面我们直接搭一个最小可用组件库:包含 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.scss
  • mixins.scss
  • tokens.scss
  • theme-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:输出 esumd
  • external: ['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 提示缺失或不准确

排查点

  1. 检查 package.jsontypes 路径是否正确
  2. 检查 exports 中是否声明了 types
  3. 检查 vite-plugin-dts 输出目录是否与你填写的一致
  4. 检查 .vue 组件的类型是否被正确处理

建议

先直接打开 dist/types 看文件真实路径,不要凭感觉写。


坑 2:业务项目用了组件,但样式没生效

常见原因

  • 样式没有在库入口引入
  • 样式被 scoped 限制后无法作用到预期结构
  • 构建后 CSS 文件没有被正确输出
  • 消费方没有处理样式资源

排查方式

  1. dist 里有没有 CSS
  2. 打开浏览器 DevTools,看样式是否真正加载
  3. 检查入口是否 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-button
  • my-input
  • my-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 处理依赖边界
  • 用统一入口支持全量注册

如果你问我最实用的建议是什么,我会给三个:

  1. 先做稳定结构,再追求高级特性
    不要一开始就把精力全花在自动按需、主题切换、脚手架上。

  2. 一定要做真实消费验证
    build 成功不代表可用,最好用另一个业务项目安装测试。

  3. 把组件库当产品维护,而不是代码仓库
    它有用户、有 API、有兼容性成本,也需要文档、版本和质量保障。

如果你现在正在从“会封装组件”往“能搭工程体系”迈一步,这套方案就是一个很好的起点。


分享到:

上一篇
《Java 中基于 CompletableFuture 的异步编排实战:从并行聚合到超时控制与异常恢复》
下一篇
《Java Web 开发中基于 Spring Boot + Redis 的接口限流实战与性能调优》