构建基于 Vue3 和 Element Plus 之上的 Ribbon UI 库

0 阅读7分钟

复杂命令界面,往往是前端架构最容易暴露问题的地方。

Ribbon 在静态状态下看起来并不复杂:标签页、分组、按钮,也许再加一个菜单。但在真实产品中,它必须适配不同宽度、支持键盘优先操作、暴露运行时变更能力,同时还要和整套设计系统保持一致。当这些要求叠加在一起时,团队很快会发现:这不再是“做一个工具栏”,而是在构建一个编排引擎。

在这个项目中,我们选择了一条很明确的路径:把成熟的 UI 基础控件交给 Element Plus,只在本地实现 Ribbon 特有的行为层。这个边界带来了很大收益。我们不再重复造按钮、下拉、复选框、选择器,而是把精力放在 Ribbon 真正难的部分:状态协调、布局切换、溢出策略、键盘模型、运行时 API 一致性。

这篇文章会从架构角度展开,解释为什么这种方式适合 Vue 3 + TypeScript 组件库。

ribbon tabs.jpg

架构:三层模型,职责清晰

想让 Ribbon 可维护,关键是把“控件渲染”和“行为编排”拆开。

我们采用了下面的三层模型:

  • 第一层:UI 基础组件(Element Plus)

    • ElButton、ElDropdown、ElSelect、ElColorPicker、ElCheckbox、ElPopover、ElDrawer 等。
    • 负责无障碍基础交互和视觉一致性。
  • 第二层:Ribbon 编排层(MlRibbon)

    • 管理 activeTab、layout(classic/simplified)、minimized、overflow 状态、backstage 状态、key tip 会话状态。
    • 通过 RibbonDynamicApi 暴露动态变更能力。
  • 第三层:Ribbon 模块与 item 适配层

    • 模块:file menu、backstage、contextual tabs、key tips。
    • item host 根据 RibbonItemModel.type 映射到 Element Plus 控件或 Ribbon 特有组件(groupButton、gallery、template)。

这种拆分让复杂行为可以组合,而不是堆在一个巨型组件里。MlRibbon 是统一入口,但并不承载所有细节。模块职责聚焦,item 渲染按类型分发,消费者也能保持简单。

它还降低了维护风险。Element Plus 可以持续演进基础控件,我们只演进编排逻辑。两者节奏不同,但因为边界清晰,彼此不会互相拖累。

状态即引擎:受控 Props + 运行时变更 API

静态 Ribbon 容易演示,动态 Ribbon 才能上线。真实应用会在运行中根据权限、文档状态、功能开关、用户操作路径改变命令面板。

这个库的核心契约是受控状态:

  • v-model:active-tab
  • v-model:layout
  • v-model:minimized

这保证了宿主应用对状态的可预测控制。仅有受控 props 还不够,所以 MlRibbon 同时暴露了 RibbonDynamicApi,支持结构级变更,例如 addTab、removeGroup、updateItem、toggleSimplified。

<MlRibbon
  v-model:active-tab="activeTab"
  v-model:layout="layout"
  v-model:minimized="minimized"
  :tabs="tabs"
  :texts="ribbonTexts"
/>
const ribbonRef = ref<InstanceType<typeof MlRibbon> | null>(null)

// 运行时变更
ribbonRef.value?.addTab({ id: 'review', title: 'Review', groups: [] })
ribbonRef.value?.removeGroup('home', 'clipboard')
ribbonRef.value?.updateItem('home', 'draw', 'draw-primary', 'draw-circle', {
  disabled: true,
})
ribbonRef.value?.toggleSimplified()

这里有个实现细节非常关键:模型克隆与规范化。

当消费者把图标组件放进响应式 tab schema 时,Vue 可能对“组件被响应式代理”发出警告。为避免这个问题,状态层在写入可变运行时状态前,会对 tab/group/item 做克隆,并通过 toRaw + markRaw 规范化组件字段。这样既能安全更新,又能避免运行时告警。对应行为在测试里也有覆盖。

最终效果是:对外集成时保持声明式,对内动态编排时保留命令式能力,两者兼得。

交互工程:Ribbon 复杂度真正所在

这个库的难点主要不在“渲染”,而在“交互行为”。其中有三类最关键。

1)Classic 与 Simplified 布局切换

Ribbon 支持两种布局:

  • classic:分组直接内联展示
  • simplified:分组作为触发器,点击后弹出面板

编排层在切换布局时保证命令行为一致。simplified 模式会把分组内容扁平化为 medium 尺寸行内 item,确保弹层里的排布稳定,不随原始 group 结构产生不可控漂移。

2)基于宽度与优先级的 Overflow

Ribbon 不能假设固定宽度。空间不足时,哪些 group 进入 overflow 必须可预测。

MlRibbon 的策略是:

  • 读取当前面板可见宽度与 group 实际渲染宽度。
  • 按优先级决定谁先移出(高优先级更晚移出)。
  • 对 enableGroupOverflow === false 的 group 保持“固定可见”。
  • 仅在需要时预留 overflow 触发位。
  • 在 resize、tab/layout 变化后重算。

看起来简单,真正难点在于“确定性”。组件会在 DOM 稳定后调度重算,并配合尺寸观察机制持续校正,避免 resize 过程中出现闪动或跳变。

3)Alt 驱动的 Key Tips 与序列匹配

键盘支持不只是“加几个快捷键”,而是完整会话模型。

实现包括:

  • Alt 打开/关闭 key tips。
  • 序列输入增量匹配。
  • 精确匹配后触发命令并关闭 key tips。
  • Escape 退出,Backspace 回退序列。
  • 对输入框等可编辑上下文做忽略保护。
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Alt', altKey: true }))
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'r' }))
// 若某 item 的 keyTip 为 "R",会触发对应命令并关闭 key tips

另一个实用交互是下拉命令记忆:选择菜单项后会更新触发器图标/文案;点击图标执行当前命令;点击文案/箭头展开菜单。这样既提升高频操作效率,也不牺牲可发现性。

定制与国际化:不分叉也能适配业务

一个可复用 Ribbon 应该在“行为”上有主见,而不是在“文案与业务扩展 UI”上强绑定。

这里主要依赖两类扩展契约:

  • texts prop:承载本地化文案(file menu、backstage、key tips、overflow 文本等)。

  • 插槽扩展:

    • #tabs-extra:用于标签区右侧扩展(语言切换、状态区、环境开关等)
    • #backstage:完全接管 backstage 内容,同时复用组件提供的 slot props(close、open、size、labels)

这点非常重要,因为它避免了“看似通用但一接业务就要 fork”的局面。团队可以共享同一套 Ribbon 行为核心,同时为不同产品、语言、品牌输出差异化体验。

demo 里也展示了这点:en-US / zh-CN 实时切换,以及自定义 backstage 外壳,都不需要修改 Ribbon 内核。

质量与信心:测试行为,而不只是结构

重交互组件库如果只测渲染结果,往往不够。要测试“行为系统”。

这个项目的测试策略是:

  • Vitest + Vue Test Utils(单元/组件)覆盖:

    • key tip 激活路径
    • 几何条件下的 overflow 决策
    • simplified 布局内容行为
    • slot 扩展行为
    • file menu/backstage 行为
    • dropdown 箭头与命令记忆行为
    • reactive icon 规范化防护
  • Playwright 提供端到端基础验证。

一个很实际的经验是:涉及溢出和响应式布局的测试,如果没有可控几何条件,很容易不稳定。测试中对 getBoundingClientRect 与 clientWidth 做了必要 mock,让 overflow 测试变得可重复、可定位问题,而不是靠时序碰运气。

这类确定性测试投入,决定了组件是“能演示”,还是“能作为库长期维护”。

结语:能扩展的设计原则

构建 Ribbon 的核心,不是造更多控件,而是在高压场景下保持交互一致性:空间收缩、上下文变化、多输入方式并存、运行时结构变更。

这次实现里最有效的三条原则是:

  1. 基础控件尽量复用,只实现基础控件无法提供的编排能力。
  2. 把状态契约和运行时 API 当作一等接口设计。
  3. 用可控几何与键盘模型测试,验证交互级保证而非表面结构。

如果你的团队也在做复杂命令型 UI,这套模式并不只适用于 Ribbon:把“渲染原语”和“编排逻辑”分层,明确扩展面,用测试保障行为确定性,长期收益会非常明显。