一个裁剪覆盖层,半透明背景,图片居中,四个角手柄,一个可拖拽的框——看起来万事俱备。鼠标移到手柄上,光标立刻变成拖拽箭头,顺手按下去,向外一拖,满以为裁剪区域会跟着手型走。结果什么都没动。再试一次,按住整个裁剪框拖拽,照样纹丝不动。这个固定定位的浮层在视觉上霸占了全屏,所有交互信号都到位了,可每一次拖拽,它都像一块钢化玻璃,看着在,伸手摸到的全是墙。为什么会这样?
开发者天然觉得,一个元素既然画在屏幕上并且盖住其他东西,那鼠标事件也该归它管。但浏览器的绘制系统和事件系统其实是两套机器,各干各的,只不过平时合作得太默契,我们根本没意识到它们是分开的。固定定位的元素在绘制时与祖先彻底断开联系,视觉上出走;可传播事件时,DOM 树上的那条树根还牢牢扎着。
所以你的眼里是一个悬浮在视口之上的独立浮层,事件系统里它却依然是父级节点的子孙。第一个点击能准确落到它上面,只是因为那一刻还没有谁启动指针捕捉。一旦父级调用了捕捉,后续所有移动事件都会被定向到父级,浮层再也收不到任何通知。这件事的发现过程本身就有点黑色幽默。
作者起初以为是个数学错误。裁剪手柄的计算涉及指针坐标映射到画布相对位置,再钳位后写回矩形框,一个符号错误就可能让框体冻结。于是他给每次移动打上日志,等鼠标一动就把计算出的分数吐出来。结果日志空如白纸。数学没算错,因为计算函数根本就没被调用。事情从这里开始变得不对劲。
不是数学,那就是事件没绑上。最容易犯的错,比如监听器挂错节点,或者绑定的事件名写错。跳进浏览器调试,按下鼠标的瞬间,浮层上的 pointerdown 处理器精确地触发了一次。事件通道是通的,按下那一刻没有掉链子。问题不在“按”,而在“按了之后”——捕获到了第一个动作,然后耳朵就彻底失聪了。
为验证这个猜想,作者在代码里加了两个计数器。一个统计浮层上收到的 pointerdown 次数,另一个统计它收到的 pointermove 次数。按下鼠标,慢慢划过一个又长又慢的弧线,几乎横跨半个屏幕,然后松开。计数结果很有意思:pointerdown 计数 1,pointermove 计数也是 1。手指动了那么远,屏幕上的裁剪框只收到了第一次移动事件。
之后所有信号全被截胡了。这不是挂掉的处理函数能演出来的戏码。处理函数要是坏了,要么一次都不来,要么全来。只来一次然后闭嘴,说明有另外一股力量在第一次移动之后,把指针从浮层手里抢走了。
在 Pointer Events 的世界里,能做到这件事的 API 只有一个,而且目的就是名正言顺地抢:setPointerCapture。作者想起来,就在同一个文件的几十行之外,自己还真就用了这个方法。画布区域需要拖拽平移,所以当用户在舞台上按下并移动时,调用 setPointerCapture 把指针捕获住,这样即便光标移出元素范围,平移手势也不会断。文档的解释毫不含糊:一旦调用了捕捉,后续的指针事件将全部定向到捕捉元素,直到捕捉被释放为止。不是在光标底下的元素,而是捕捉元素,优先级高于所有命中测试。
现在,布局上的罪魁祸首可以上场了。裁剪覆盖层使用的是固定定位,这种定位只做了一件事:在布局和绘制层面,把节点拎出正常流,按视口钉死位置,让它看起来仿佛独立于所有层之上。但它没有改变节点的 DOM 归属。在渲染树上,它确实飞升了;在事件树上,它依然是舞台元素的小辈,血缘关系没断。
这也解释了为什么第一个 pointerdown 能准确落在浮层上。因为按下那一刻还没有谁启动捕获,命中测试老老实实找到了最上面那个可见元素,正是它。但第一次移动事件一产生,舞台元素上的平移逻辑立刻抓住机会,调用 setPointerCapture 宣布主权。从这一刻起,所有后续的 pointermove 都被定向到舞台元素,固定定位的浮层再也不会收到任何移动通知。你看到的效果就是:手指按住裁剪框,拖拽的第二帧起,框彻底“冻结”。
所以整件事的根子在于:固定定位元素只在绘制上为自己赎了身,事件传播上仍是祖先的附庸。浏览器里面两套系统原本并行运转,绘制树决定你看到什么,事件树决定谁能响应交互。平时它们高度重合,你碰到哪个元素,事件几乎总是在同一个元素的 DOM 路径上传递,但一旦固定定位和指针捕捉的组合同时出现,裂缝就被撕开了。你在视觉层上看到一个独立悬浮的层,在事件层上它却只是个听话的子孙。祖先一伸手,事件就被收走,那块钢化玻璃般的浮层,一碰即锁。
热门跟贴