如何检测横向 div 列表是否会溢出,并自动切换为下拉列表?
可以用 ResizeObserver 同时观察可用宽度和列表完整展开时的宽度。发生溢出时,隐藏横向列表并显示下拉列表,同时保留横向列表的布局尺寸,以便继续判断何时可以恢复。
浏览器没有专门用于通知元素“即将溢出”的通用 DOM 事件,因此仍需比较尺寸。不过,不必持续轮询,也不必逐个累加 div 的宽度。
为什么只监听父容器不够
原问题通过 requestAnimationFrame 累加 $div.getBoundingClientRect().width,再与父容器宽度比较。这个方法可以工作,但要额外考虑 gap、margin 等占用的空间。如果每帧都执行,还会反复读取布局。
只观察父容器也不够:父容器宽度不变时,项目文字、字体或内容变化仍可能让列表变宽。
可以这样组织结构:
- 外层 viewport 提供当前可用宽度,裁剪溢出内容。
- 内层 row 保持单行,使用
width: max-content保留完整内容所需的宽度。 - 同时观察 viewport 和 row,任一方尺寸变化时重新判断。
这样可以响应容器宽度和列表自然宽度的变化。
一个完整示例
示例将整个列表切换为原生 select。项目来自静态 HTML,下拉选项在初始化时生成。
<div class="control" id="items-control">
<div class="viewport">
<div class="row">
<div>项目 Alpha</div>
<div>项目 Beta</div>
<div>名称较长的项目 Gamma</div>
</div>
</div>
<select class="menu" aria-label="选择项目" hidden></select>
</div>
.control {
position: relative;
min-width: 0;
}
.viewport {
overflow-x: hidden;
}
.row {
display: flex;
flex-wrap: nowrap;
width: max-content;
gap: 8px;
white-space: nowrap;
}
.row > div {
flex: 0 0 auto;
}
.control.is-overflowing .viewport {
visibility: hidden;
}
.menu {
position: absolute;
inset: 0;
box-sizing: border-box;
width: 100%;
height: 100%;
}
const control = document.querySelector('#items-control');
const viewport = control.querySelector('.viewport');
const row = control.querySelector('.row');
const menu = control.querySelector('.menu');
for (const [index, item] of Array.from(row.children).entries()) {
menu.add(new Option(item.textContent.trim(), String(index)));
}
let frameId = null;
function update() {
frameId = null;
// 本例 viewport 没有 padding、border,测得宽度即可用于比较。
const availableWidth = viewport.getBoundingClientRect().width;
const requiredWidth = row.getBoundingClientRect().width;
const overflowing = requiredWidth > availableWidth;
control.classList.toggle('is-overflowing', overflowing);
viewport.setAttribute('aria-hidden', String(overflowing));
menu.hidden = !overflowing;
}
function scheduleUpdate() {
if (frameId !== null) return;
frameId = requestAnimationFrame(update);
}
const observer = new ResizeObserver(scheduleUpdate);
observer.observe(viewport);
observer.observe(row);
scheduleUpdate();
// 在组件卸载时调用。
function destroy() {
observer.disconnect();
if (frameId !== null) {
cancelAnimationFrame(frameId);
frameId = null;
}
}
这里的 requestAnimationFrame 在收到尺寸变化通知时安排一次更新,并合并同一帧内的重复通知,不会持续运行动画循环。
为什么隐藏后仍然可以检测
visibility: hidden 会保留元素的布局尺寸。即使当前显示的是下拉列表,row 仍具有完整展开时的宽度,ResizeObserver 也可以继续观察它。
如果对测量用的 row 设置 display: none,它就不再生成布局盒,无法直接测出完整列表所需的宽度。
示例中的下拉列表使用绝对定位,不参与正常布局;切换时只改变横向列表的可见性。这可以减少展示方式改变尺寸、尺寸变化又触发切换所造成的来回抖动。
父子组件之间的处理也可以放在组件内部:由组件持有 viewport 和 row 的引用,不需要让每个子 div 自行查找应该监听的祖先元素。
能否直接比较 scrollWidth 和 clientWidth
如果内容始终保留在容器中,下面的判断通常更简单:
const overflowing = viewport.scrollWidth > viewport.clientWidth;
overflow-x: hidden 只负责裁剪显示,不会让溢出的内容从布局中消失,因此仍可读取滚动区域的宽度。
不过,scrollWidth 和 clientWidth 是整数值,临界位置可能受取整影响。示例使用 getBoundingClientRect() 比较两个独立盒子的宽度,可以保留小数精度。实际布局若包含 padding、border 或 transform,需要先统一比较口径,不能直接照搬这个宽度比较。
使用时需要注意什么
ResizeObserver 观察的是元素尺寸,不是通用的内容变更通知器。示例通过 width: max-content 让 row 的宽度随内容变化;如果把 row 固定为容器宽度,即使内部溢出增加,它自身的宽度也可能不变。
如果项目会动态增删,应同步更新下拉选项。最好从同一份数据生成两种展示;只有无法控制 DOM 更新来源时,才考虑用 MutationObserver 补充监听。尺寸检测不会自动维护选项数据。
如果需求变成保留能放下的项目,将剩余项目放进“更多”菜单,还需要计算菜单按钮本身占用的宽度,并判断每个项目的边界。当前示例是整组切换,比较列表总宽度与容器可用宽度即可。
备注:内容仅供参考。
