背景
博客使用了自定义光标组件来替代系统光标。这个光标由两部分组成:一个中心圆点,以及四个 L 形角标,通过 GSAP 缓动跟随鼠标移动。当鼠标悬停在链接、按钮等元素上时,角标会展开包裹住目标元素。
文章详情页的代码块支持横向滚动。当用户拖动代码块底部的横向滚动条时,出现了问题:光标会停在按下时的位置,不再跟随鼠标移动。
问题定位
最初我以为是光标本身实现的问题,但排查后发现这其实是浏览器的结构性限制。
原生滚动条拖动不会派发 mousemove
原生滚动条的拖动是由浏览器内部处理的。用户按住滚动条拇指拖动时,页面收不到任何 mousemove 事件,因此依赖 mousemove 来更新位置的自定义光标自然就停住了。
这个限制也解释了为什么页面级滚动条之前能正常工作:App.vue 中有一段逻辑,在 scroll 事件发生时派发一个基于缓存的鼠标坐标的合成 mousemove。但它在代码块上不生效,原因有两个:
scroll事件不冒泡,代码块这种内层滚动容器的事件无法传递到window;- 即使能收到,缓存的是上一次真实
mousemove的坐标,而拖动滚动条过程中的新坐标从未经过页面。
所以问题的本质是:在原生滚动条拖动期间,页面无法得知鼠标的真实位置。
第一次尝试:派发合成事件(无效)
我首先尝试把 scroll 监听改为捕获阶段注册,以截获内层容器的滚动事件,然后派发合成的 mousemove:
window.addEventListener('scroll', onScroll, { passive: true, capture: true })
内层滚动确实能被捕获到,但光标没有任何反应。原因正如上文分析:合成事件携带的是缓存的旧坐标,派发再多也无法让光标移动到正确的位置。
这个尝试说明了一个原则:光标由 mousemove 驱动,任何"在滚动时补发事件"的方案都只能恢复到已知位置,无法获得拖动过程中的新坐标。
解决方案:自定义滚动条
既然原生滚动条无法被跟踪,我决定改用自定义滚动条。思路是:用 CSS 隐藏原生滚动条,用 DOM 元素绘制一条轨道和一个可拖动的拇指,让拖动过程走正常的 pointer 事件。pointerdown、pointermove、pointerup 都是派发给页面的,光标就能正常跟随。
代码块的结构如下(由 Markdown 渲染器生成):
<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>
隐藏原生滚动条的样式:
.code-block pre { overflow-x: auto; scrollbar-width: none; /* Firefox */}.code-block pre::-webkit-scrollbar { height: 0; display: none; /* Chrome / Safari */}
拖拽逻辑的演进
第一版使用了 setPointerCapture 和 preventDefault,这是实现拖拽时的常见写法:
function onPointerDown(e) { const thumb = e.target.closest('.code-block__scrollbar-thumb') thumb.setPointerCapture?.(e.pointerId) e.preventDefault()}
这一版在自动化测试中通过了:滚动条可以拖动,光标也能跟随。但上线后用户反馈了一个新问题:页面滚动和拖动滚动条时,光标会剧烈抖动(乱飞)。
抖动问题的排查
排查过程中发现两个值得记录的浏览器行为:
其一,setPointerCapture 会冻结原生 mousemove 的坐标。 实测发现,在指针捕获期间,pointermove 事件的坐标是实时更新的(滚动条因此能正常拖动),但原生 mousemove 的 clientX 被固定在按下时的位置。
为了绕过这一点,我尝试用 pointermove 的实时坐标手动派发合成 mousemove:
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,避免与原生事件冲突。
保留的核心逻辑:
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 盒模型的一个经典陷阱:
.code-block pre code { /* code 默认是 inline,而 .line 是 block */ /* 块级元素嵌在行内元素中会触发匿名块盒拆分,导致第一行偏移 */ display: block; /* 修复 */}
<code> 默认为行内元素(inline),而每一行 .line 被设置为块级元素(block)。块级元素嵌套在行内元素中时,浏览器会触发匿名块盒拆分,第一行被包裹进一个额外的匿名块,视觉上便产生了偏移。将 code 也设为 display: block 即可解决。
总结
这次修复的核心收获可以概括为以下几点:
- 原生滚动条拖动期间,页面无法获得鼠标的实时位置。 这是浏览器层面的限制,试图通过"派发事件"来绕过是行不通的。
- 光标跟随依赖真实的
mousemove事件。 任何补发的事件都只能使用已知的旧坐标,无法解决新位置缺失的问题。让事件正常流动,比手动模拟事件更可靠。 - 自定义滚动条将拖拽转化为普通的 DOM 事件。 这样做让问题从"浏览器不提供事件"转变为"如何处理自己的事件逻辑",更可控、更易调试。
- 一些"标准"写法可能破坏事件行为。 例如
setPointerCapture会冻结mousemove坐标,pointerdown的preventDefault会抑制mousemove。这些行为在不同环境中表现可能不一致,需要实际验证。
在调试这类事件交互问题时,建议关注以下几点:
- 不要只依赖自动化测试。 有些问题(如合成事件与原生事件的时序冲突)在无头浏览器中无法复现,需要结合真实环境的反馈。
- 打印事件的坐标值。 对比不同事件(如
mousemove与pointermove)的坐标,可以快速判断是哪个环节出了问题——坐标被冻结,还是坐标在变化。 - 一次只修改一个变量。 捕获、
preventDefault、合成事件,逐一开启或关闭,可以准确定位到是哪个机制导致的异常。