Chromium Touch 命中测试 Bug 分析报告
问题描述
在 Chrome DevTools 移动端模拟模式下,触摸一个与 el-scrollbar(Element Plus)相邻的手柄元素时,touchstart 事件的 e.target 始终是滚动容器内部的 content 元素,而不是视觉上位于最顶层的手柄。
scss
┌──────────────────┬──┐
│ │ │ ← 手柄 (position: absolute, z-index: 10)
│ el-scrollbar │ │ 宽度 10px,在滚动容器右侧
│ (滚动内容) │ │
│ │ │
└──────────────────┴──┘
↑ ↑
touch 实际命中的 理应命中的元素
元素 (content) (handle)
排查过程
第一阶段:确认 DOM 层叠关系
假设: z-index 或 overflow 导致手柄被滚动容器遮挡。
验证: 用 document.elementFromPoint(213, 340) 检查该坐标处的最顶层元素。
结果:
scss
elementFromPoint(213, 340) → handle ✅ 正确
touchstart at (213, 340) → content ❌ 错误
结论: 渲染树的层叠关系正确,手柄确实在最上层。问题出在 touch 事件分发机制,而非 CSS 层叠。
第二阶段:排查 el-scrollbar 的 CSS 属性
假设: el-scrollbar 自身的 overflow: hidden、position: relative 等 CSS 属性,导致合成器做了错误优化。
验证: 逐一修改 el-scrollbar 的 CSS 属性,测试 touch target。
结论: CSS 属性不是原因。不管如何修改 overflow、position、z-index,touch target 始终是 content。
第三阶段:排查隐藏的 scrollbar bar 元素
假设: el-scrollbar 内部 display: none 的滚动条轨道(position: absolute + z-index: 1)干扰了合成器的命中测试。
验证: 从 DOM 中移除这些 bar 元素。
结果: touch target 仍然是 content ❌
结论: 隐藏的 bar 元素不是原因。
第四阶段:对比普通 div 与 el-scrollbar
假设: 问题与 el-scrollbar 的 DOM 结构有关。
验证: 用三种模式进行对比:
- el-scrollbar(Element Plus 组件)
- 模拟嵌套(用普通 div 复制 el-scrollbar 的 overflow 层级,但没有隐藏 bar)
- 普通 div(单层 overflow: auto)
| 模式 | touch target | DOM 结构 |
|---|---|---|
| el-scrollbar | content ❌ | overflow:hidden 外层 + overflow:auto 内层 + 隐藏 bar |
| 模拟嵌套 | handle ✅ | overflow:hidden 外层 + overflow:auto 内层(无 bar) |
| 普通 div | handle ✅ | 单层 overflow:auto |
结论: 嵌套 overflow 结构本身不是原因,问题与 el-scrollbar 组件直接相关。
第五阶段:排查 el-scrollbar 的特殊属性
假设: el-scrollbar 元素上有某种特殊属性(如 inert、data-*、Vue 内部标记等),导致合成器做了特殊处理。
验证: 检查 el-scrollbar 的所有属性、计算样式和 JS 属性。
结论: 没有任何特殊属性,el-scrollbar 就是一个普通的 <div>。
第六阶段:cloneNode 对比实验(关键突破)
假设: 如果用 cloneNode(true) 复制 el-scrollbar 并替换原元素,DOM 结构完全一样,但 Vue 绑定的事件监听器会丢失。如果克隆后问题消失,说明原因在事件监听器。
验证:
ini
const original = document.querySelector('.el-scrollbar')
const clone = original.cloneNode(true)
original.replaceWith(clone)
结果: touch target = handle ✅
结论: cloneNode 修复了问题。 克隆后的元素保留了 DOM 结构和 CSS,但丢失了 Vue 绑定的事件监听器,证明问题是由事件监听器导致的。
第七阶段:逐个添加事件监听器(精确定位)
假设: el-scrollbar 内部绑定了多种事件监听器(scroll、mousemove、mouseleave 等),需确定是哪一个监听器导致的。
验证: 在克隆出的元素上逐个添加事件监听器,测试哪个会重新触发 bug。
| 添加的监听器 | touch target | 结论 |
|---|---|---|
| 无(纯 clone) | handle ✅ | 基线正常 |
| scroll(在 wrap 上) | handle ✅ | scroll 不是原因 |
| mousemove(在 scrollbar 上) | content ❌ | mousemove 是原因! |
| ResizeObserver | handle ✅ | ResizeObserver 不是原因 |
| MutationObserver | handle ✅ | MutationObserver 不是原因 |
| mousemove + mouseleave + scroll + ResizeObserver + mousedown | content ❌ | 只要包含 mousemove 就会触发 |
结论: mousemove 事件监听器是唯一的触发条件。
根因推断
Chromium 合成器在触摸命中测试时,会将绑定了 mousemove 事件监听器的元素区域标记为需要优先响应的"交互区域"。之后,触摸事件会被优先分配给这个区域,即使视觉上有其他 z-index 更高的元素覆盖在它上面。
具体过程:
-
el-scrollbar 组件在挂载时绑定了
mousemove监听器:scssuseEventListener(scrollbarElement, 'mousemove', mouseMoveScrollbarHandler) -
Chromium 合成器检测到 el-scrollbar 上存在
mousemove监听器。 -
合成器将 el-scrollbar 的整个盒模型区域标记为"交互区域"。
-
执行触摸命中测试时,合成器优先把触摸事件分配给该区域内的可滚动子元素(即
.el-scrollbar__wrap内的 content)。 -
即便手柄在 z-index 上更高,
elementFromPoint返回的也是手柄,合成器仍将 touch 事件分发给了 content。
需要说明的是: 这个行为并未在公开文档中明确说明,属于通过对比实验推断出的 Chromium 内部策略,并非 Element Plus 或 CSS 的缺陷。
解决方案
方案 A:CSS pointer-events(推荐)
在触摸设备上禁用 el-scrollbar 自身的 pointer-events,让触摸穿透到手柄,同时恢复内部子元素的 pointer-events,以保证滚动和点击正常。
css
@media (pointer: coarse) {
.el-scrollbar {
pointer-events: none;
}
.el-scrollbar > * {
pointer-events: auto;
}
}
说明:
pointer-events: none是合成器在触摸命中测试中会直接跳过的少数 CSS 属性之一,设置后,el-scrollbar 所在区域不再被当作候选触摸目标,事件会正确落在手柄上。- 通过
> *恢复子元素的 pointer-events,可以继续响应滚动和点击。 - 在移动端,el-scrollbar 上原本用于显示/隐藏滚动条的
mousemove监听器本来就不会触发,功能不受影响。
方案 B:JavaScript 事件拦截
在 document 的捕获阶段拦截 touchstart,利用 elementFromPoint 做二次校正,将事件重新导向真正被触摸的元素。
javascript
document.addEventListener('touchstart', function(e) {
const touch = e.touches[0];
if (!touch) return;
const target = document.elementFromPoint(touch.clientX, touch.clientY);
if (target && target !== e.target && target.classList.contains('handle')) {
e.stopImmediatePropagation();
e.preventDefault();
// 使用 target.dispatchEvent 重新触发事件需谨慎,避免递归;
// 更稳妥的做法是直接在此处执行业务逻辑,或通过自定义事件通知。
}
}, { capture: true });
说明:
elementFromPoint基于渲染树做命中测试,结果不受合成器内部标记影响,因此是准确的。
结论
- 该问题的直接原因:el-scrollbar 组件绑定了
mousemove事件监听器。 - 根本机制:Chromium 合成器为优化触摸响应,会优先将触摸事件导向存在
mousemove监听的区域,这一行为会忽略 z-index 等视觉层叠关系。 - 该行为不是 Element Plus 的 bug,也非 CSS 规范问题,而是 Chromium 的一项未文档化的内部优化策略。
- 可行的修复方式包括:通过
pointer-events规则跳过该区域,或在捕获阶段用elementFromPoint手动校正事件目标。