JavaScript 获取鼠标坐标
每个教程都告诉你 clientX 存在。几乎没有一个告诉你为什么它和 offsetX 给出不同的数字——证据就在你面前。
实时演示:四个坐标系同时显示
在下方网格区域上移动鼠标。四个坐标系同时更新。这是理解它们差异的最快方法——你可以亲眼看到 screenX 比 clientX 大,因为它从物理显示器角落开始计算,而不是浏览器视口。
在此区域移动鼠标
pageY 如何增长,但 clientY 保持不变。基础:一分钟搞懂 mousemove
JavaScript 中所有的鼠标坐标都从 mousemove 事件开始。你绑定一个监听器,读取事件对象,就能获得 X 和 Y 值。这就是整个基础:
document.addEventListener('mousemove', function(e) {
console.log(e.clientX, e.clientY);
});
事件对象 e 携带一堆坐标属性。你实际会用到的有四个:offsetX、clientX、pageX 和 screenX(加上对应的 Y)。它们描述的是同一个物理鼠标位置,但从不同的参考点测量。
大多数教程犯的错误:展示 clientX 就草草了事。但当你构建真正的拖拽、画布绘图工具或提示框定位系统时,选错坐标属性会导致难以排查的隐蔽 bug。上面的演示就是让你再也不用猜。
offsetX vs clientX vs pageX vs screenX:该用哪个?
这是一张应该出现在每个教程页面上但没有的对比表。每个属性从不同的原点测量同一个光标位置:
| 属性 | 参考点 | 适用场景 | 滚动时变化? |
|---|---|---|---|
offsetX / offsetY | 目标元素(光标下方的元素)的 padding 边缘 | 悬停效果、元素内部交互 | 否 |
clientX / clientY | 浏览器视口的左上角 | 定位提示框、下拉菜单、模态框等相对于可见页面的元素 | 否 |
pageX / pageY | 整个文档的左上角(含已滚出视口的部分) | 画布绘制、锚定在滚动时保持不动的元素 | 是 |
screenX / screenY | 物理屏幕(显示器)的左上角 | 多窗口应用、浏览器扩展,普通网页开发很少需要 | 否 |
最容易让人栽跟头的:pageX 包含滚动偏移,clientX 不包含。如果你的页面有 3000px 高,你向下滚动了 1000px,那么在同一光标位置,pageY 会比 clientY 大约多 1000。上面的实时演示证明了这一点——滚动页面,观察差距。
经验法则:90% 的 UI 定位任务用 clientX/clientY。只关心光标下方的元素时用 offsetX。需要文档锚定坐标时(画布绘制、绝对定位元素)用 pageX。普通网页开发几乎不用 screenX。
getBoundingClientRect():你需要的万能工具
这是教程几乎不提的一点:JavaScript 中最有用的坐标函数根本不是鼠标事件属性,而是 Element.getBoundingClientRect()。它返回任意元素相对于视口的大小和位置:
var rect = element.getBoundingClientRect();
// rect.left, rect.top -- distance from viewport top-left
// rect.right, rect.bottom -- opposite edges
// rect.width, rect.height -- element dimensions
// All values include CSS border but NOT margin
真正的威力:用 e.clientX 减去 rect.left,就能得到鼠标相对于该元素的位置,不管它在页面的哪个位置。这就是构建可靠点击目标、画布绘图和拖拽区域的方法:
element.addEventListener('mousemove', function(e) {
var rect = this.getBoundingClientRect();
var localX = e.clientX - rect.left; // X relative to this element
var localY = e.clientY - rect.top; // Y relative to this element
console.log('Mouse inside element:', localX, localY);
});
这比 offsetX 在边缘情况下更可靠。当你的元素有子元素时,offsetX 可能突然跳变,因为参考点转移到了子元素。getBoundingClientRect() 方法始终给你相对于你真正关心的元素的坐标。
CSS 变换:坐标在哪儿静默出错
这一节来自真实的 bug 报告。如果你给元素加了 transform: scale() 或 transform: rotate(),坐标属性就开始对你撒谎——而且文档几乎不提这个。
transform: scale() 扭曲 offsetX
当你把元素缩放到 50%,浏览器缩小了视觉尺寸,但 offsetX 报告的是缩放后的坐标空间中的值。如果你的元素宽 200px 且缩放到 0.5,鼠标移到视觉中心时 offsetX = 50,而不是 100。getBoundingClientRect() 也返回缩放后的尺寸。
// WRONG: offsetX under scale() gives scaled coordinates
// If element is scaled to 0.5, offsetX is halved
element.style.transform = 'scale(0.5)';
element.addEventListener('mousemove', function(e) {
console.log(e.offsetX); // Reports in scaled space
});
// CORRECT: Use getBoundingClientRect for true visual position
element.addEventListener('mousemove', function(e) {
var rect = element.getBoundingClientRect();
var visualX = e.clientX - rect.left;
var visualY = e.clientY - rect.top;
// To get the unscaled coordinate:
var scaleX = element.offsetWidth / rect.width;
var realX = visualX * scaleX;
console.log('Unscaled X:', realX);
});
SVG 元素:offsetX 行为不同
在 SVG 中,offsetX/offsetY 可能参考 SVG 根元素或具体子形状,取决于浏览器。可靠的方法是 getBoundingClientRect() 或 createSVGPoint():
// SVG: use createSVGPoint for accurate coordinates
var svg = document.querySelector('svg');
var pt = svg.createSVGPoint();
svg.addEventListener('pointermove', function(e) {
pt.x = e.clientX;
pt.y = e.clientY;
// Convert to SVG user space:
var svgPoint = pt.matrixTransform(
svg.getScreenCTM().inverse()
);
console.log('SVG coords:', svgPoint.x, svgPoint.y);
});
Pointer Events:别再只用 mousemove
如果你还在为 mousemove、touchmove 和 touchstart 分别写处理函数,你在做三倍的工作却得到更差的结果。Pointer Events 把鼠标、触摸和触控笔统一到一个 API 里:
// Old way: three event types, three handlers
element.addEventListener('mousemove', handleMouse);
element.addEventListener('touchmove', handleTouch); // different event shape!
element.addEventListener('touchstart', handleTouchStart);
// Modern way: one event type
element.addEventListener('pointermove', function(e) {
// Same properties as MouseEvent:
console.log(e.clientX, e.clientY, e.pointerType);
// pointerType: 'mouse', 'touch', or 'pen'
});
关键优势:pointermove 对鼠标、手指和触控笔触发,使用完全相同的 clientX/clientY 接口。再也不用把 touches[0] 映射成假的鼠标事件。
| 事件 | 鼠标 | 触摸 | 触控笔 | 说明 |
|---|---|---|---|---|
mousemove | 是 | 否(模拟) | 否 | 传统方式,触摸会延迟模拟 mousemove |
touchmove | 否 | 是 | 否 | 使用 touches 数组,API 结构不同 |
pointermove | 是 | 是 | 是 | 统一 API,相同的坐标接口 |
Pointer Events 的浏览器支持已经普及(全球 97%+)。除非要支持非常旧的浏览器,pointermove 是正确选择。加 touch-action: none CSS 可以阻止浏览器劫持触摸手势用于滚动。
性能:节流 vs requestAnimationFrame
mousemove 事件在快速电脑上每秒可以触发超过 100 次。如果你的处理函数做 DOM 更新、布局计算或画布重绘,性能会崩溃。两种解决方案:节流和 requestAnimationFrame。
节流(限制每秒调用次数)
// Throttle: max one call per 16ms (~60fps)
var lastTime = 0;
element.addEventListener('mousemove', function(e) {
var now = Date.now();
if (now - lastTime < 16) return;
lastTime = now;
doExpensiveWork(e.clientX, e.clientY);
});
requestAnimationFrame(与浏览器重绘同步)
// rAF: batch updates to the next frame
var pendingEvent = null;
element.addEventListener('mousemove', function(e) {
pendingEvent = e; // Store latest, drop intermediates
});
function update() {
if (pendingEvent) {
doExpensiveWork(pendingEvent.clientX, pendingEvent.clientY);
pendingEvent = null;
}
requestAnimationFrame(update);
}
requestAnimationFrame(update);
哪个更好?requestAnimationFrame 在视觉更新方面更优,因为它与显示刷新率同步,并且在标签页不可见时自动暂停。节流更适合非视觉任务(如分析日志)。对于任何在屏幕上移动像素的操作,用 rAF。
实测影响
在处理器中做画布重绘的典型页面上:原始 mousemove 在快速移动时降到 12-15fps。节流到 60fps:流畅但在帧之间仍做冗余工作。用 rAF:保持 60fps 且零浪费渲染。在实际应用中差别肉眼可见。
生产代码:带边界限制的拖拽
这是当初我们希望拥有的拖拽实现。它使用 pointermove(统一鼠标/触摸),将元素限制在容器内,并正确清理监听器:
function makeDraggable(element, container) {
var isDragging = false;
var startX, startY, origLeft, origTop;
element.addEventListener('pointerdown', function(e) {
isDragging = true;
startX = e.clientX;
startY = e.clientY;
var rect = element.getBoundingClientRect();
origLeft = rect.left;
origTop = rect.top;
element.setPointerCapture(e.pointerId);
e.preventDefault();
});
element.addEventListener('pointermove', function(e) {
if (!isDragging) return;
var deltaX = e.clientX - startX;
var deltaY = e.clientY - startY;
var newLeft = origLeft + deltaX;
var newTop = origTop + deltaY;
// Boundary clamping: keep element inside container
var cRect = container.getBoundingClientRect();
var eRect = element.getBoundingClientRect();
var maxLeft = cRect.right - eRect.width;
var maxTop = cRect.bottom - eRect.height;
newLeft = Math.max(cRect.left, Math.min(newLeft, maxLeft));
newTop = Math.max(cRect.top, Math.min(newTop, maxTop));
element.style.left = newLeft + 'px';
element.style.top = newTop + 'px';
});
element.addEventListener('pointerup', function(e) {
isDragging = false;
element.releasePointerCapture(e.pointerId);
});
}
两点让它达到生产级别:setPointerCapture() 确保即使光标在拖拽中途离开元素,它仍然继续接收事件;边界限制使用 getBoundingClientRect() 来尊重实际渲染尺寸,而不是从 CSS 值猜测。
Canvas 坐标映射
在画布上绘图需要把鼠标坐标映射到画布像素空间。当画布的 CSS 尺寸与内部分辨率不同时(HiDPI 显示器应该如此),简单的方法会出错:
var canvas = document.getElementById('my-canvas');
var ctx = canvas.getContext('2d');
// Set internal resolution for HiDPI
var dpr = window.devicePixelRatio || 1;
var rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
canvas.addEventListener('pointermove', function(e) {
var rect = canvas.getBoundingClientRect();
// CSS pixel coordinates (what you draw with):
var cssX = e.clientX - rect.left;
var cssY = e.clientY - rect.top;
// If you need raw canvas pixels:
var pixelX = cssX * dpr;
var pixelY = cssY * dpr;
ctx.fillRect(cssX - 2, cssY - 2, 4, 4); // Draw at cursor
});
dpr(设备像素比)缩放在 Retina/HiDPI 显示器上必不可少。没有它,你的画布绘制会出现模糊,点击坐标也会略有偏差。我们在 物理像素与逻辑像素指南 中深入讲解。
常见问题:常见坑
为什么 offsetX 在鼠标经过子元素时会跳变?
offsetX 相对于光标正下方的元素。当你从父元素移到子元素时,参考点会跳到子元素的角落。用 e.clientX - element.getBoundingClientRect().left 可以获得稳定的元素相对坐标。
鼠标坐标在 iframe 里能正常工作吗?
可以,但 screenX/screenY 报告的是相对于 iframe 在父页面中位置的值,不是真实显示器。clientX 和 pageX 在 iframe 自己的文档内正常工作。跨框架坐标转换需要 postMessage 通信。
Shadow DOM 里的坐标会怎样?
鼠标事件在跨越 Shadow DOM 边界时会重定向目标。e.target 可能指向 shadow host 而非内部元素。event.composedPath() 可以获取穿过 shadow 边界的真实事件路径。offsetX/offsetY 参考 shadow host,不是内部元素。
父元素的 CSS 变换会影响坐标吗?
会。getBoundingClientRect() 返回视觉变换后的位置,所以如果父元素有 transform: rotate(45deg),rect 会反映旋转后的布局。clientX/clientY 始终在视口空间中,不受变换影响,但元素相对于这些坐标的位置会变。
movementX/movementY 是什么?
这些属性报告的是自上次 mousemove 事件以来的增量(变化量),不是绝对位置。它们在实现指针锁定(FPS 风格的相机控制)等场景时很有用,你关心的是鼠标移动了多远,而不是它在哪里。
应该用 pageX 还是 clientX?
大多数 UI 定位(提示框、模态框、弹出层)用 clientX,因为它忽略滚动偏移。当你需要在滚动变化后记住一个位置时(比如在长文档上标记一个点)用 pageX。差别只是滚动偏移:pageX = clientX + window.scrollX。