一、什么是组件封装
组件封装是将 UI 和业务逻辑打包成一个可复用、可独立维护的单元,对外暴露清晰的接口(Props/Events/Slots),隐藏内部实现细节。
┌──────────────────────────────┐
│ 封装的组件 │
│ │
│ Props(输入)→ ┌──────┐ │
│ │ 内部 │ │
│ │ 实现 │ → Events(输出)│
│ Slots(插槽)→ │ 细节 │ │
│ └──────┘ │
│ (样式、状态、逻辑) │
└──────────────────────────────┘封装 = 高内聚 + 低耦合:组件内部高度聚合,对外依赖最小化。
二、为什么要封装组件
| 原因 | 说明 |
|---|---|
| 复用性 | 一次编写,多处使用,减少重复代码 |
| 可维护性 | 修改只影响组件内部,不影响外部 |
| 可测试性 | 独立组件更容易编写单元测试 |
| 协作效率 | 团队成员可以并行开发不同组件 |
| 一致性 | 统一 UI 风格和交互行为 |
三、Props 接口设计
Props 是组件的"输入参数",是组件封装的第一步。
3.1 基本原则
<script setup>
// ✅ 好的 Props 设计:语义化、类型明确、有默认值
const props = defineProps({
title: {
type: String,
required: true,
default: ''
},
size: {
type: String,
validator: (v) => ['small', 'medium', 'large'].includes(v),
default: 'medium'
},
disabled: {
type: Boolean,
default: false
}
})
</script>3.2 用对象代替多个离散参数
// ❌ 不好:多个离散参数,调用方难以记忆
<UserCard
name="张三"
age="25"
avatar="/img.jpg"
email="..."
phone="..."
/>
// ✅ 好:关联数据放在一起
<UserCard :user="userData" />
// 或者用 config 对象分组
<DataTable
:columns="tableColumns"
:data="tableData"
:pagination="{
page: 1,
pageSize: 20,
total: 100
}"
/>3.3 使用布尔 Props 的正确姿势
// ✅ 推荐:语义化的布尔 prop
<Button loading />
<Button disabled />
<Button readonly />
// ❌ 避免:含义模糊的字符串枚举
<Button status="loading" />
<Button state="disabled" />布尔 Prop 的传入即视为 true:
<Button loading /> <!-- loading = true -->
<Button :loading="true" /> <!-- 同上 -->
<Button :loading="false" /> <!-- loading = false -->3.4 单向数据流
Props 是只读的,子组件不能直接修改父组件的数据:
// ❌ 错误:直接修改 prop
const props = defineProps(['count'])
props.count++ // 警告!
// ✅ 正确:通过事件通知父组件修改
const emit = defineEmits(['update:count'])
function handleClick() {
emit('update:count', props.count + 1)
}配合 v-model 实现双向绑定:
<!-- 父组件 -->
<Counter v-model:count="pageCount" />
<!-- 子组件 -->
<script setup>
const props = defineProps(['count'])
const emit = defineEmits(['update:count'])
</script>四、事件与通信
4.1 自定义事件
组件通过事件向父组件传递信息:
<!-- SearchInput.vue -->
<script setup>
const emit = defineEmits(['search', 'clear', 'focus'])
function handleSearch(value) {
emit('search', value)
}
function handleClear() {
emit('clear')
}
</script>
<template>
<div class="search-input">
<input @input="handleSearch($event.target.value)" />
<button @click="handleClear">清除</button>
</div>
</template>4.2 事件命名规范
// ✅ 推荐:kebab-case 事件名(HTML 原生风格)
emit('update:model-value')
emit('item-selected')
emit('before-close')
// ❌ 避免:camelCase(在模板中不直观)
emit('updateModelValue')
emit('itemSelected')
emit('beforeClose')4.3 暴露组件方法(Expose)
某些场景下父组件需要直接调用子组件的方法:
<!-- FormInput.vue -->
<script setup>
import { ref } from 'vue'
const inputRef = ref(null)
function focus() {
inputRef.value?.focus()
}
function validate() {
// 校验逻辑...
return true
}
// 暴露给父组件
defineExpose({ focus, validate })
</script>
<!-- 父组件调用 -->
<script setup>
const formRef = ref(null)
formRef.value?.focus()
formRef.value?.validate()
</script>五、Slot 插槽机制
插槽让组件的内容区域可定制,实现"骨架固定 + 内容灵活"。
5.1 基础插槽
<!-- Card.vue -->
<template>
<div class="card">
<div class="card-header">
<slot name="header">
<!-- 默认标题 -->
<h3>默认标题</h3>
</slot>
</div>
<div class="card-body">
<!-- 默认插槽:主要内容 -->
<slot />
</div>
<div class="card-footer">
<slot name="footer" />
</div>
</div>
</template>
<!-- 使用 -->
<Card>
<template #header>
<h3>用户信息</h3>
<button>编辑</button>
</template>
<p>姓名:张三</p>
<p>年龄:25</p>
<template #footer>
<button>保存</button>
<button>取消</button>
</template>
</Card>5.2 作用域插槽
插槽可以访问子组件的数据:
<!-- Select.vue -->
<script setup>
const props = defineProps({
options: { type: Array, required: true }
})
const selected = ref(null)
</script>
<template>
<ul class="select-options">
<li
v-for="item in options"
:key="item.value"
@click="selected = item.value"
>
<!-- 将 item 数据传递给父组件自定义渲染 -->
<slot name="option" :item="item" :selected="selected === item.value">
{{ item.label }}
</slot>
</li>
</ul>
</template>
<!-- 使用作用域插槽自定义选项样式 -->
<Select :options="countries">
<template #option="{ item, selected }">
<span :class="{ active: selected }">
<img :src="item.flag" class="flag" />
{{ item.label }} ({{ item.code }})
</span>
</template>
</Select>六、样式封装
6.1 Scoped CSS
Vue 的 scoped 通过添加 data 属性实现样式隔离:
<style scoped>
/* 只影响当前组件,不影响子组件 */
.card {
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* 深度选择器:穿透到子组件 */
:deep(.table-cell) {
padding: 12px;
}
/* 插槽内容样式 */
:slotted(.custom-header) {
font-weight: bold;
}
</style>6.2 CSS Modules
/* Button.module.css */
.button {
display: inline-flex;
align-items: center;
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
}
.primary {
background: #1890ff;
color: #fff;
}
.danger {
background: #ff4d4f;
color: #fff;
}// React 中使用
import styles from './Button.module.css'
function Button({ variant = 'primary', children }) {
return (
<button
className={`${styles.button} ${styles[variant]}`}
>
{children}
</button>
)
}6.3 CSS-in-JS
// styled-components 示例
import styled from 'styled-components'
const StyledButton = styled.button<{ $variant: 'primary' | 'danger' }>`
display: inline-flex;
align-items: center;
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
background: ${props => props.$variant === 'danger' ? '#ff4d4f' : '#1890ff'};
color: white;
&:hover {
opacity: 0.9;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`七、组件组合模式
7.1 复合组件模式
将相关组件组合在一起,通过命名空间组织:
<!-- 复合组件:Select + Option -->
<Select v-model="selected">
<SelectOption value="1">选项一</SelectOption>
<SelectOption value="2">选项二</SelectOption>
<SelectOption value="3">选项三</SelectOption>
</Select>
<!-- 实现思路 -->
<script setup>
// Select.vue
import { provide, ref } from 'vue'
const selected = defineModel()
provide('selectValue', selected)
// SelectOption.vue
import { inject } from 'vue'
const selectValue = inject('selectValue')
const isSelected = computed(() => selectValue.value === props.value)
</script>7.2 渲染函数与 Render Props
// React Render Props 模式
interface DataTableProps<T> {
data: T[]
renderRow: (item: T, index: number) => React.ReactNode
renderEmpty?: () => React.ReactNode
}
function DataTable<T>({ data, renderRow, renderEmpty }: DataTableProps<T>) {
if (data.length === 0 && renderEmpty) {
return renderEmpty()
}
return (
<table>
<tbody>
{data.map((item, index) => renderRow(item, index))}
</tbody>
</table>
)
}
// 使用
<DataTable
:data="users"
:renderRow="(user) => (
<tr>
<td>{user.name}</td>
<td>{user.email}</td>
</tr>
)"
:renderEmpty="() => <p>暂无数据</p>"
/>7.3 高阶组件(HOC)
// 高阶组件:给组件添加通用能力
function withLoading<T>(WrappedComponent: React.ComponentType<T>) {
return function WithLoadingComponent(props: T & { loading?: boolean }) {
if (props.loading) {
return <div className="loading-spinner">加载中...</div>
}
return <WrappedComponent {...props} />
}
}
// 使用
const UserListWithLoading = withLoading(UserList)
<UserListWithLoading loading={isLoading} users={users} />八、组件封装检查清单
8.1 设计阶段
- [ ] 这个组件有明确的单一职责吗?
- [ ] Props 的命名是否语义化、类型是否完整?
- [ ] 是否有合理的默认值?
- [ ] 是否提供了必要的插槽/事件?
8.2 实现阶段
- [ ] 样式是否隔离?(scoped / CSS Modules)
- [ ] 是否处理了边界情况?(空数据、加载中、错误)
- [ ] 是否避免直接修改 Props?
- [ ] 事件命名是否符合规范?
8.3 测试阶段
// Vue 组件测试示例
import { mount } from '@vue/test-utils'
import Button from './Button.vue'
describe('Button', () => {
it('渲染默认状态', () => {
const wrapper = mount(Button, {
props: { label: '提交' }
})
expect(wrapper.text()).toContain('提交')
})
it('禁用状态下触发点击', async () => {
const wrapper = mount(Button, {
props: { label: '提交', disabled: true }
})
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeFalsy()
})
it('loading 状态显示加载图标', () => {
const wrapper = mount(Button, {
props: { label: '保存', loading: true }
})
expect(wrapper.find('.loading-icon').exists()).toBe(true)
})
})九、实战示例:封装一个确认弹窗
<!-- ConfirmDialog.vue -->
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
visible: { type: Boolean, default: false },
title: { type: String, default: '确认操作' },
message: { type: String, required: true },
confirmText: { type: String, default: '确定' },
cancelText: { type: String, default: '取消' },
type: {
type: String,
validator: v => ['info', 'warning', 'danger'].includes(v),
default: 'info'
}
})
const emit = defineEmits(['update:visible', 'confirm', 'cancel'])
function handleConfirm() {
emit('confirm')
emit('update:visible', false)
}
function handleCancel() {
emit('cancel')
emit('update:visible', false)
}
// ESC 键关闭
function onKeydown(e) {
if (e.key === 'Escape' && props.visible) {
handleCancel()
}
}
watch(() => props.visible, (val) => {
if (val) {
document.addEventListener('keydown', onKeydown)
} else {
document.removeEventListener('keydown', onKeydown)
}
})
</script>
<template>
<Teleport to="body">
<Transition name="fade">
<div v-if="visible" class="dialog-overlay" @click.self="handleCancel">
<div class="dialog-panel" :class="`dialog-${type}`">
<div class="dialog-header">
<slot name="icon">
<span class="dialog-icon">⚠️</span>
</slot>
<h3>{{ title }}</h3>
</div>
<div class="dialog-body">
<slot name="message">
<p>{{ message }}</p>
</slot>
</div>
<div class="dialog-footer">
<button class="btn btn-cancel" @click="handleCancel">
{{ cancelText }}
</button>
<button class="btn btn-confirm" @click="handleConfirm">
{{ confirmText }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dialog-panel {
background: #fff;
border-radius: 12px;
padding: 24px;
min-width: 360px;
max-width: 480px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>十、总结
组件封装是前端工程化的核心技能,好的封装需要做到:
- 接口清晰 — Props 语义化、类型完整、有默认值
- 内容灵活 — 利用插槽/Slots 让调用方自定义渲染
- 样式隔离 — scoped / CSS Modules / CSS-in-JS
- 单向数据流 — 不直接修改 Props,通过事件通信
- 边界处理 — 空状态、加载中、错误状态都要考虑
- 可测试 — 单元测试覆盖核心功能
封装的核心思想:暴露"什么"(What),隐藏"怎么实现"(How)。