我把一个 bug 发给 ai,ai 直接通过 playwright mcp 直接排查出 bug。。

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: hiddenposition: 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 结构有关。

验证: 用三种模式进行对比:

  1. el-scrollbar(Element Plus 组件)
  2. 模拟嵌套(用普通 div 复制 el-scrollbar 的 overflow 层级,但没有隐藏 bar)
  3. 普通 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 元素上有某种特殊属性(如 inertdata-*、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 更高的元素覆盖在它上面。

具体过程:

  1. el-scrollbar 组件在挂载时绑定了 mousemove 监听器:

    scss 复制代码
    useEventListener(scrollbarElement, 'mousemove', mouseMoveScrollbarHandler)
  2. Chromium 合成器检测到 el-scrollbar 上存在 mousemove 监听器。

  3. 合成器将 el-scrollbar 的整个盒模型区域标记为"交互区域"。

  4. 执行触摸命中测试时,合成器优先把触摸事件分配给该区域内的可滚动子元素(即 .el-scrollbar__wrap 内的 content)。

  5. 即便手柄在 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 基于渲染树做命中测试,结果不受合成器内部标记影响,因此是准确的。

结论

  1. 该问题的直接原因:el-scrollbar 组件绑定了 mousemove 事件监听器
  2. 根本机制:Chromium 合成器为优化触摸响应,会优先将触摸事件导向存在 mousemove 监听的区域,这一行为会忽略 z-index 等视觉层叠关系。
  3. 该行为不是 Element Plus 的 bug,也非 CSS 规范问题,而是 Chromium 的一项未文档化的内部优化策略。
  4. 可行的修复方式包括:通过 pointer-events 规则跳过该区域,或在捕获阶段用 elementFromPoint 手动校正事件目标。
相关推荐
meilindehuzi_a1 小时前
打破0基础:通过 5 个核心案例深度拆解 JavaScript 正则表达式与运行时类型系统
开发语言·javascript·正则表达式
时寒的笔记1 小时前
LF11期 day21-day22:逆向瑞数加密 欧冶案例分析(一)
javascript
lbb 小魔仙1 小时前
稳定比技巧更重要:海外多地区数据采集的经验教训
开发语言·javascript·ecmascript
布兰妮甜1 小时前
Vue 视图不更新?常见赋值踩坑点汇总
前端·javascript·vue.js·vue踩坑·vue视图不更新
我有满天星辰2 小时前
【Dart 语言学习教程 】第三章:函数式编程与高阶特性
开发语言·javascript·ecmascript
前端 贾公子2 小时前
uni-app工程化实战:基于vue-i18n和i18n-ally的国际化方案 (下)
前端
@zulnger2 小时前
selenium 操作浏览器
前端·javascript·selenium
爱编程的小金2 小时前
告别手写分页逻辑:usePagination 从 50 行到 3 行
javascript·vue·前端分页·alova·usepagination
触底反弹2 小时前
5 个 Step,让你的前端代码连上 AI 大模型
javascript·人工智能·面试