自定义光标在代码块横向滚动时无法跟随:原因与修复

背景

博客使用了自定义光标组件来替代系统光标。这个光标由两部分组成:一个中心圆点,以及四个 L 形角标,通过 GSAP 缓动跟随鼠标移动。当鼠标悬停在链接、按钮等元素上时,角标会展开包裹住目标元素。

文章详情页的代码块支持横向滚动。当用户拖动代码块底部的横向滚动条时,出现了问题:光标会停在按下时的位置,不再跟随鼠标移动。

问题定位

最初我以为是光标本身实现的问题,但排查后发现这其实是浏览器的结构性限制。

原生滚动条拖动不会派发 mousemove

原生滚动条的拖动是由浏览器内部处理的。用户按住滚动条拇指拖动时,页面收不到任何 mousemove 事件,因此依赖 mousemove 来更新位置的自定义光标自然就停住了。

这个限制也解释了为什么页面级滚动条之前能正常工作:App.vue 中有一段逻辑,在 scroll 事件发生时派发一个基于缓存的鼠标坐标的合成 mousemove。但它在代码块上不生效,原因有两个:

  1. scroll 事件不冒泡,代码块这种内层滚动容器的事件无法传递到 window
  2. 即使能收到,缓存的是上一次真实 mousemove 的坐标,而拖动滚动条过程中的新坐标从未经过页面。

所以问题的本质是:在原生滚动条拖动期间,页面无法得知鼠标的真实位置。

第一次尝试:派发合成事件(无效)

我首先尝试把 scroll 监听改为捕获阶段注册,以截获内层容器的滚动事件,然后派发合成的 mousemove

javascript
window.addEventListener('scroll', onScroll, { passive: true, capture: true })

内层滚动确实能被捕获到,但光标没有任何反应。原因正如上文分析:合成事件携带的是缓存的旧坐标,派发再多也无法让光标移动到正确的位置。

这个尝试说明了一个原则:光标由 mousemove 驱动,任何"在滚动时补发事件"的方案都只能恢复到已知位置,无法获得拖动过程中的新坐标。

解决方案:自定义滚动条

既然原生滚动条无法被跟踪,我决定改用自定义滚动条。思路是:用 CSS 隐藏原生滚动条,用 DOM 元素绘制一条轨道和一个可拖动的拇指,让拖动过程走正常的 pointer 事件。pointerdownpointermovepointerup 都是派发给页面的,光标就能正常跟随。

代码块的结构如下(由 Markdown 渲染器生成):

html
<div class="code-block">  <div class="code-block__bar">    <span class="code-block__lang">javascript</span>    <button class="code-block__copy">…</button>  </div>  <pre class="shiki">…代码…</pre>  <div class="code-block__scrollbar">    <div class="code-block__scrollbar-thumb"></div>  </div></div>

隐藏原生滚动条的样式:

css
.code-block pre {  overflow-x: auto;  scrollbar-width: none; /* Firefox */}.code-block pre::-webkit-scrollbar {  height: 0;  display: none; /* Chrome / Safari */}

拖拽逻辑的演进

第一版使用了 setPointerCapturepreventDefault,这是实现拖拽时的常见写法:

javascript
function onPointerDown(e) {  const thumb = e.target.closest('.code-block__scrollbar-thumb')  thumb.setPointerCapture?.(e.pointerId)  e.preventDefault()}

这一版在自动化测试中通过了:滚动条可以拖动,光标也能跟随。但上线后用户反馈了一个新问题:页面滚动和拖动滚动条时,光标会剧烈抖动(乱飞)。

抖动问题的排查

排查过程中发现两个值得记录的浏览器行为:

其一,setPointerCapture 会冻结原生 mousemove 的坐标。 实测发现,在指针捕获期间,pointermove 事件的坐标是实时更新的(滚动条因此能正常拖动),但原生 mousemoveclientX 被固定在按下时的位置。

为了绕过这一点,我尝试用 pointermove 的实时坐标手动派发合成 mousemove

javascript
function onPointerMove(e) {  window.dispatchEvent(new MouseEvent('mousemove', {    clientX: e.clientX,    clientY: e.clientY,  }))}

这确实让光标跟手了,但引入了新的问题:合成 mousemove 与浏览器自身的事件产生冲突,两者同时向光标发起 GSAP 补间动画,导致光标在两个目标之间来回抖动。

其二,pointerdown 上的 preventDefault 会抑制后续的 mousemove 移除之后,光标在拖动时依靠原生的 mousemove 就能正常跟随,完全不需要手动派发事件。

最终实现

综合以上发现,最终方案删除了所有"额外"的机制:

  • 不使用 setPointerCapture,避免冻结 mousemove 坐标;
  • 不在 pointerdown 上调用 preventDefault,避免抑制 mousemove
  • 不手动派发合成 mousemove,避免与原生事件冲突。

保留的核心逻辑:

javascript
function onPointerDown(e) {  const thumb = e.target.closest('.code-block__scrollbar-thumb')  if (!thumb) return  const scrollbar = thumb.parentElement  const pre = scrollbar.closest('.code-block')?.querySelector('pre')  if (!pre) return  dragState = { pre, startX: e.clientX, startLeft: pre.scrollLeft, ratio }  // 不调用 preventDefault,改用 selectstart / dragstart 阻止拖拽时选中文字  document.addEventListener('selectstart', preventSelect, true)  document.addEventListener('dragstart', preventSelect, true)}function onPointerMove(e) {  if (!dragState) return  const { pre, startX, startLeft, ratio } = dragState  pre.scrollLeft = startLeft + (e.clientX - startX) * ratio}

光标跟随完全依赖原生的 mousemove,这也是自定义光标本身的实现方式。我们要做的只是避免使用那些会破坏 mousemove 行为的浏览器机制。同时,之前为捕获内层滚动而加的捕获阶段监听也一并回退,因为合成事件越少,抖动出现的概率越低。

顺带修复的问题:行号第一行偏移

在给代码块加行号时,还遇到过一个与光标无关但值得记录的问题:第一行的行号会向右偏移。

getBoundingClientRect() 逐一测量每一行,行号列的起点完全一致,但在实际渲染中第一行确实偏了。这是 CSS 盒模型的一个经典陷阱:

css
.code-block pre code {  /* code 默认是 inline,而 .line 是 block */  /* 块级元素嵌在行内元素中会触发匿名块盒拆分,导致第一行偏移 */  display: block; /* 修复 */}

<code> 默认为行内元素(inline),而每一行 .line 被设置为块级元素(block)。块级元素嵌套在行内元素中时,浏览器会触发匿名块盒拆分,第一行被包裹进一个额外的匿名块,视觉上便产生了偏移。将 code 也设为 display: block 即可解决。

总结

这次修复的核心收获可以概括为以下几点:

  1. 原生滚动条拖动期间,页面无法获得鼠标的实时位置。 这是浏览器层面的限制,试图通过"派发事件"来绕过是行不通的。
  2. 光标跟随依赖真实的 mousemove 事件。 任何补发的事件都只能使用已知的旧坐标,无法解决新位置缺失的问题。让事件正常流动,比手动模拟事件更可靠。
  3. 自定义滚动条将拖拽转化为普通的 DOM 事件。 这样做让问题从"浏览器不提供事件"转变为"如何处理自己的事件逻辑",更可控、更易调试。
  4. 一些"标准"写法可能破坏事件行为。 例如 setPointerCapture 会冻结 mousemove 坐标,pointerdownpreventDefault 会抑制 mousemove。这些行为在不同环境中表现可能不一致,需要实际验证。

在调试这类事件交互问题时,建议关注以下几点:

  • 不要只依赖自动化测试。 有些问题(如合成事件与原生事件的时序冲突)在无头浏览器中无法复现,需要结合真实环境的反馈。
  • 打印事件的坐标值。 对比不同事件(如 mousemovepointermove)的坐标,可以快速判断是哪个环节出了问题——坐标被冻结,还是坐标在变化。
  • 一次只修改一个变量。 捕获、preventDefault、合成事件,逐一开启或关闭,可以准确定位到是哪个机制导致的异常。