{T}

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。因此折叠按钮应是事件触发器而非状态存储器——Headeremit("toggle-collapse"),由外层布局接收后修改状态。同理,语言切换的数据逻辑也应写在外层使用处,而不是写死在 Header 基础组件内部;LocaleItem 这类跨多个组件共享的数据结构要尽早抽成公共类型,避免字段名不一致。

二、右侧工具栏:flex-grow 与图标扩展统一

功能接通后通常不是加逻辑,而是统一图标尺寸、垂直居中和水平间距——这是工具栏「接通即暴露」的典型问题。图标大小先可用类名快速验证,但最终更适合作为 props(iconClass/iconProps)开放给外层,因为标准后台、紧凑移动端、演示版面需求不同。

当组件既有本地业务 props,又要向下透传图标组件 props 时,两者不能混在同一个 v-bind 里。关键是拆开「图标内容」(当前数据项的 icon)与「图标展示配置」(透传给图标组件的 props),用 computed 解构排除本地消费字段后再透传:

ts
const iconPropsComputed = computed(() => {
  const { locales, icon, iconClass, ...restProps } = props
  return restProps
})

三、头像下拉复合组件:两层配置与类型冲突

用户头像菜单通常是右侧工具组与用户上下文区之间的分界点。它本质是 el-dropdownel-avatar 的复合包装,会带来两个典型工程点:

  1. 触发方式覆盖顺序Dropdown 默认 hover,想改成 click 却没生效,多半是封装层 v-bind 把显式 :trigger 覆盖掉了。Vue 3 中 v-bind="object" 与同名显式属性同时存在时,后声明的覆盖前者,因此要把自己的 :trigger 写在 v-bind 之后。
  2. 同名字段冲突DropdownPropsAvatarProps 都有 size,直接多重继承会报冲突。最稳妥的是用 Omit 去掉其中一个冲突字段,或显式重命名为 avatarSize/menuSize,并对 triggerteleported 这类核心行为字段在封装层重新声明。

推荐把配置拆成两层,类型不会互相打架、默认值更清晰:

ts
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" 再取字段),长期更适合收敛成统一对象接口:

ts
interface AvatarMenuItem {
  label: string
  command: string | number | object
  disabled?: boolean
  divided?: boolean
}

基础组件要保留自己的原生事件出口,聚合组件(如 Header)可以继续转发,但不应替代它——否则别人单独拿 AvatarMenu 去别的页面用时会失去通用性。provide/inject 适合深层共享状态,但不适合替代基础组件的公开事件。

五、头像回退与尺寸优先级

头像这类视觉元素一旦允许多层覆盖(外层 sizeavatarProps.size、组件内默认尺寸),就必须设计明确优先级链路,否则「谁生效、为何生效」难以解释:

ts
const avatarSize = computed(() =>
  props.avatarSize ?? props.avatarProps?.size ?? "small"
)

头像和用户名应视为同一个触发区域,用 flex items-center 包成整体,而不是各自分散排版。username 这类随用户信息变化的显示字段要尽早从模板常量升级为显式 props。defineProps 对复杂继承类型仍有边界(尤其包装第三方 props 时),优先用本地 interface + Partial/Omit 组合,必要时再考虑兼容手段。头像加载失败时要有兜底,常见两种方案:一是准备一张默认头像图,二是用用户名首字母作为 Avatar 内容,保证界面不破相:

Vue SFC
<el-avatar :size="avatarSize">
  <template v-if="!avatar">
    {{ username?.charAt(0)?.toUpperCase() }}
  </template>
</el-avatar>

菜单项常需要分割线与危险样式,统一对象接口里预留 divideddanger 即可,渲染时分别用 el-dropdown-itemdivided 属性与文字颜色切换:

ts
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 错模板直接读对象字段无守卫先判断对象再取文本/命令,或收敛成统一接口

延伸阅读