Header 与 AvatarMenu 封装演进
概述
头部是 DefaultLayout 真正成型的起点。本节把 Header 拆成左侧导航区与右侧工具区,处理折叠事件的归属、语言数据的来源、工具栏对齐与图标扩展统一;再围绕用户头像菜单,解决复合组件(Dropdown + Avatar)的 props 类型冲突、触发方式覆盖顺序、command 事件透传,以及头像加载失败的内容回退。整体主线是「基础组件从能显示走向稳用」。
学习目标
- 用 Left / flex-grow / Right 三段结构稳定头部布局,折叠按钮只发事件不持状态
- 把语言列表等数据由外层传入,并用
LocaleItem统一跨组件类型 - 用
iconClass/iconProps收口图标尺寸与对齐,透传时用computed排除本地消费字段 - 把 AvatarMenu 拆成 Dropdown 配置与 Avatar 配置两层,用
Partial/Omit消解同名字段冲突 - 通过
command事件向外透传命令,并设计头像尺寸回退链与首字母/默认头像回退
一、Header 左右分区与状态上抛
头部最容易出的问题是把所有内容平铺在一个容器里,越加越乱。更合理的结构是固定左右骨架:左侧放折叠触发与面包屑,右侧放全屏、暗黑、语言切换等工具按钮,中间留一个 flex-grow 容器把右侧推到最右——这比写死定位更稳,也方便后续插面包屑。
折叠按钮的状态归属要清楚:Header 通常不是侧边栏折叠状态的最终拥有者,真正控制布局变化的是 DefaultLayout。因此折叠按钮应是事件触发器而非状态存储器——Header 只 emit("toggle-collapse"),由外层布局接收后修改状态。同理,语言切换的数据逻辑也应写在外层使用处,而不是写死在 Header 基础组件内部;LocaleItem 这类跨多个组件共享的数据结构要尽早抽成公共类型,避免字段名不一致。
二、右侧工具栏:flex-grow 与图标扩展统一
功能接通后通常不是加逻辑,而是统一图标尺寸、垂直居中和水平间距——这是工具栏「接通即暴露」的典型问题。图标大小先可用类名快速验证,但最终更适合作为 props(iconClass/iconProps)开放给外层,因为标准后台、紧凑移动端、演示版面需求不同。
当组件既有本地业务 props,又要向下透传图标组件 props 时,两者不能混在同一个 v-bind 里。关键是拆开「图标内容」(当前数据项的 icon)与「图标展示配置」(透传给图标组件的 props),用 computed 解构排除本地消费字段后再透传:
const iconPropsComputed = computed(() => {
const { locales, icon, iconClass, ...restProps } = props
return restProps
})三、头像下拉复合组件:两层配置与类型冲突
用户头像菜单通常是右侧工具组与用户上下文区之间的分界点。它本质是 el-dropdown 与 el-avatar 的复合包装,会带来两个典型工程点:
- 触发方式覆盖顺序:
Dropdown默认hover,想改成click却没生效,多半是封装层v-bind把显式:trigger覆盖掉了。Vue 3 中v-bind="object"与同名显式属性同时存在时,后声明的覆盖前者,因此要把自己的:trigger写在v-bind之后。 - 同名字段冲突:
DropdownProps和AvatarProps都有size,直接多重继承会报冲突。最稳妥的是用Omit去掉其中一个冲突字段,或显式重命名为avatarSize/menuSize,并对trigger、teleported这类核心行为字段在封装层重新声明。
推荐把配置拆成两层,类型不会互相打架、默认值更清晰:
interface AvatarMenuProps extends Partial<DropdownProps> {
username?: string
trigger?: "hover" | "click" | "contextmenu"
teleported?: boolean
avatarProps?: Partial<AvatarProps>
}四、command 事件透传与数据驱动菜单项
用户菜单进入「可用组件」阶段,标志是数据结构和事件链路都闭环。菜单项应数据化(label + command,并可扩展 disabled/divided),点击后把 command 继续向外 emit——AvatarMenu 只负责渲染与发命令,不负责解释命令(个人中心、退出登录等动作属于外层业务)。
早期为了快速跑通,菜单项可能用 string | number | object 这类宽松联合类型,但它要求模板里补类型守卫(typeof === "object" 再取字段),长期更适合收敛成统一对象接口:
interface AvatarMenuItem {
label: string
command: string | number | object
disabled?: boolean
divided?: boolean
}基础组件要保留自己的原生事件出口,聚合组件(如 Header)可以继续转发,但不应替代它——否则别人单独拿 AvatarMenu 去别的页面用时会失去通用性。provide/inject 适合深层共享状态,但不适合替代基础组件的公开事件。
五、头像回退与尺寸优先级
头像这类视觉元素一旦允许多层覆盖(外层 size、avatarProps.size、组件内默认尺寸),就必须设计明确优先级链路,否则「谁生效、为何生效」难以解释:
const avatarSize = computed(() =>
props.avatarSize ?? props.avatarProps?.size ?? "small"
)头像和用户名应视为同一个触发区域,用 flex items-center 包成整体,而不是各自分散排版。username 这类随用户信息变化的显示字段要尽早从模板常量升级为显式 props。defineProps 对复杂继承类型仍有边界(尤其包装第三方 props 时),优先用本地 interface + Partial/Omit 组合,必要时再考虑兼容手段。头像加载失败时要有兜底,常见两种方案:一是准备一张默认头像图,二是用用户名首字母作为 Avatar 内容,保证界面不破相:
<el-avatar :size="avatarSize">
<template v-if="!avatar">
{{ username?.charAt(0)?.toUpperCase() }}
</template>
</el-avatar>菜单项常需要分割线与危险样式,统一对象接口里预留 divided 与 danger 即可,渲染时分别用 el-dropdown-item 的 divided 属性与文字颜色切换:
interface AvatarMenuItem {
label: string
command: string | number | object
disabled?: boolean
divided?: boolean
danger?: boolean
}这类看似细小的扩展点,恰恰是复合组件从「能跑」到「稳用」的分水岭——接口提前留好,后续加功能时类型就不会分叉。
常见问题
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 右侧功能没推到最右 | 左右结构没有可伸缩空间 | 中间加 flex-grow 容器 |
设了 trigger="click" 仍 hover 打开 | 封装层 v-bind 覆盖了显式属性 | 把 :trigger 写在 v-bind 之后 |
| 扩展 Dropdown+Avatar props 报 size 冲突 | 两套底层组件同名字段 | 用 Omit 去掉冲突字段或重命名 avatarSize |
| 图标 props 透传后字段冲突 | 本地业务字段与下层图标 props 混传 | computed 排除本地字段再 v-bind |
| 头像忽大忽小 | 多层 size 无明确优先级 | 固定回退链 avatarSize → avatarProps.size → small |
| 菜单项联合类型到处报 TS 错 | 模板直接读对象字段无守卫 | 先判断对象再取文本/命令,或收敛成统一接口 |