你是不是也有过这样的经历?
每次新项目开始,都要从老项目里复制一堆组件过来,然后修修改改大半天。明明看起来差不多的功能,却因为一些细微差别,不得不重新写一个"定制版"组件。
更崩溃的是,当产品经理说"这个按钮的颜色能不能统一改成蓝色"时,你需要在十几个地方手动修改...
别担心,今天我就跟你分享7个实战原则,帮你彻底告别这种低效的重复劳动。这些原则都是我在多个大型项目中总结出来的,保证接地气、可落地。
读完本文,你将能够像搭积木一样构建可复用的Vue组件,真正实现"一次编写,到处使用"。
原则一:Props设计要像API一样思考
很多新手最容易犯的错误就是把props当成简单的参数传递。其实,组件的props设计就像设计一个微型的API接口,需要考虑周全。
来看看反面教材:
javascript
// 不推荐:props设计太随意
export default {
props: {
data: Array,
config: Object,
isShow: Boolean
}
}
这种设计为什么不好?因为使用你组件的人根本不知道要传什么格式的数据!data
数组里应该有什么字段?config
对象需要哪些属性?
再看看改进后的版本:
javascript
// 推荐:明确的props设计
export default {
props: {
// 列表数据,明确每个对象的字段
items: {
type: Array,
required: true,
validator: (value) => {
return value.every(item =>
item.hasOwnProperty('id') &&
item.hasOwnProperty('label')
)
}
},
// 加载状态
loading: {
type: Boolean,
default: false
},
// 每页显示数量
pageSize: {
type: Number,
default: 10,
validator: (value) => value > 0 && value <= 100
}
}
}
看到区别了吗?好的props设计就像给使用者一份清晰的说明书,让人一看就知道该怎么用。
原则二:用插槽(Slot)打造灵活的内容结构
Props能传递数据,但如果你想要组件的内容结构也能灵活定制,插槽就是你的秘密武器。
想象一下你要做一个卡片组件,不同场景下卡片的头部、内容、底部可能完全不一样。硬编码肯定不行,这时候插槽就派上用场了。
html
<!-- 灵活的卡片组件 -->
<template>
<div class="card">
<!-- 头部插槽,有默认内容 -->
<div class="card-header">
<slot name="header">
<h3>{{ defaultTitle }}</h3>
</slot>
</div>
<!-- 主要内容插槽 -->
<div class="card-body">
<slot></slot>
</div>
<!-- 底部操作区插槽 -->
<div class="card-footer">
<slot name="actions"></slot>
</div>
</div>
</template>
<script>
export default {
props: {
defaultTitle: String
}
}
</script>
使用起来超级灵活:
html
<!-- 使用示例 -->
<template>
<CardComponent default-title="默认标题">
<!-- 覆盖头部内容 -->
<template #header>
<div class="custom-header">
<h3>自定义标题</h3>
<span class="badge">New</span>
</div>
</template>
<!-- 主要内容 -->
<p>这里是卡片的主要内容...</p>
<!-- 底部操作按钮 -->
<template #actions>
<button @click="handleSubmit">提交</button>
<button @click="handleCancel">取消</button>
</template>
</CardComponent>
</template>
插槽让组件的布局变得像乐高积木一样可以随意组合,复用性大大提升。
原则三:事件通信要语义化
组件之间的通信不能乱来,事件命名要有意义,让使用者一眼就知道这个事件在什么情况下触发。
javascript
// 不推荐:事件命名太随意
this.$emit('update')
this.$emit('change')
// 推荐:语义化的事件命名
this.$emit('page-change', { page: 2, pageSize: 20 })
this.$emit('item-selected', { item: selectedItem, index: selectedIndex })
更高级的做法是使用v-model实现双向绑定:
html
<!-- 支持v-model的输入框组件 -->
<template>
<div class="custom-input">
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
@blur="$emit('blur', $event)"
@focus="$emit('focus', $event)"
/>
</div>
</template>
<script>
export default {
props: {
modelValue: [String, Number]
},
emits: ['update:modelValue', 'blur', 'focus']
}
</script>
使用起来超级简洁:
html
<CustomInput v-model="username" @blur="handleBlur" />
原则四:组合式函数抽离业务逻辑
Vue 3的组合式API让我们可以把复杂的业务逻辑抽离成独立的函数,真正做到逻辑复用。
比如,我们经常需要处理表格数据的加载、分页、搜索,可以这样封装:
javascript
// useTable.js - 表格逻辑复用
import { ref, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
export function useTable(apiFn, options = {}) {
const {
immediate = true,
pageSize = 10,
transformResponse
} = options
// 响应式数据
const data = ref([])
const loading = ref(false)
const pagination = ref({
current: 1,
pageSize: pageSize,
total: 0
})
const searchParams = ref({})
// 计算属性
const isEmpty = computed(() => !loading.value && data.value.length === 0)
// 加载数据的方法
const loadData = async (params = {}) => {
loading.value = true
try {
const requestParams = {
page: pagination.value.current,
pageSize: pagination.value.pageSize,
...searchParams.value,
...params
}
const response = await apiFn(requestParams)
const result = transformResponse ? transformResponse(response) : response
data.value = result.list || result.data || []
pagination.value.total = result.total || 0
} catch (error) {
message.error('数据加载失败')
console.error('加载数据失败:', error)
} finally {
loading.value = false
}
}
// 搜索方法
const handleSearch = (params) => {
searchParams.value = { ...params }
pagination.value.current = 1
loadData()
}
// 重置方法
const handleReset = () => {
searchParams.value = {}
pagination.value.current = 1
loadData()
}
// 页码变化
const handlePageChange = (page, pageSize) => {
pagination.value.current = page
pagination.value.pageSize = pageSize
loadData()
}
// 立即加载数据
if (immediate) {
onMounted(loadData)
}
return {
// 数据
data,
loading,
pagination,
searchParams,
// 计算属性
isEmpty,
// 方法
loadData,
handleSearch,
handleReset,
handlePageChange
}
}
在组件中使用:
html
<template>
<div>
<SearchForm @search="handleSearch" @reset="handleReset" />
<a-table
:dataSource="data"
:loading="loading"
:pagination="pagination"
@change="handlePageChange"
>
<!-- 表格列定义 -->
</a-table>
</div>
</template>
<script setup>
import { useTable } from '@/composables/useTable'
import { getUserList } from '@/api/user'
// 使用表格逻辑
const {
data,
loading,
pagination,
handleSearch,
handleReset,
handlePageChange
} = useTable(getUserList, {
transformResponse: (response) => ({
list: response.data.list,
total: response.data.total
})
})
</script>
这样一来,所有表格相关的逻辑都被完美复用,新页面只需要几行代码就能实现完整的表格功能。
原则五:提供足够的自定义能力
一个好的可复用组件,应该像瑞士军刀一样,既能满足基本需求,又能在特殊场景下深度定制。
html
<!-- 高度可定制的按钮组件 -->
<template>
<button
:class="[
'base-button',
`base-button--${type}`,
`base-button--${size}`,
{
'base-button--disabled': disabled,
'base-button--loading': loading,
'base-button--block': block
}
]"
:disabled="disabled || loading"
@click="handleClick"
>
<!-- 加载状态 -->
<span v-if="loading" class="base-button__loading">
<LoadingIcon />
</span>
<!-- 前置图标 -->
<span v-if="$slots.prefix || icon" class="base-button__prefix">
<slot name="prefix">
<Icon :name="icon" v-if="icon" />
</slot>
</span>
<!-- 按钮文本 -->
<span class="base-button__content">
<slot>{{ text }}</slot>
</span>
<!-- 后置图标 -->
<span v-if="$slots.suffix" class="base-button__suffix">
<slot name="suffix"></slot>
</span>
</button>
</template>
<script>
export default {
props: {
type: {
type: String,
default: 'default',
validator: (value) => ['default', 'primary', 'danger', 'warning'].includes(value)
},
size: {
type: String,
default: 'medium',
validator: (value) => ['small', 'medium', 'large'].includes(value)
},
disabled: Boolean,
loading: Boolean,
block: Boolean,
icon: String,
text: String
},
emits: ['click'],
methods: {
handleClick(event) {
if (!this.disabled && !this.loading) {
this.$emit('click', event)
}
}
}
}
</script>
<style scoped>
.base-button {
/* 基础样式 */
display: inline-flex;
align-items: center;
justify-content: center;
/* 更多样式... */
}
/* 不同类型、尺寸的样式变异... */
</style>
这个按钮组件提供了多种定制方式:类型、尺寸、状态、图标、插槽内容等,几乎能满足所有按钮场景。
原则六:完善的文档和示例
代码写得再好,如果没有清晰的文档,别人也不敢用你的组件。好的文档应该包括:
markdown
# SearchTable 搜索表格组件
用于快速构建带搜索功能的表格页面。
## 基本用法
<template>
<SearchTable
:columns="columns"
:api="getUserList"
/>
</template>
## API
### Props
| 参数 | 说明 | 类型 | 默认值 |
|------|------|------|--------|
| columns | 表格列配置 | Array | [] |
| api | 数据接口函数 | Function | - |
### Events
| 事件名 | 说明 | 回调参数 |
|--------|------|----------|
| success | 数据加载成功 | (data) => {} |
## 示例
### 带搜索条件的表格
### 可编辑的表格
### 树形表格
在实际项目中,你甚至可以做一个在线的组件演示平台,让使用者直接看效果、调参数。
原则七:保持适度的抽象层次
这是最重要也最难把握的原则。组件不是越抽象越好,过度抽象会让组件变得复杂难用。
什么时候应该抽象?
- 同一个组件在3个以上地方使用
- 业务逻辑相对稳定,不会频繁变动
- 有明确的职责边界
什么时候不应该抽象?
- 只在一个地方使用的功能
- 业务逻辑还在频繁变动
- 职责边界模糊,什么功能都想往里塞
记住:先让代码工作,再考虑优化,最后才考虑抽象。
实战案例:从业务组件到通用组件
让我们看一个完整的例子,如何把一个业务组件改造成可复用的通用组件。
改造前:硬编码的业务组件
html
<template>
<div class="user-management">
<div class="search-area">
<input v-model="searchForm.name" placeholder="用户名" />
<select v-model="searchForm.role">
<option value="">全部角色</option>
<option value="admin">管理员</option>
<option value="user">普通用户</option>
</select>
<button @click="handleSearch">搜索</button>
</div>
<table>
<thead>
<tr>
<th>ID</th>
<th>用户名</th>
<th>角色</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="user in userList" :key="user.id">
<td>{{ user.id }}</td>
<td>{{ user.name }}</td>
<td>{{ user.role }}</td>
<td>
<button @click="editUser(user)">编辑</button>
<button @click="deleteUser(user.id)">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script>
export default {
data() {
return {
searchForm: {
name: '',
role: ''
},
userList: []
}
},
methods: {
async handleSearch() {
// 调用用户列表接口
},
editUser(user) {
// 编辑用户逻辑
},
deleteUser(id) {
// 删除用户逻辑
}
}
}
</script>
改造后:可复用的通用表格组件
html
<!-- GenericTable.vue -->
<template>
<div class="generic-table">
<!-- 搜索区域 -->
<div v-if="$slots.search" class="search-area">
<slot name="search" :search="handleSearch" :reset="handleReset"></slot>
</div>
<!-- 表格操作区 -->
<div v-if="$slots.actions" class="action-area">
<slot name="actions"></slot>
</div>
<!-- 表格主体 -->
<div class="table-container">
<table>
<thead>
<tr>
<th v-for="column in columns" :key="column.key">
{{ column.title }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in data" :key="getRowKey(item, index)">
<td v-for="column in columns" :key="column.key">
<slot
:name="`column-${column.key}`"
:record="item"
:index="index"
:value="item[column.key]"
>
{{ item[column.key] }}
</slot>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div v-if="pagination && data.length > 0" class="pagination-area">
<Pagination
v-bind="pagination"
@change="handlePageChange"
/>
</div>
</div>
</template>
<script>
export default {
props: {
columns: {
type: Array,
required: true
},
data: {
type: Array,
default: () => []
},
pagination: {
type: Object,
default: null
},
rowKey: {
type: [String, Function],
default: 'id'
}
},
emits: ['search', 'reset', 'page-change'],
methods: {
handleSearch(params) {
this.$emit('search', params)
},
handleReset() {
this.$emit('reset')
},
handlePageChange(pageInfo) {
this.$emit('page-change', pageInfo)
},
getRowKey(record, index) {
return typeof this.rowKey === 'function'
? this.rowKey(record, index)
: record[this.rowKey]
}
}
}
</script>
使用改造后的组件
html
<template>
<GenericTable
:columns="columns"
:data="userList"
:pagination="pagination"
@search="handleSearch"
@reset="handleReset"
@page-change="handlePageChange"
>
<!-- 自定义搜索区域 -->
<template #search="{ search, reset }">
<div class="custom-search">
<input v-model="searchForm.name" placeholder="用户名" />
<select v-model="searchForm.role">
<option value="">全部角色</option>
<option value="admin">管理员</option>
<option value="user">普通用户</option>
</select>
<button @click="search(searchForm)">搜索</button>
<button @click="reset">重置</button>
</div>
</template>
<!-- 自定义操作列 -->
<template #column-actions="{ record }">
<button @click="editUser(record)">编辑</button>
<button @click="deleteUser(record.id)">删除</button>
</template>
</GenericTable>
</template>
<script>
export default {
data() {
return {
columns: [
{ key: 'id', title: 'ID' },
{ key: 'name', title: '用户名' },
{ key: 'role', title: '角色' },
{ key: 'actions', title: '操作' }
],
searchForm: {
name: '',
role: ''
},
userList: [],
pagination: {
current: 1,
pageSize: 10,
total: 0
}
}
},
methods: {
handleSearch(params) {
// 处理搜索
},
handleReset() {
// 处理重置
},
handlePageChange(pageInfo) {
// 处理分页
}
}
}
</script>
看到差别了吗?改造后的组件可以在任何需要表格的地方使用,而不仅仅是用户管理。
总结
编写高复用性的Vue组件,本质上是一种平衡艺术:要在灵活性和易用性之间找到平衡点,要在抽象程度和具体需求之间找到平衡点。
记住这7个原则,你的组件设计能力会有质的飞跃:
- Props设计要像API一样思考 - 明确、严谨、自解释
- 用插槽打造灵活的内容结构 - 让组件布局可定制
- 事件通信要语义化 - 清晰的沟通协议
- 组合式函数抽离业务逻辑 - 逻辑复用的最高境界
- 提供足够的自定义能力 - 像瑞士军刀一样多功能
- 完善的文档和示例 - 降低使用门槛
- 保持适度的抽象层次 - 避免过度设计