Search K
Appearance
Appearance
📊 SEO元描述:2024年最新Vue3组件TypeScript类型定义教程,详解Props类型、事件类型、插槽类型、组合式API类型。包含完整组件类型安全方案。
核心关键词:Vue3组件类型定义2024、Vue TypeScript组件、Props类型检查、Vue3 TS组件开发
长尾关键词:Vue3组件TypeScript怎么写、Vue组件Props类型定义、Vue3 TypeScript最佳实践、Vue组件类型安全开发
通过本节Vue3组件类型定义深度教程,你将系统性掌握:
为什么需要组件类型定义?在Vue3+TypeScript开发中,组件类型定义是确保代码质量和开发体验的关键。通过明确的类型定义,我们能够获得更好的IDE支持、编译时错误检查和代码自动补全,特别是在团队协作和大型项目中。类型安全的组件是现代Vue3开发的重要实践。
💡 设计建议:从组件接口设计开始,明确Props、事件和插槽的类型定义,然后实现组件逻辑
Vue3提供了多种方式定义Props类型:
<!-- components/UserCard.vue - Props类型定义示例 -->
<template>
<div class="user-card" :class="cardClasses">
<div class="user-avatar">
<img
:src="user.avatar || defaultAvatar"
:alt="`${user.name}的头像`"
@error="handleAvatarError"
>
<div v-if="showStatus" class="status-indicator" :class="user.status"></div>
</div>
<div class="user-info">
<h3 class="user-name">{{ user.name }}</h3>
<p class="user-title">{{ user.title }}</p>
<p v-if="showEmail" class="user-email">{{ user.email }}</p>
<div class="user-stats" v-if="showStats">
<div class="stat-item">
<span class="stat-label">关注者</span>
<span class="stat-value">{{ formatNumber(user.followers) }}</span>
</div>
<div class="stat-item">
<span class="stat-label">关注中</span>
<span class="stat-value">{{ formatNumber(user.following) }}</span>
</div>
</div>
<div class="user-actions">
<button
v-for="action in actions"
:key="action.key"
:class="['action-btn', action.type]"
:disabled="action.disabled"
@click="handleAction(action)"
>
<i v-if="action.icon" :class="action.icon"></i>
{{ action.label }}
</button>
</div>
</div>
<div v-if="$slots.extra" class="user-extra">
<slot name="extra" :user="user" :actions="actions"></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
// Props类型定义
interface User {
id: string | number
name: string
email: string
avatar?: string
title?: string
status: 'online' | 'offline' | 'away' | 'busy'
followers: number
following: number
createdAt: string | Date
updatedAt: string | Date
}
interface ActionItem {
key: string
label: string
type: 'primary' | 'secondary' | 'success' | 'warning' | 'danger'
icon?: string
disabled?: boolean
loading?: boolean
}
interface UserCardProps {
// 必需的用户数据
user: User
// 可选的显示配置
size?: 'small' | 'medium' | 'large'
variant?: 'default' | 'compact' | 'detailed'
showEmail?: boolean
showStatus?: boolean
showStats?: boolean
// 操作按钮配置
actions?: ActionItem[]
// 样式配置
bordered?: boolean
shadow?: boolean
rounded?: boolean
// 交互配置
clickable?: boolean
selectable?: boolean
selected?: boolean
// 自定义配置
defaultAvatar?: string
maxNameLength?: number
}
// 使用 defineProps 定义Props
const props = withDefaults(defineProps<UserCardProps>(), {
size: 'medium',
variant: 'default',
showEmail: false,
showStatus: true,
showStats: true,
actions: () => [],
bordered: false,
shadow: true,
rounded: true,
clickable: false,
selectable: false,
selected: false,
defaultAvatar: '/images/default-avatar.png',
maxNameLength: 20
})
// 事件类型定义
interface UserCardEmits {
// 用户点击事件
'user-click': [user: User, event: MouseEvent]
// 操作按钮点击事件
'action-click': [action: ActionItem, user: User]
// 选择状态变化事件
'selection-change': [selected: boolean, user: User]
// 头像加载错误事件
'avatar-error': [user: User, event: Event]
// 更新事件
'update:selected': [selected: boolean]
}
// 定义事件
const emit = defineEmits<UserCardEmits>()
// 响应式数据
const avatarError = ref(false)
// 计算属性
const cardClasses = computed(() => {
return {
[`user-card--${props.size}`]: true,
[`user-card--${props.variant}`]: true,
'user-card--bordered': props.bordered,
'user-card--shadow': props.shadow,
'user-card--rounded': props.rounded,
'user-card--clickable': props.clickable,
'user-card--selectable': props.selectable,
'user-card--selected': props.selected,
'user-card--avatar-error': avatarError.value
}
})
const displayName = computed(() => {
const name = props.user.name
if (props.maxNameLength && name.length > props.maxNameLength) {
return name.substring(0, props.maxNameLength) + '...'
}
return name
})
// 方法
const formatNumber = (num: number): string => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return num.toString()
}
const handleAction = (action: ActionItem): void => {
if (action.disabled || action.loading) return
emit('action-click', action, props.user)
}
const handleCardClick = (event: MouseEvent): void => {
if (!props.clickable) return
emit('user-click', props.user, event)
if (props.selectable) {
const newSelected = !props.selected
emit('selection-change', newSelected, props.user)
emit('update:selected', newSelected)
}
}
const handleAvatarError = (event: Event): void => {
avatarError.value = true
emit('avatar-error', props.user, event)
}
// 暴露给父组件的方法和数据
defineExpose({
user: props.user,
refresh: () => {
avatarError.value = false
},
focus: () => {
// 聚焦到卡片
}
})
</script>
<style scoped>
.user-card {
display: flex;
padding: 16px;
background: white;
transition: all 0.3s ease;
}
.user-card--small {
padding: 12px;
}
.user-card--large {
padding: 20px;
}
.user-card--bordered {
border: 1px solid #e1e5e9;
}
.user-card--shadow {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.user-card--rounded {
border-radius: 8px;
}
.user-card--clickable {
cursor: pointer;
}
.user-card--clickable:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.user-card--selected {
border-color: #409eff;
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
}
.user-card--compact {
flex-direction: row;
align-items: center;
}
.user-card--compact .user-avatar {
margin-right: 12px;
margin-bottom: 0;
}
.user-avatar {
position: relative;
margin-bottom: 12px;
flex-shrink: 0;
}
.user-avatar img {
width: 60px;
height: 60px;
border-radius: 50%;
object-fit: cover;
}
.user-card--small .user-avatar img {
width: 40px;
height: 40px;
}
.user-card--large .user-avatar img {
width: 80px;
height: 80px;
}
.status-indicator {
position: absolute;
bottom: 2px;
right: 2px;
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid white;
}
.status-indicator.online {
background: #67c23a;
}
.status-indicator.offline {
background: #909399;
}
.status-indicator.away {
background: #e6a23c;
}
.status-indicator.busy {
background: #f56c6c;
}
.user-info {
flex: 1;
min-width: 0;
}
.user-name {
margin: 0 0 4px 0;
font-size: 16px;
font-weight: 600;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-title {
margin: 0 0 8px 0;
font-size: 14px;
color: #606266;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-email {
margin: 0 0 12px 0;
font-size: 12px;
color: #909399;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-stats {
display: flex;
gap: 16px;
margin-bottom: 12px;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-label {
font-size: 12px;
color: #909399;
margin-bottom: 2px;
}
.stat-value {
font-size: 14px;
font-weight: 600;
color: #303133;
}
.user-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.action-btn {
padding: 4px 8px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: white;
color: #606266;
font-size: 12px;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 4px;
}
.action-btn:hover {
border-color: #409eff;
color: #409eff;
}
.action-btn.primary {
background: #409eff;
border-color: #409eff;
color: white;
}
.action-btn.success {
background: #67c23a;
border-color: #67c23a;
color: white;
}
.action-btn.warning {
background: #e6a23c;
border-color: #e6a23c;
color: white;
}
.action-btn.danger {
background: #f56c6c;
border-color: #f56c6c;
color: white;
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.user-extra {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
}
</style>组件事件类型系统确保事件的类型安全和正确使用:
<!-- components/DataTable.vue - 事件类型定义示例 -->
<template>
<div class="data-table">
<div class="table-header">
<div class="table-title">
<h3>{{ title }}</h3>
<span class="table-count">共 {{ total }} 条记录</span>
</div>
<div class="table-actions">
<slot name="actions" :selection="selection" :data="data">
<button
v-if="selectable"
:disabled="selection.length === 0"
@click="handleBatchDelete"
class="btn btn-danger"
>
批量删除 ({{ selection.length }})
</button>
</slot>
</div>
</div>
<div class="table-container">
<table class="table">
<thead>
<tr>
<th v-if="selectable" class="selection-column">
<input
type="checkbox"
:checked="isAllSelected"
:indeterminate="isIndeterminate"
@change="handleSelectAll"
>
</th>
<th
v-for="column in columns"
:key="column.key"
:class="getColumnClass(column)"
@click="handleSort(column)"
>
{{ column.title }}
<span v-if="column.sortable" class="sort-indicator">
<i :class="getSortIcon(column)"></i>
</span>
</th>
<th v-if="$slots.actions || actions.length > 0" class="actions-column">
操作
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, index) in paginatedData"
:key="getRowKey(row, index)"
:class="getRowClass(row, index)"
@click="handleRowClick(row, index, $event)"
@dblclick="handleRowDoubleClick(row, index, $event)"
>
<td v-if="selectable" class="selection-column">
<input
type="checkbox"
:checked="isRowSelected(row)"
@change="handleRowSelect(row, $event)"
@click.stop
>
</td>
<td
v-for="column in columns"
:key="column.key"
:class="getCellClass(column, row)"
>
<slot
:name="`column-${column.key}`"
:row="row"
:column="column"
:index="index"
:value="getCellValue(row, column)"
>
{{ formatCellValue(getCellValue(row, column), column) }}
</slot>
</td>
<td v-if="$slots.actions || actions.length > 0" class="actions-column">
<slot
name="actions"
:row="row"
:index="index"
:actions="actions"
>
<button
v-for="action in getRowActions(row)"
:key="action.key"
:class="['action-btn', action.type]"
:disabled="action.disabled"
@click.stop="handleAction(action, row, index)"
>
<i v-if="action.icon" :class="action.icon"></i>
{{ action.label }}
</button>
</slot>
</td>
</tr>
</tbody>
</table>
<div v-if="loading" class="table-loading">
<div class="loading-spinner"></div>
<span>加载中...</span>
</div>
<div v-if="!loading && data.length === 0" class="table-empty">
<slot name="empty">
<div class="empty-content">
<i class="empty-icon">📄</i>
<p>暂无数据</p>
</div>
</slot>
</div>
</div>
<div v-if="pagination" class="table-pagination">
<div class="pagination-info">
显示第 {{ (currentPage - 1) * pageSize + 1 }} 到
{{ Math.min(currentPage * pageSize, total) }} 条,
共 {{ total }} 条记录
</div>
<div class="pagination-controls">
<button
:disabled="currentPage <= 1"
@click="handlePageChange(currentPage - 1)"
class="pagination-btn"
>
上一页
</button>
<span class="pagination-pages">
<button
v-for="page in visiblePages"
:key="page"
:class="['pagination-page', { active: page === currentPage }]"
@click="handlePageChange(page)"
>
{{ page }}
</button>
</span>
<button
:disabled="currentPage >= totalPages"
@click="handlePageChange(currentPage + 1)"
class="pagination-btn"
>
下一页
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
// 数据类型定义
interface TableColumn<T = any> {
key: keyof T | string
title: string
width?: number | string
sortable?: boolean
filterable?: boolean
align?: 'left' | 'center' | 'right'
fixed?: 'left' | 'right'
render?: (value: any, row: T, index: number) => any
formatter?: (value: any, row: T) => string
}
interface TableAction<T = any> {
key: string
label: string
type: 'primary' | 'secondary' | 'success' | 'warning' | 'danger'
icon?: string
disabled?: boolean | ((row: T) => boolean)
visible?: boolean | ((row: T) => boolean)
loading?: boolean
}
interface SortConfig {
key: string
order: 'asc' | 'desc'
}
// Props类型定义
interface DataTableProps<T = any> {
// 数据相关
data: T[]
columns: TableColumn<T>[]
loading?: boolean
// 表格配置
title?: string
bordered?: boolean
striped?: boolean
hoverable?: boolean
size?: 'small' | 'medium' | 'large'
// 选择功能
selectable?: boolean
selection?: T[]
rowKey?: keyof T | ((row: T) => string | number)
// 排序功能
sortable?: boolean
defaultSort?: SortConfig
// 分页功能
pagination?: boolean
currentPage?: number
pageSize?: number
total?: number
pageSizes?: number[]
// 操作按钮
actions?: TableAction<T>[]
// 行配置
rowClassName?: string | ((row: T, index: number) => string)
rowClickable?: boolean
// 其他配置
emptyText?: string
maxHeight?: number | string
}
const props = withDefaults(defineProps<DataTableProps>(), {
loading: false,
bordered: true,
striped: true,
hoverable: true,
size: 'medium',
selectable: false,
selection: () => [],
sortable: true,
pagination: true,
currentPage: 1,
pageSize: 10,
total: 0,
pageSizes: () => [10, 20, 50, 100],
actions: () => [],
rowClickable: true,
emptyText: '暂无数据'
})
// 事件类型定义
interface DataTableEmits<T = any> {
// 选择相关事件
'selection-change': [selection: T[]]
'select': [row: T, selected: boolean]
'select-all': [selected: boolean]
// 排序事件
'sort-change': [sort: SortConfig | null]
// 分页事件
'page-change': [page: number]
'page-size-change': [pageSize: number]
// 行事件
'row-click': [row: T, index: number, event: MouseEvent]
'row-dblclick': [row: T, index: number, event: MouseEvent]
// 操作事件
'action-click': [action: TableAction<T>, row: T, index: number]
// 更新事件
'update:selection': [selection: T[]]
'update:currentPage': [page: number]
'update:pageSize': [pageSize: number]
}
const emit = defineEmits<DataTableEmits>()
// 响应式数据
const currentSort = ref<SortConfig | null>(props.defaultSort || null)
const internalSelection = ref<any[]>([...props.selection])
// 计算属性
const sortedData = computed(() => {
if (!currentSort.value || !props.sortable) {
return props.data
}
const { key, order } = currentSort.value
return [...props.data].sort((a, b) => {
const aVal = getCellValue(a, { key } as TableColumn)
const bVal = getCellValue(b, { key } as TableColumn)
if (aVal === bVal) return 0
const result = aVal > bVal ? 1 : -1
return order === 'asc' ? result : -result
})
})
const paginatedData = computed(() => {
if (!props.pagination) return sortedData.value
const start = (props.currentPage - 1) * props.pageSize
const end = start + props.pageSize
return sortedData.value.slice(start, end)
})
const totalPages = computed(() => {
return Math.ceil(props.total / props.pageSize)
})
const visiblePages = computed(() => {
const pages: number[] = []
const total = totalPages.value
const current = props.currentPage
let start = Math.max(1, current - 2)
let end = Math.min(total, current + 2)
for (let i = start; i <= end; i++) {
pages.push(i)
}
return pages
})
const isAllSelected = computed(() => {
return props.data.length > 0 && internalSelection.value.length === props.data.length
})
const isIndeterminate = computed(() => {
return internalSelection.value.length > 0 && internalSelection.value.length < props.data.length
})
// 方法
const getRowKey = (row: any, index: number): string | number => {
if (typeof props.rowKey === 'function') {
return props.rowKey(row)
} else if (props.rowKey) {
return row[props.rowKey]
}
return index
}
const getCellValue = (row: any, column: TableColumn): any => {
const keys = String(column.key).split('.')
let value = row
for (const key of keys) {
value = value?.[key]
}
return value
}
const formatCellValue = (value: any, column: TableColumn): string => {
if (column.formatter) {
return column.formatter(value, {} as any)
}
if (value === null || value === undefined) {
return ''
}
return String(value)
}
const isRowSelected = (row: any): boolean => {
const key = getRowKey(row, 0)
return internalSelection.value.some(item => getRowKey(item, 0) === key)
}
const handleRowSelect = (row: any, event: Event): void => {
const target = event.target as HTMLInputElement
const selected = target.checked
if (selected) {
if (!isRowSelected(row)) {
internalSelection.value.push(row)
}
} else {
const key = getRowKey(row, 0)
const index = internalSelection.value.findIndex(item => getRowKey(item, 0) === key)
if (index > -1) {
internalSelection.value.splice(index, 1)
}
}
emit('select', row, selected)
emit('selection-change', [...internalSelection.value])
emit('update:selection', [...internalSelection.value])
}
const handleSelectAll = (event: Event): void => {
const target = event.target as HTMLInputElement
const selected = target.checked
if (selected) {
internalSelection.value = [...props.data]
} else {
internalSelection.value = []
}
emit('select-all', selected)
emit('selection-change', [...internalSelection.value])
emit('update:selection', [...internalSelection.value])
}
const handleSort = (column: TableColumn): void => {
if (!column.sortable) return
const key = String(column.key)
if (currentSort.value?.key === key) {
// 切换排序方向
if (currentSort.value.order === 'asc') {
currentSort.value.order = 'desc'
} else {
currentSort.value = null
}
} else {
// 新的排序列
currentSort.value = { key, order: 'asc' }
}
emit('sort-change', currentSort.value)
}
const handleRowClick = (row: any, index: number, event: MouseEvent): void => {
if (!props.rowClickable) return
emit('row-click', row, index, event)
}
const handleRowDoubleClick = (row: any, index: number, event: MouseEvent): void => {
emit('row-dblclick', row, index, event)
}
const handleAction = (action: TableAction, row: any, index: number): void => {
emit('action-click', action, row, index)
}
const handlePageChange = (page: number): void => {
emit('page-change', page)
emit('update:currentPage', page)
}
const handleBatchDelete = (): void => {
// 批量删除逻辑
console.log('批量删除', internalSelection.value)
}
const getColumnClass = (column: TableColumn): string[] => {
const classes = [`column-${column.key}`]
if (column.align) {
classes.push(`text-${column.align}`)
}
if (column.sortable) {
classes.push('sortable')
}
return classes
}
const getRowClass = (row: any, index: number): string[] => {
const classes = []
if (typeof props.rowClassName === 'string') {
classes.push(props.rowClassName)
} else if (typeof props.rowClassName === 'function') {
classes.push(props.rowClassName(row, index))
}
if (isRowSelected(row)) {
classes.push('selected')
}
return classes
}
const getCellClass = (column: TableColumn, row: any): string[] => {
const classes = [`cell-${column.key}`]
if (column.align) {
classes.push(`text-${column.align}`)
}
return classes
}
const getSortIcon = (column: TableColumn): string => {
if (!currentSort.value || currentSort.value.key !== String(column.key)) {
return 'sort-icon'
}
return currentSort.value.order === 'asc' ? 'sort-asc' : 'sort-desc'
}
const getRowActions = (row: any): TableAction[] => {
return props.actions.filter(action => {
if (typeof action.visible === 'function') {
return action.visible(row)
}
return action.visible !== false
})
}
// 监听Props变化
watch(() => props.selection, (newSelection) => {
internalSelection.value = [...newSelection]
}, { deep: true })
// 暴露方法
defineExpose({
clearSelection: () => {
internalSelection.value = []
emit('selection-change', [])
emit('update:selection', [])
},
toggleRowSelection: (row: any, selected?: boolean) => {
const isSelected = selected ?? !isRowSelected(row)
handleRowSelect(row, { target: { checked: isSelected } } as any)
},
getSelection: () => [...internalSelection.value]
})
</script>
<style scoped>
/* 表格样式 */
.data-table {
background: white;
border-radius: 8px;
overflow: hidden;
}
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #f0f0f0;
}
.table-title h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.table-count {
font-size: 14px;
color: #666;
margin-left: 8px;
}
.table-container {
position: relative;
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #f0f0f0;
}
.table th {
background: #fafafa;
font-weight: 600;
color: #333;
}
.table th.sortable {
cursor: pointer;
user-select: none;
}
.table th.sortable:hover {
background: #f0f0f0;
}
.sort-indicator {
margin-left: 4px;
opacity: 0.5;
}
.table tbody tr:hover {
background: #f8f9fa;
}
.table tbody tr.selected {
background: #e6f7ff;
}
.selection-column {
width: 50px;
text-align: center;
}
.actions-column {
width: 120px;
text-align: center;
}
.action-btn {
padding: 4px 8px;
margin: 0 2px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
color: #666;
font-size: 12px;
cursor: pointer;
transition: all 0.3s;
}
.action-btn:hover {
border-color: #409eff;
color: #409eff;
}
.action-btn.primary {
background: #409eff;
border-color: #409eff;
color: white;
}
.action-btn.danger {
background: #f56c6c;
border-color: #f56c6c;
color: white;
}
.table-loading {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid #f3f3f3;
border-top: 3px solid #409eff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 8px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.table-empty {
padding: 40px;
text-align: center;
}
.empty-content {
color: #999;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
display: block;
}
.table-pagination {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-top: 1px solid #f0f0f0;
}
.pagination-controls {
display: flex;
align-items: center;
gap: 8px;
}
.pagination-btn {
padding: 6px 12px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
color: #666;
cursor: pointer;
transition: all 0.3s;
}
.pagination-btn:hover:not(:disabled) {
border-color: #409eff;
color: #409eff;
}
.pagination-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination-pages {
display: flex;
gap: 4px;
}
.pagination-page {
padding: 6px 10px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
color: #666;
cursor: pointer;
transition: all 0.3s;
}
.pagination-page:hover {
border-color: #409eff;
color: #409eff;
}
.pagination-page.active {
background: #409eff;
border-color: #409eff;
color: white;
}
.text-left { text-align: left; }
.text-center { text-align: center; }
.text-right { text-align: right; }
</style>事件类型系统核心特性:
💼 事件提示:合理设计事件接口,避免过多的事件定义,优先使用组合事件和状态更新事件
Vue3插槽的TypeScript类型定义确保插槽内容的类型安全:
<!-- components/Layout.vue - 插槽类型定义示例 -->
<template>
<div class="layout" :class="layoutClasses">
<!-- 头部插槽 -->
<header v-if="$slots.header || showDefaultHeader" class="layout-header">
<slot
name="header"
:user="currentUser"
:theme="currentTheme"
:toggleSidebar="toggleSidebar"
:logout="handleLogout"
>
<div class="default-header">
<h1>{{ title }}</h1>
<div class="header-actions">
<button @click="toggleSidebar">菜单</button>
<button @click="handleLogout">退出</button>
</div>
</div>
</slot>
</header>
<div class="layout-body">
<!-- 侧边栏插槽 -->
<aside
v-if="$slots.sidebar || showDefaultSidebar"
class="layout-sidebar"
:class="{ collapsed: sidebarCollapsed }"
>
<slot
name="sidebar"
:collapsed="sidebarCollapsed"
:menuItems="menuItems"
:activeMenu="activeMenu"
:onMenuClick="handleMenuClick"
>
<nav class="default-sidebar">
<ul class="menu-list">
<li
v-for="item in menuItems"
:key="item.id"
:class="{ active: item.id === activeMenu }"
@click="handleMenuClick(item)"
>
<i v-if="item.icon" :class="item.icon"></i>
<span v-if="!sidebarCollapsed">{{ item.title }}</span>
</li>
</ul>
</nav>
</slot>
</aside>
<!-- 主内容区插槽 -->
<main class="layout-main">
<!-- 面包屑插槽 -->
<div v-if="$slots.breadcrumb" class="layout-breadcrumb">
<slot
name="breadcrumb"
:breadcrumbs="breadcrumbs"
:currentRoute="currentRoute"
></slot>
</div>
<!-- 内容插槽 -->
<div class="layout-content">
<slot
:loading="loading"
:error="error"
:refresh="refresh"
:user="currentUser"
>
<div v-if="loading" class="loading-placeholder">
加载中...
</div>
<div v-else-if="error" class="error-placeholder">
<p>{{ error.message }}</p>
<button @click="refresh">重试</button>
</div>
<div v-else class="default-content">
<h2>欢迎使用系统</h2>
<p>请在父组件中提供内容插槽</p>
</div>
</slot>
</div>
<!-- 底部插槽 -->
<footer v-if="$slots.footer" class="layout-footer">
<slot
name="footer"
:version="version"
:buildTime="buildTime"
></slot>
</footer>
</main>
</div>
<!-- 浮动操作按钮插槽 -->
<div v-if="$slots.fab" class="layout-fab">
<slot
name="fab"
:scrollToTop="scrollToTop"
:showBackTop="showBackTop"
></slot>
</div>
<!-- 模态框插槽 -->
<teleport to="body">
<div v-if="$slots.modal" class="layout-modal">
<slot
name="modal"
:closeModal="closeModal"
:modalVisible="modalVisible"
></slot>
</div>
</teleport>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue'
import type { Component } from 'vue'
// 用户类型
interface User {
id: string
name: string
email: string
avatar?: string
role: string
}
// 菜单项类型
interface MenuItem {
id: string
title: string
icon?: string
path?: string
children?: MenuItem[]
meta?: {
requiresAuth?: boolean
roles?: string[]
}
}
// 面包屑类型
interface BreadcrumbItem {
title: string
path?: string
icon?: string
}
// 错误类型
interface LayoutError {
message: string
code?: number
details?: any
}
// 插槽类型定义
interface LayoutSlots {
// 头部插槽
header(props: {
user: User | null
theme: string
toggleSidebar: () => void
logout: () => void
}): any
// 侧边栏插槽
sidebar(props: {
collapsed: boolean
menuItems: MenuItem[]
activeMenu: string
onMenuClick: (item: MenuItem) => void
}): any
// 面包屑插槽
breadcrumb(props: {
breadcrumbs: BreadcrumbItem[]
currentRoute: string
}): any
// 默认插槽(主内容)
default(props: {
loading: boolean
error: LayoutError | null
refresh: () => void
user: User | null
}): any
// 底部插槽
footer(props: {
version: string
buildTime: string
}): any
// 浮动操作按钮插槽
fab(props: {
scrollToTop: () => void
showBackTop: boolean
}): any
// 模态框插槽
modal(props: {
closeModal: () => void
modalVisible: boolean
}): any
}
// Props类型定义
interface LayoutProps {
title?: string
theme?: 'light' | 'dark' | 'auto'
sidebarCollapsed?: boolean
showDefaultHeader?: boolean
showDefaultSidebar?: boolean
loading?: boolean
error?: LayoutError | null
menuItems?: MenuItem[]
currentUser?: User | null
version?: string
buildTime?: string
}
const props = withDefaults(defineProps<LayoutProps>(), {
title: '管理系统',
theme: 'light',
sidebarCollapsed: false,
showDefaultHeader: true,
showDefaultSidebar: true,
loading: false,
error: null,
menuItems: () => [],
currentUser: null,
version: '1.0.0',
buildTime: new Date().toISOString()
})
// 事件类型定义
interface LayoutEmits {
'update:sidebarCollapsed': [collapsed: boolean]
'menu-click': [item: MenuItem]
'logout': []
'refresh': []
'theme-change': [theme: string]
}
const emit = defineEmits<LayoutEmits>()
// 响应式数据
const activeMenu = ref('')
const currentRoute = ref('')
const modalVisible = ref(false)
const showBackTop = ref(false)
const scrollY = ref(0)
// 计算属性
const layoutClasses = computed(() => ({
[`layout--${props.theme}`]: true,
'layout--sidebar-collapsed': props.sidebarCollapsed
}))
const currentTheme = computed(() => props.theme)
const breadcrumbs = computed((): BreadcrumbItem[] => {
// 根据当前路由生成面包屑
return [
{ title: '首页', path: '/' },
{ title: '当前页面', path: currentRoute.value }
]
})
// 方法
const toggleSidebar = (): void => {
emit('update:sidebarCollapsed', !props.sidebarCollapsed)
}
const handleMenuClick = (item: MenuItem): void => {
activeMenu.value = item.id
emit('menu-click', item)
}
const handleLogout = (): void => {
emit('logout')
}
const refresh = (): void => {
emit('refresh')
}
const scrollToTop = (): void => {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
const closeModal = (): void => {
modalVisible.value = false
}
const handleScroll = (): void => {
scrollY.value = window.scrollY
showBackTop.value = scrollY.value > 300
}
// 生命周期
onMounted(() => {
window.addEventListener('scroll', handleScroll)
})
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll)
})
// 暴露给父组件
defineExpose({
toggleSidebar,
scrollToTop,
openModal: () => { modalVisible.value = true },
closeModal,
setActiveMenu: (menuId: string) => { activeMenu.value = menuId }
})
</script>
<style scoped>
.layout {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.layout--light {
background: #f5f5f5;
color: #333;
}
.layout--dark {
background: #1a1a1a;
color: #fff;
}
.layout-header {
height: 60px;
background: white;
border-bottom: 1px solid #e8e8e8;
display: flex;
align-items: center;
padding: 0 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.default-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.default-header h1 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 12px;
}
.header-actions button {
padding: 6px 12px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
transition: all 0.3s;
}
.header-actions button:hover {
border-color: #409eff;
color: #409eff;
}
.layout-body {
flex: 1;
display: flex;
}
.layout-sidebar {
width: 240px;
background: white;
border-right: 1px solid #e8e8e8;
transition: width 0.3s ease;
}
.layout-sidebar.collapsed {
width: 60px;
}
.default-sidebar {
padding: 20px 0;
}
.menu-list {
list-style: none;
margin: 0;
padding: 0;
}
.menu-list li {
padding: 12px 20px;
cursor: pointer;
transition: background 0.3s;
display: flex;
align-items: center;
gap: 12px;
}
.menu-list li:hover {
background: #f5f5f5;
}
.menu-list li.active {
background: #e6f7ff;
color: #409eff;
border-right: 3px solid #409eff;
}
.layout-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.layout-breadcrumb {
padding: 12px 20px;
background: white;
border-bottom: 1px solid #e8e8e8;
}
.layout-content {
flex: 1;
padding: 20px;
overflow-y: auto;
}
.loading-placeholder,
.error-placeholder,
.default-content {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
flex-direction: column;
}
.error-placeholder button {
margin-top: 12px;
padding: 8px 16px;
background: #409eff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.layout-footer {
padding: 20px;
background: white;
border-top: 1px solid #e8e8e8;
text-align: center;
color: #666;
font-size: 14px;
}
.layout-fab {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000;
}
.layout-modal {
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;
z-index: 2000;
}
</style>// composables/useUserManagement.ts - Composition API类型定义
import { ref, computed, reactive, watch, onMounted, onUnmounted } from 'vue'
import type { Ref, ComputedRef, UnwrapRef } from 'vue'
// 用户数据类型
interface User {
id: string
name: string
email: string
avatar?: string
role: 'admin' | 'user' | 'guest'
status: 'active' | 'inactive' | 'banned'
createdAt: Date
updatedAt: Date
}
// 用户查询参数类型
interface UserQuery {
page: number
pageSize: number
keyword?: string
role?: User['role']
status?: User['status']
sortBy?: keyof User
sortOrder?: 'asc' | 'desc'
}
// 用户操作结果类型
interface UserOperationResult {
success: boolean
message: string
data?: User | User[]
error?: Error
}
// 用户管理状态类型
interface UserManagementState {
users: User[]
loading: boolean
error: string | null
total: number
query: UserQuery
selectedUsers: User[]
}
// 用户管理操作类型
interface UserManagementActions {
fetchUsers: () => Promise<void>
createUser: (userData: Omit<User, 'id' | 'createdAt' | 'updatedAt'>) => Promise<UserOperationResult>
updateUser: (id: string, userData: Partial<User>) => Promise<UserOperationResult>
deleteUser: (id: string) => Promise<UserOperationResult>
batchDeleteUsers: (ids: string[]) => Promise<UserOperationResult>
searchUsers: (keyword: string) => Promise<void>
filterUsers: (filters: Partial<UserQuery>) => Promise<void>
selectUser: (user: User) => void
selectAllUsers: () => void
clearSelection: () => void
refreshUsers: () => Promise<void>
}
// 用户管理Composable返回类型
interface UseUserManagementReturn {
// 状态
state: UnwrapRef<UserManagementState>
// 计算属性
filteredUsers: ComputedRef<User[]>
hasSelection: ComputedRef<boolean>
isAllSelected: ComputedRef<boolean>
paginationInfo: ComputedRef<{
start: number
end: number
total: number
pages: number
}>
// 操作方法
actions: UserManagementActions
// 工具方法
utils: {
formatUserRole: (role: User['role']) => string
formatUserStatus: (status: User['status']) => string
getUserDisplayName: (user: User) => string
isUserActive: (user: User) => boolean
}
}
// 用户管理Composable实现
export function useUserManagement(
initialQuery: Partial<UserQuery> = {}
): UseUserManagementReturn {
// 响应式状态
const state = reactive<UserManagementState>({
users: [],
loading: false,
error: null,
total: 0,
query: {
page: 1,
pageSize: 10,
...initialQuery
},
selectedUsers: []
})
// 计算属性
const filteredUsers = computed(() => {
let result = state.users
// 关键词搜索
if (state.query.keyword) {
const keyword = state.query.keyword.toLowerCase()
result = result.filter(user =>
user.name.toLowerCase().includes(keyword) ||
user.email.toLowerCase().includes(keyword)
)
}
// 角色过滤
if (state.query.role) {
result = result.filter(user => user.role === state.query.role)
}
// 状态过滤
if (state.query.status) {
result = result.filter(user => user.status === state.query.status)
}
// 排序
if (state.query.sortBy) {
result.sort((a, b) => {
const aVal = a[state.query.sortBy!]
const bVal = b[state.query.sortBy!]
if (aVal === bVal) return 0
const comparison = aVal > bVal ? 1 : -1
return state.query.sortOrder === 'desc' ? -comparison : comparison
})
}
return result
})
const hasSelection = computed(() => state.selectedUsers.length > 0)
const isAllSelected = computed(() =>
state.users.length > 0 && state.selectedUsers.length === state.users.length
)
const paginationInfo = computed(() => {
const { page, pageSize } = state.query
const start = (page - 1) * pageSize + 1
const end = Math.min(page * pageSize, state.total)
const pages = Math.ceil(state.total / pageSize)
return { start, end, total: state.total, pages }
})
// API调用函数
const fetchUsers = async (): Promise<void> => {
state.loading = true
state.error = null
try {
// 模拟API调用
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(state.query)
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
state.users = data.users
state.total = data.total
} catch (error) {
state.error = error instanceof Error ? error.message : '获取用户列表失败'
console.error('Failed to fetch users:', error)
} finally {
state.loading = false
}
}
const createUser = async (
userData: Omit<User, 'id' | 'createdAt' | 'updatedAt'>
): Promise<UserOperationResult> => {
try {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData)
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const newUser = await response.json()
state.users.unshift(newUser)
state.total++
return {
success: true,
message: '用户创建成功',
data: newUser
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '创建用户失败'
return {
success: false,
message: errorMessage,
error: error instanceof Error ? error : new Error(errorMessage)
}
}
}
const updateUser = async (
id: string,
userData: Partial<User>
): Promise<UserOperationResult> => {
try {
const response = await fetch(`/api/users/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData)
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const updatedUser = await response.json()
const index = state.users.findIndex(user => user.id === id)
if (index > -1) {
state.users[index] = updatedUser
}
return {
success: true,
message: '用户更新成功',
data: updatedUser
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '更新用户失败'
return {
success: false,
message: errorMessage,
error: error instanceof Error ? error : new Error(errorMessage)
}
}
}
const deleteUser = async (id: string): Promise<UserOperationResult> => {
try {
const response = await fetch(`/api/users/${id}`, {
method: 'DELETE'
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const index = state.users.findIndex(user => user.id === id)
if (index > -1) {
state.users.splice(index, 1)
state.total--
}
// 从选中列表中移除
const selectedIndex = state.selectedUsers.findIndex(user => user.id === id)
if (selectedIndex > -1) {
state.selectedUsers.splice(selectedIndex, 1)
}
return {
success: true,
message: '用户删除成功'
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '删除用户失败'
return {
success: false,
message: errorMessage,
error: error instanceof Error ? error : new Error(errorMessage)
}
}
}
const batchDeleteUsers = async (ids: string[]): Promise<UserOperationResult> => {
try {
const response = await fetch('/api/users/batch-delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids })
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
// 从列表中移除删除的用户
state.users = state.users.filter(user => !ids.includes(user.id))
state.total -= ids.length
// 清空选中列表
state.selectedUsers = []
return {
success: true,
message: `成功删除 ${ids.length} 个用户`
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '批量删除用户失败'
return {
success: false,
message: errorMessage,
error: error instanceof Error ? error : new Error(errorMessage)
}
}
}
// 搜索和过滤方法
const searchUsers = async (keyword: string): Promise<void> => {
state.query.keyword = keyword
state.query.page = 1 // 重置到第一页
await fetchUsers()
}
const filterUsers = async (filters: Partial<UserQuery>): Promise<void> => {
Object.assign(state.query, filters)
state.query.page = 1 // 重置到第一页
await fetchUsers()
}
// 选择操作方法
const selectUser = (user: User): void => {
const index = state.selectedUsers.findIndex(u => u.id === user.id)
if (index > -1) {
state.selectedUsers.splice(index, 1)
} else {
state.selectedUsers.push(user)
}
}
const selectAllUsers = (): void => {
if (isAllSelected.value) {
state.selectedUsers = []
} else {
state.selectedUsers = [...state.users]
}
}
const clearSelection = (): void => {
state.selectedUsers = []
}
const refreshUsers = async (): Promise<void> => {
await fetchUsers()
}
// 工具方法
const formatUserRole = (role: User['role']): string => {
const roleMap = {
admin: '管理员',
user: '普通用户',
guest: '访客'
}
return roleMap[role] || role
}
const formatUserStatus = (status: User['status']): string => {
const statusMap = {
active: '活跃',
inactive: '非活跃',
banned: '已禁用'
}
return statusMap[status] || status
}
const getUserDisplayName = (user: User): string => {
return user.name || user.email || `用户${user.id}`
}
const isUserActive = (user: User): boolean => {
return user.status === 'active'
}
// 监听查询参数变化
watch(
() => state.query,
() => {
fetchUsers()
},
{ deep: true }
)
// 组件挂载时获取数据
onMounted(() => {
fetchUsers()
})
// 返回接口
return {
state,
filteredUsers,
hasSelection,
isAllSelected,
paginationInfo,
actions: {
fetchUsers,
createUser,
updateUser,
deleteUser,
batchDeleteUsers,
searchUsers,
filterUsers,
selectUser,
selectAllUsers,
clearSelection,
refreshUsers
},
utils: {
formatUserRole,
formatUserStatus,
getUserDisplayName,
isUserActive
}
}
}
// 导出类型供其他地方使用
export type {
User,
UserQuery,
UserOperationResult,
UserManagementState,
UserManagementActions,
UseUserManagementReturn
}通过本节Vue3组件类型定义深度教程的学习,你已经掌握:
A: 使用泛型Props、联合类型或条件类型。对于完全动态的Props,可以使用Record<string, any>,但要注意类型安全。
A: 插槽类型主要用于IDE提示,运行时不会进行类型检查。建议在插槽内容中进行必要的类型断言和验证。
A: 使用明确的类型注解、泛型约束和类型断言。避免过度依赖类型推断,在关键位置提供明确的类型定义。
A: 使用类型别名、避免深度嵌套类型、合理使用泛型约束、启用TypeScript的增量编译等。
A: 使用描述性的事件名称、明确的参数类型、避免过多的事件定义、优先使用组合事件和状态更新事件。
// types/component-standards.ts - 组件类型定义规范
// 1. Props接口命名规范
interface ComponentNameProps {
// 必需属性在前
requiredProp: string
// 可选属性在后,按重要性排序
optionalProp?: number
configProp?: boolean
}
// 2. 事件接口命名规范
interface ComponentNameEmits {
// 使用描述性的事件名
'value-change': [newValue: string, oldValue: string]
'item-select': [item: any, index: number]
// 更新事件使用update:前缀
'update:modelValue': [value: string]
}
// 3. 插槽接口命名规范
interface ComponentNameSlots {
default(props: { data: any }): any
header(props: { title: string }): any
footer(props: { actions: any[] }): any
}
// 4. 组合式函数返回类型规范
interface UseComponentNameReturn {
// 状态
state: UnwrapRef<ComponentState>
// 计算属性
computedValue: ComputedRef<any>
// 方法
methods: {
action1: () => void
action2: (param: any) => Promise<any>
}
}"组件类型定义是Vue3+TypeScript开发的核心技能。通过明确的Props、事件和插槽类型定义,我们能够构建类型安全、易于维护的组件系统。良好的类型定义不仅提升开发体验,更是团队协作和代码质量的重要保障!"