目录
一、什么是自定义组件?
把项目中重复使用的、通用的模块封装成可复用的组件,方便多个页面直接调用,避免重复写同样的代码。
没有自定义组件:
┌─────────┐ ┌─────────┐ ┌─────────┐
│ 页面A │ │ 页面B │ │ 页面C │
│ 重复写 │ │ 重复写 │ │ 重复写 │
│ 分页逻辑│ │ 分页逻辑│ │ 分页逻辑│
└─────────┘ └─────────┘ └─────────┘
有自定义组件:
┌─────────────────────────────────┐
│ 分页组件(写一次) │
└─────────────────────────────────┘
↑ ↑ ↑
┌────┴────┐ │ ┌────┴────┐
│ 页面A │ │ │ 页面C │
│ 直接调用 │ │ │ 直接调用 │
└─────────┘ │ └─────────┘
┌───┴───┐
│ 页面B │
│ 直接调用│
└───────┘
二、常见自定义组件示例
| 组件类型 | 说明 | 复用场景 |
|---|---|---|
| 公共头部/底部 | 每个页面都一样 | 所有页面 |
| 分页组件 | 页码切换、每页条数 | 列表页 |
| 搜索栏组件 | 输入框、搜索按钮、防抖 | 搜索场景 |
| 弹窗/确认框 | 确定、取消、回调 | 确认操作 |
| 表单组件 | 输入框、下拉框、校验 | 表单场景 |
| 表格组件 | 请求、加载、选中、操作列 | 数据列表 |
三、封装一个分页组件(完整示例)
javascript
<!-- components/Pagination.vue -->
<template>
<div class="pagination">
<button
:disabled="currentPage === 1"
@click="handlePageChange(currentPage - 1)"
>
上一页
</button>
<span>第 {{ currentPage }} / {{ totalPages }} 页</span>
<button
:disabled="currentPage === totalPages"
@click="handlePageChange(currentPage + 1)"
>
下一页
</button>
<select :value="pageSize" @change="handleSizeChange">
<option :value="10">10条/页</option>
<option :value="20">20条/页</option>
<option :value="50">50条/页</option>
</select>
</div>
</template>
<script setup>
import { computed } from 'vue'
// 1. 接收父组件传过来的数据(props)
const props = defineProps({
currentPage: {
type: Number,
default: 1
},
pageSize: {
type: Number,
default: 10
},
total: {
type: Number,
default: 0
}
})
// 2. 声明要触发的事件(emit)
const emit = defineEmits(['update:currentPage', 'update:pageSize', 'change'])
// 计算总页数
const totalPages = computed(() => Math.ceil(props.total / props.pageSize))
// 3. 触发事件,通知父组件
function handlePageChange(page) {
emit('update:currentPage', page)
emit('change', { page, pageSize: props.pageSize })
}
function handleSizeChange(e) {
const newSize = Number(e.target.value)
emit('update:pageSize', newSize)
emit('change', { page: 1, pageSize: newSize })
}
</script>
javascript
<!-- 父组件中使用 -->
<template>
<div>
<!-- 使用分页组件,双向绑定数据 -->
<Pagination
v-model:currentPage="page"
v-model:pageSize="size"
:total="total"
@change="loadData"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
import Pagination from '@/components/Pagination.vue'
const page = ref(1)
const size = ref(10)
const total = ref(100)
function loadData({ page, pageSize }) {
// 调用接口获取数据
fetchData({ page, pageSize })
}
</script>
四、封装一个搜索栏组件(带防抖)
javascript
<!-- components/SearchBar.vue -->
<template>
<div class="search-bar">
<input
type="text"
:value="modelValue"
placeholder="请输入搜索关键词"
@input="handleInput"
/>
<button @click="handleSearch">搜索</button>
<button v-if="modelValue" @click="handleClear">清空</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
modelValue: {
type: String,
default: ''
},
delay: {
type: Number,
default: 300
}
})
const emit = defineEmits(['update:modelValue', 'search'])
let timer = null
// 防抖处理
function handleInput(e) {
const value = e.target.value
emit('update:modelValue', value)
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
emit('search', value)
}, props.delay)
}
function handleSearch() {
if (timer) clearTimeout(timer)
emit('search', props.modelValue)
}
function handleClear() {
emit('update:modelValue', '')
emit('search', '')
}
</script>
javascript
<!-- 父组件中使用 -->
<template>
<SearchBar v-model="keyword" @search="handleSearch" />
</template>
<script setup>
import { ref } from 'vue'
import SearchBar from '@/components/SearchBar.vue'
const keyword = ref('')
function handleSearch(val) {
console.log('搜索:', val)
// 调用接口搜索
}
</script>
五、封装一个弹窗组件
javascript
<!-- components/Modal.vue -->
<template>
<Teleport to="body">
<div v-if="visible" class="modal-overlay" @click="handleClose">
<div class="modal-container" @click.stop>
<div class="modal-header">
<h3>{{ title }}</h3>
<button class="close" @click="handleClose">×</button>
</div>
<div class="modal-body">
<slot></slot>
</div>
<div class="modal-footer">
<button @click="handleCancel">取消</button>
<button @click="handleConfirm">确定</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
const props = defineProps({
visible: {
type: Boolean,
default: false
},
title: {
type: String,
default: '提示'
}
})
const emit = defineEmits(['update:visible', 'confirm', 'cancel'])
function handleClose() {
emit('update:visible', false)
}
function handleConfirm() {
emit('confirm')
handleClose()
}
function handleCancel() {
emit('cancel')
handleClose()
}
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
}
.modal-container {
background: white;
border-radius: 8px;
min-width: 400px;
}
</style>
javascript
<!-- 父组件中使用 -->
<template>
<button @click="showModal = true">打开弹窗</button>
<Modal v-model:visible="showModal" title="确认删除" @confirm="handleDelete">
<p>确定要删除这条数据吗?</p>
</Modal>
</template>
<script setup>
import { ref } from 'vue'
import Modal from '@/components/Modal.vue'
const showModal = ref(false)
function handleDelete() {
console.log('执行删除操作')
}
</script>
六、封装自定义组件的核心要点
| 要点 | 说明 |
|---|---|
| props | 接收父组件传过来的数据 |
| emit | 向父组件发送事件 |
| slot | 允许父组件传入自定义内容 |
| v-model | 实现双向绑定 |
| defineExpose | 暴露方法给父组件调用 |
七、问答
问:有没有自己写过自定义组件?
答:写过。我会把项目中通用的模块封装成可复用组件,比如:
分页组件
搜索栏组件
弹窗组件
公共头部/底部
封装时主要使用:
props接收父组件传参
emit向父组件发送事件
slot支持内容定制
v-model实现双向绑定好处: 减少重复代码、统一风格、提高开发效率、便于维护。
问:自定义组件和自定义 Hooks 有什么区别?
答:两者的核心区别在于封装的内容不同:
自定义组件 :封装 UI + 逻辑,有实际的界面展示,如分页组件、弹窗组件
自定义 Hooks :只封装 逻辑,不包含 UI,返回数据和方法,如 useCounter、useRequest
使用场景:
多个页面需要同样的界面 → 自定义组件
多个组件需要同样的逻辑,但界面不同 → 自定义 Hooks
两者也可以配合使用:Hooks 为组件提供逻辑,组件负责 UI 展示。