很多人第一次写下拉菜单时,心里的模型特别简单:
- 按钮被点一下;
- 菜单显示出来;
- 再点外面关闭。
这个模型不是不能用,但只适合最轻的场景。
一旦系统里开始出现:
- 多层子菜单;
- 一排相邻菜单;
- 键盘上下左右导航;
- 移动端触屏;
- RTL 界面;
- 弹层容器与激活元素边界;
你就会发现,下拉菜单早就不是“一个列表显示/隐藏”这么简单。
Odoo 在 dropdown.js、dropdown_hooks.js、dropdown_nesting.js、dropdown_group_hook.js 里的做法,非常适合拿来回答这个问题。
一、Dropdown 首先不是裸 DOM,而是带状态对象的组件
Dropdown 组件可以接收外部 state,也可以内部通过 useDropdownState() 自己创建。
这个 DropdownState 很朴素:
isOpenopen()close()onOpenonClose
但它的重要性在于:
菜单开关不再只是某个 DOM class 的副作用,而是一个可观察、可组合、可注入的状态对象。
一旦你有了明确状态对象,后面的:
- 嵌套;
- 分组;
- 键盘导航;
- 手动控制;
- 外部协同;
才有共同语言。
二、真正的难点不在“展开”,而在“别的菜单怎么反应”
useDropdownNesting() 很精彩。
它内部维护一个 DropdownNestingState,包含:
parentchildrenclose()closeChildren()closeAllParents()
并且用一根模块级 EventBus 广播:
dropdown-opened
这里最妙的不是“谁打开了”,而是打开之后别的 dropdown 如何判断:
- 我是不是它自己;
- 我是不是它的父级;
- 我是不是它的子级;
- 我们是不是同一个 active element 世界。
这说明官方早就知道,下拉菜单真正棘手的不是某一个菜单自身,而是:
一群菜单同时存在时,谁该保持、谁该让路。
三、active element 边界,是避免“跨弹窗互相误伤”的关键
dropdown_nesting.js 里有一个很容易错过的细节:
- 会把
uiService.activeElement记录进当前 dropdown; - 只有 active element 语境一致时,菜单之间才互相影响。
这特别重要。
因为在大型 Web Client 里,不同 dialog、不同 overlay、不同局部宿主中都可能存在 dropdown。
如果你只要听见“有别的菜单开了”就立刻全关,很容易出现:
- A 对话框里的菜单,误关掉 B 对话框里的菜单;
- 一个局部面板打开子菜单,别处菜单也跟着异常收缩;
- 调试时觉得像随机 bug,其实是作用域没切开。
Odoo 在这里表达得非常明确:
- dropdown 的互斥不是全局绝对互斥;
- 它要服从当前活跃宿主边界。
四、嵌套菜单不是“多画几层”,而是要有关闭契约
DropdownNestingState 提供:
closeChildren()closeAllParents()
这说明嵌套菜单不是单纯视觉层级,而是一棵有方向的交互树。
用户在子菜单按 Escape,应该只关当前层还是一路往上关? 左箭头、右箭头在 RTL 下是不是反过来? 切到另一个兄弟菜单时,要不要顺手收掉旧子树?
这些都不是 CSS 能解决的,而是交互契约。
Odoo 把它们放进 nesting 行为层,而不是散落在每个业务菜单里重复写。
五、分组行为解决的是“菜单条”的一致性
useDropdownGroup() 会把当前 dropdown 加入父级 DropdownGroup,并暴露:
isInGroupisOpen
配合 handleMouseEnter() 的逻辑,就能实现一个很常见但很容易写烂的体验:
- 菜单条里已经有一个菜单打开时;
- 鼠标移到同组另一个菜单;
- 它应当自动接棒打开。
这类体验用户觉得理所当然,但如果没有 group 层,很容易每个菜单各写各的 hover 逻辑,最后细节不一致。
六、键盘导航不是附属能力,而是主协议之一
Dropdown 内部调用 useNavigation(),并把:
- Escape
- ArrowLeft
- ArrowRight
以及嵌套时的左右逻辑一起纳入。
尤其值得注意的是 RTL 支持:
- 在 RTL 环境里,左右箭头行为会反转。
这点非常能体现 Odoo 的工程成熟度。
很多组件库说自己支持 RTL,实际上只是把样式镜像一下;但真正完整的 RTL 支持,连键盘语义都要跟着翻。
七、click-away 也不是“document 上挂个点击事件”
dropdown.js 会把 click-away 判断委托给 popover 逻辑,再结合 uiService.getActiveElementOf(target) 与当前 activeEl 比较。
这说明判断“点击外部关闭”时,官方考虑的不只是 DOM 包含关系,还包括:
- 当前激活元素属于哪个宿主;
- Shadow DOM 主机怎么处理;
- 不同浮层上下文怎么隔离。
成熟组件最怕的,就是把 click-away 写得过于天真。
八、移动端 bottom sheet 不是另做一套,而是 Dropdown 的变体
源码里还有一个很务实的判断:
- 小屏 + 触屏时,可启用
bottomSheet; - 同一个 Dropdown 逻辑切到 bottom sheet 表现。
这特别聪明。
因为移动端用户需要的通常不是“缩小版桌面下拉”,而是更好点按、更稳定位的底部抽屉式交互。
Odoo 没有把它拆成完全独立组件,而是把它纳入同一套 Dropdown 运行时里,让:
- 状态;
- 嵌套;
- 关闭;
- 交互契约;
尽量保持一致。
九、为什么说 Dropdown 更像一个微型运行时
把这些点串起来,你就会发现 Dropdown 真正管理的是一整套小系统:
- 自身开关状态;
- 菜单与子菜单树;
- 与兄弟菜单的互斥关系;
- 活跃宿主边界;
- 键盘导航与 RTL 语义;
- 点击外部关闭;
- 移动端展示策略。
这已经远远超出“一个按钮 + 一个 ul”的范畴了。
十、总结:稳定下拉菜单靠的不是动画,而是规则清楚
Odoo 的 Dropdown 之所以值得研究,不是因为它动画多炫,而是它把最容易被业务代码重复踩坑的规则都前置到了基础层:
- 开关状态对象化;
- 嵌套树显式化;
- 同组行为统一化;
- 键盘协议完整化;
- RTL 与移动端纳入主链路;
- click-away 服从宿主边界。
所以真正成熟的下拉系统,从来不是“点一下展开就完了”,而是:
任何时候打开、关闭、切层、换方向、换设备,系统都知道谁该响应、谁该关闭、谁该保持。
这才是 Dropdown 在大型 Web Client 里真正值钱的地方。
DISCUSSION
评论区