第7章 7.5 综合实战:组件库开发

🎯 上一章我们把 Vue3 项目部署到了 Vercel、Netlify 和 Nginx 上,你的「小明的购物清单」终于可以在网上访问了。但不知道你有没有这种感觉——每次做新项目,都要重新写按钮、写弹窗、写表单,烦不烦?

这一章,我们来解决这个痛点:自己动手做一个组件库。

啥意思呢?就好比你是一家餐厅的厨师,每次客人点宫保鸡丁,你不用从养鸡开始,而是去冰箱里拿事先准备好的鸡丁。组件库就是你的「冰箱」——把常用的按钮、弹窗、表单提前做好,下次直接拿来用。

学完这章,你能:
- 用 Vite 库模式创建一个组件库项目
- 写出 Button、Modal、Form 这三个最常用组件
- 把组件发布到 npm,别人 npm install 就能用你的库


🧱 基础 25 分钟:组件库核心概念

7.5.1 先搞懂:啥是组件库?

是什么?
组件库就是一堆「可复用 UI 零件」的集合。按钮、输入框、弹窗、下拉菜单……把它们抽离成独立的代码块,哪里用到哪里引用。

生活类比:\n\nSimple tech illustration expla\n\nAI comic creation scene, creat\n\n
想象你装修房子,不用每面墙都自己砌砖,而是买标准化的「砌块」。这些砌块就是「装修组件库」,你只需要按图纸拼装。

为啥要用?
- 省时间:不用重复造轮子
- 统一风格:整个项目视觉一致
- 好维护:改一个地方,所有用到的地方都生效

7.5.2 用 Vite 库模式创建项目

Vue 官方推荐用 Vite 的「库模式」来开发组件库。我们来创建项目:

npm create vite@latest my-ui-lib -- --template vue
cd my-ui-lib
npm install

然后修改 vite.config.js,改成库模式输出:

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
plugins: [vue()],
build: {
// 库模式配置
lib: {
  entry: './src/index.js',      // 入口文件
  name: 'MyUILib',              // 库的名字(PascalCase)
  fileName: 'my-ui-lib',        // 输出文件名(kebab-case)
},
rollupOptions: {
  // 外部依赖(不打包进库)
  external: ['vue'],
  output: {
    // 在 UMD 构建中提供 Vue 的全局变量
    globals: {
      vue: 'Vue',
    },
  },
},
},
})

解释一下:这段配置告诉 Vite「我要把代码打包成一个库,而不是一个网站」。entry 是入口,lib 是输出配置。

7.5.3 组件库的文件结构

一个正经的组件库,长这样:

my-ui-lib/
├── src/
│   ├── components/     # 放组件
│   │   ├── Button/
│   │   │   └── Button.vue
│   │   ├── Modal/
│   │   │   └── Modal.vue
│   │   └── Form/
│   │       └── Form.vue
│   ├── index.js        # 入口文件(导出所有组件)
│   └── style.css       # 全局样式
├── package.json
└── vite.config.js

这个结构的好处是:每个组件一个文件夹,样式、逻辑、测试都放一起,井水不犯河水。

7.5.4 写第一个组件:Button

来,写一个按钮组件。这是最简单的,但能让你理解组件库的本质。

<!-- src/components/Button/Button.vue -->
<script setup>
defineProps({
type: {
type: String,
default: 'primary', // primary / success / warning / danger
},
disabled: {
type: Boolean,
default: false,
},
})

defineEmits(['click'])
</script>

<template>
<button
:class="['my-button', `my-button--${type}`, { 'my-button--disabled': disabled }]"
:disabled="disabled"
@click="$emit('click')"
>
<slot />
</button>
</template>

<style scoped>
.my-button {
padding: 8px 16px;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}

.my-button--primary {
background: #409eff;
color: white;
}

.my-button--success {
background: #67c23a;
color: white;
}

.my-button--warning {
background: #e6a23c;
color: white;
}

.my-button--danger {
background: #f56c6c;
color: white;
}

.my-button--disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

解释一下:defineProps 定义了组件的「接口」(type 和 disabled),defineEmits 定义了它能触发的事件(click),<slot /> 是插槽,让使用者往按钮里塞文字或图标。

7.5.5 写第二个组件:Modal 弹窗

弹窗稍微复杂一点,因为它涉及到「显示/隐藏」的状态管理。

<!-- src/components/Modal/Modal.vue -->
<script setup>
import { watch } from 'vue'

const props = defineProps({
visible: {
type: Boolean,
required: true,
},
title: {
type: String,
default: '提示',
},
})

const emit = defineEmits(['update:visible', 'close'])

// 监听 visible 变化,阻止背景滚动
watch(() => props.visible, (val) => {
document.body.style.overflow = val ? 'hidden' : ''
})

const close = () => {
emit('update:visible', false)
emit('close')
}
</script>

<template>
<Teleport to="body">
<Transition name="modal">
  <div v-if="visible" class="my-modal-overlay" @click.self="close">
    <div class="my-modal">
      <div class="my-modal__header">
        <span>{{ title }}</span>
        <button class="my-modal__close" @click="close">×</button>
      </div>
      <div class="my-modal__body">
        <slot />
      </div>
      <div class="my-modal__footer">
        <slot name="footer" />
      </div>
    </div>
  </div>
</Transition>
</Teleport>
</template>

<style scoped>
.my-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}

.my-modal {
background: white;
border-radius: 8px;
min-width: 400px;
max-width: 90%;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}

.my-modal__header {
padding: 16px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
font-weight: bold;
}

.my-modal__close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #999;
}

.my-modal__body {
padding: 16px;
}

.my-modal__footer {
padding: 16px;
border-top: 1px solid #eee;
}
</style>

<style>
/* 过渡动画 */
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s;
}

.modal-enter-from,
.modal-leave-to {
opacity: 0;
}

.modal-enter-active .my-modal,
.modal-leave-active .my-modal {
transition: transform 0.3s;
}

.modal-enter-from .my-modal,
.modal-leave-to .my-modal {
transform: scale(0.9);
}
</style>

这里有个新东西:<Teleport to="body">。它的作用是把弹窗直接挂到 body 下,这样不管你在哪个层级调用弹窗,它都能「穿透」出来显示在最上层。

7.5.6 写第三个组件:Form 表单

表单组件比较特殊,它不是一个「看得见的 UI」,而是一个「管理状态的容器」。我们用provide/inject来做:

<!-- src/components/Form/Form.vue -->
<script setup>
import { provide, reactive } from 'vue'

const props = defineProps({
modelValue: {
type: Object,
required: true,
},
})

const emit = defineEmits(['update:modelValue', 'submit'])

// 表单数据
const formData = reactive(props.modelValue)

// 监听变化,同步到父组件
const updateField = (field, value) => {
formData[field] = value
emit('update:modelValue', { ...formData })
}

// 暴露方法给子组件
provide('formContext', {
modelValue: formData,
updateField,
})

// 表单提交
const handleSubmit = () => {
emit('submit', { ...formData })
}
</script>

<template>
<form class="my-form" @submit.prevent="handleSubmit">
<slot />
</form>
</template>

<style scoped>
.my-form {
padding: 16px;
}
</style>
<!-- src/components/Form/FormItem.vue -->
<script setup>
import { inject } from 'vue'

const props = defineProps({
label: {
type: String,
required: true,
},
field: {
type: String,
required: true,
},
})

const { modelValue, updateField } = inject('formContext')
</script>

<template>
<div class="my-form-item">
<label class="my-form-item__label">{{ label }}</label>
<input
  class="my-form-item__input"
  :value="modelValue[field]"
  @input="updateField(field, $event.target.value)"
/>
</div>
</template>

<style scoped>
.my-form-item {
margin-bottom: 12px;
}

.my-form-item__label {
display: block;
margin-bottom: 4px;
font-size: 14px;
color: #666;
}

.my-form-item__input {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}

.my-form-item__input:focus {
outline: none;
border-color: #409eff;
}
</style>

解释一下这里的设计思路:Form 是「管理者」,FormItem 是「被管理者」。用 provide/inject 传递上下文,这样 FormItem 不用关心自己的 Form 在哪里,直接喊「喂,表单,帮我更新这个字段」就行。

7.5.7 导出所有组件

最后一步,在入口文件里导出所有组件:

// src/index.js
import Button from './components/Button/Button.vue'
import Modal from './components/Modal/Modal.vue'
import Form from './components/Form/Form.vue'
import FormItem from './components/Form/FormItem.vue'

// 导出所有组件
export {
Button,
Modal,
Form,
FormItem,
}

// 方便全局注册
export default {
install(app) {
app.component('Button', Button)
app.component('Modal', Modal)
app.component('Form', Form)
app.component('FormItem', FormItem)
},
}

这样别人用的时候可以:
- 逐个引入:import { Button } from 'my-ui-lib'
- 全局注册:app.use(MyUILib)


🔥 实战 35 分钟:三个递进项目

项目 1(5 分钟):用组件库写一个最简单的页面

目标: 学会怎么「用」自己写的组件库

创建 src/App.vue

<script setup>
import { ref } from 'vue'
import { Button, Modal } from './index.js'

const showModal = ref(false)
</script>

<template>
<div class="demo">
<h1>我的组件库演示</h1>

<Button type="primary" @click="showModal = true">点我弹窗</Button>
<Button type="success">成功按钮</Button>
<Button type="warning">警告按钮</Button>
<Button type="danger" disabled>禁用按钮</Button>

<Modal v-model:visible="showModal" title="欢迎弹窗">
  <p>恭喜你学会了组件库开发!</p>
  <template #footer>
    <Button type="primary" @click="showModal = false">知道了</Button>
  </template>
</Modal>
</div>
</template>

<style>
.demo {
padding: 40px;
}
.demo h1 {
margin-bottom: 20px;
}
.demo button {
margin-right: 10px;
margin-bottom: 10px;
}
</style>

预期输出: 页面上显示一排按钮,点击「点我弹窗」会弹出「欢迎弹窗」。

解释: 看到了吗?写业务代码的时候,你根本不用关心按钮长什么样、弹窗怎么实现,直接 import 拿来用就行。


项目 2(15 分钟):做一个「联系人管理」页面

目标: 把组件库用起来,做一个真实场景的小工具

<!-- src/App.vue -->
<script setup>
import { ref, reactive } from 'vue'
import { Button, Modal, Form, FormItem } from './index.js'

const showAddModal = ref(false)
const contactForm = reactive({
name: '',
phone: '',
email: '',
})

const contacts = ref([
{ id: 1, name: '小明', phone: '13800138000', email: 'xiaoming@example.com' },
{ id: 2, name: '小红', phone: '13900139000', email: 'xiaohong@example.com' },
])

const openAddModal = () => {
contactForm.name = ''
contactForm.phone = ''
contactForm.email = ''
showAddModal.value = true
}

const handleAdd = () => {
contacts.value.push({
id: Date.now(),
...contactForm,
})
showAddModal.value = false
}

const deleteContact = (id) => {
contacts.value = contacts.value.filter(c => c.id !== id)
}
</script>

<template>
<div class="contacts-page">
<h1>联系人管理</h1>

<div class="toolbar">
  <Button type="primary" @click="openAddModal">新增联系人</Button>
</div>

<table class="contacts-table">
  <thead>
    <tr>
      <th>姓名</th>
      <th>电话</th>
      <th>邮箱</th>
      <th>操作</th>
    </tr>
  </thead>
  <tbody>
    <tr v-for="contact in contacts" :key="contact.id">
      <td>{{ contact.name }}</td>
      <td>{{ contact.phone }}</td>
      <td>{{ contact.email }}</td>
      <td>
        <Button type="danger" @click="deleteContact(contact.id)">删除</Button>
      </td>
    </tr>
  </tbody>
</table>

<Modal v-model:visible="showAddModal" title="新增联系人">
  <Form :model-value="contactForm" @submit="handleAdd">
    <FormItem label="姓名" field="name">
      <input v-model="contactForm.name" placeholder="请输入姓名" />
    </FormItem>
    <FormItem label="电话" field="phone">
      <input v-model="contactForm.phone" placeholder="请输入电话" />
    </FormItem>
    <FormItem label="邮箱" field="email">
      <input v-model="contactForm.email" placeholder="请输入邮箱" />
    </FormItem>
  </Form>
  <template #footer>
    <Button @click="showAddModal = false">取消</Button>
    <Button type="primary" @click="handleAdd">保存</Button>
  </template>
</Modal>
</div>
</template>

<style>
.contacts-page {
padding: 40px;
max-width: 800px;
margin: 0 auto;
}
.toolbar {
margin-bottom: 20px;
}
.contacts-table {
width: 100%;
border-collapse: collapse;
}
.contacts-table th,
.contacts-table td {
border: 1px solid #ddd;
padding: 12px;
text-align: left;
}
.contacts-table th {
background: #f5f5f5;
font-weight: bold;
}
.contacts-table tr:hover {
background: #fafafa;
}
</style>

预期输出: 页面显示一个联系人表格,有「新增」和「删除」功能,点击新增会弹出表单弹窗。

解释: 这个页面用到了所有三个组件:Button 的各种状态、Modal 的弹窗功能、Form 的表单管理。组件库的价值在这里体现——你只用了 100 行代码,就做出了一个完整的小工具


项目 3(15 分钟):把组件库打包发布

目标: 学会怎么把组件库发布到 npm,让别人也能用

首先,修改 package.json

{
"name": "my-ui-lib-demo",
"version": "1.0.0",
"private": false,
"main": "./dist/my-ui-lib.umd.js",
"module": "./dist/my-ui-lib.es.js",
"exports": {
".": {
  "import": "./dist/my-ui-lib.es.js",
  "require": "./dist/my-ui-lib.umd.js"
}
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
}
}

然后运行打包:

npm run build

打包完成后,dist 目录里会生成:
- my-ui-lib.es.js — ES Module 版本(现代浏览器)
- my-ui-lib.umd.js — UMD 版本(老浏览器 + 直接 script 引用)

如果想发布到 npm(公开包):

# 登录 npm
npm login

# 发布
npm publish --access public

注意!发布之前把 package.json 里的 "private": false 加上,否则 npm 不让发。

解释: 打包发布这一步,就像是你把「冰箱里的半成品」封装好,贴上标签,放到超市货架上。以后别人 npm install my-ui-lib 就能用你的组件了。


💪 进阶 20 分钟:常见坑 + 小贴士

坑 1:组件名冲突

错误示例: 直接叫 Button,万一项目里已经有别人写的 Button 组件?

正确做法: 起个有前缀的名字,比如 MyButton,或者在发布到 npm 时用 @yourname/ui 这种作用域包名。

# 作用域包名(推荐)
npm init @yourname/ui-lib
# 这样包名就变成了 @yourname/ui-lib

坑 2:样式污染

错误示例: 所有样式都写在 style 里,打包后和别人的样式冲突。

正确做法: 给所有类名加前缀(如 my-),并且用 scoped 限制作用域。

/* 加前缀 */
.my-button { ... }
.my-modal { ... }

/* 或者用 CSS Modules */

坑 3:Teleport 目标不存在

错误示例: <Teleport to="#non-existent">,如果目标元素不存在,弹窗就消失了。

正确做法: 确保目标容器存在,或者用 body(Vue 会自动处理)。

<!-- 安全的做法 -->
<Teleport to="body">
<!-- 弹窗内容 -->
</Teleport>

坑 4:v-model 在组件里不好用

错误示例: 直接在组件里用 v-model="show",以为会自动同步。

正确做法: Vue 3 的组件 v-model 需要用 v-model:visible(参数名):

<!-- 父组件 -->
<Modal v-model:visible="showModal" />

<!-- 组件内部要手动处理 -->
const emit = defineEmits(['update:visible'])
const close = () => emit('update:visible', false)

坑 5:没处理 SSR

错误示例: 直接 document.body.style.overflow = 'hidden',在 SSR 环境下会报错(没有 document)。

正确做法: 加个判断:

import { inBrowser } from 'vitepress'

if (inBrowser) {
document.body.style.overflow = 'hidden'
}

性能小贴士:按需引入

全量引入: import MyUILib from 'my-ui-lib' — 把整个库都引进来

按需引入: import { Button } from 'my-ui-lib' — 只引需要的组件

// 在 main.js 里按需注册
import { Button, Modal } from 'my-ui-lib'

app.component('Button', Button)
app.component('Modal', Modal)

调试技巧:用 Vue DevTools

在开发组件库时,打开 Vue DevTools 能看到组件的 props、emit 事件、provide/inject 的值。

打开浏览器开发者工具(F12),切换到「Vue」标签页,就能看到组件树和状态。


✏️ 练习题

练习 1(2 分钟):换个按钮类型

  • 输入: 把项目 1 里的 <Button type="primary"> 改成 type="danger"
  • 预期输出: 按钮颜色从蓝色变成红色
  • 提示: 看 Button 组件的 props 定义

练习 2(2 分钟):加个禁用判断

  • 输入: 在项目 2 里,当联系人列表为空时,禁用「新增联系人」按钮
  • 预期输出: 空列表时按钮变灰、点不动
  • 提示:contacts.length === 0 判断

练习 3(3 分钟):新增一个 Badge 组件

  • 输入: 参考 Button 组件,写一个 <Badge> 组件,支持 count 属性显示数字角标
  • 预期输出: 页面上显示「99+」这样的角标
  • 提示: 角标定位用 position: absolute

练习 4(5 分钟):把项目 2 改成用 provide/inject

  • 输入: 在项目 2 的表单里,用 provide/inject 在 Form 和 FormItem 之间传递数据
  • 预期输出: 功能不变,但代码结构更合理
  • 提示: 参考「7.5.6 写第三个组件:Form 表单」

练习 5(5 分钟):分析这个报错

  • 输入: 假设你运行 npm run build 时报错:Error: Cannot find module 'vue'
  • 预期输出: 说出原因和解决方法
  • 提示: 可能是没装依赖,也可能是 external 配置有问题

作业:做一个「任务看板」组件库小工具

需求描述:
用本章开发的组件库,做一个简单的任务看板工具。

功能点:
1. 显示「待办」「进行中」「已完成」三个列表
2. 可以新增任务(输入标题,选择列表)
3. 可以把任务在不同列表之间拖拽(简化版:点击按钮移动)
4. 删除任务

加分项:
1. 给任务加上优先级颜色(高=红,中=黄,低=绿)
2. 任务数量统计显示在列表标题上

验收标准:
- 能跑起来(npm run dev
- 三个状态切换正常
- 代码有注释

提交方式: 评论区贴代码或 GitHub 链接


📚 总结 + 资源

本文学了 3 个核心点:
1. 用 Vite 库模式可以把 Vue 项目打包成组件库
2. 组件库的核心是「可复用」——Button 管按钮、Modal 管弹窗、Form 管表单
3. provide/inject 是组件间通信的利器,让「父子组件」不用一层层传值

延伸学习资源:
- Vue 官方组件库文档 — 官方出品,必属精品
- Element Plus 源码 — 学学人家怎么写工业级组件库
- Vite 库模式文档 — 官方配置指南


互动钩子: 你在做项目的时候,有没有「这个组件我好像写过」的重复劳动?是怎么处理的?评论区聊聊你的「轮子哲学」,老粉优先回复!


下章预告: 学完了桌面端的组件库,下一章我们要把它搬到手机上——用 uniapp 和 Vue3 开发移动端应用,同一套代码,打包成小程序和 H5!🚀

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。