复杂命令界面,往往是前端架构最容易暴露问题的地方。
Ribbon 在静态状态下看起来并不复杂:标签页、分组、按钮,也许再加一个菜单。但在真实产品中,它必须适配不同宽度、支持键盘优先操作、暴露运行时变更能力,同时还要和整套设计系统保持一致。当这些要求叠加在一起时,团队很快会发现:这不再是“做一个工具栏”,而是在构建一个编排引擎。
在这个项目中,我们选择了一条很明确的路径:把成熟的 UI 基础控件交给 Element Plus,只在本地实现 Ribbon 特有的行为层。这个边界带来了很大收益。我们不再重复造按钮、下拉、复选框、选择器,而是把精力放在 Ribbon 真正难的部分:状态协调、布局切换、溢出策略、键盘模型、运行时 API 一致性。
这篇文章会从架构角度展开,解释为什么这种方式适合 Vue 3 + TypeScript 组件库。
架构:三层模型,职责清晰
想让 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 的核心,不是造更多控件,而是在高压场景下保持交互一致性:空间收缩、上下文变化、多输入方式并存、运行时结构变更。
这次实现里最有效的三条原则是:
- 基础控件尽量复用,只实现基础控件无法提供的编排能力。
- 把状态契约和运行时 API 当作一等接口设计。
- 用可控几何与键盘模型测试,验证交互级保证而非表面结构。
如果你的团队也在做复杂命令型 UI,这套模式并不只适用于 Ribbon:把“渲染原语”和“编排逻辑”分层,明确扩展面,用测试保障行为确定性,长期收益会非常明显。