JavaScript 获取鼠标坐标

每个教程都告诉你 clientX 存在。几乎没有一个告诉你为什么它和 offsetX 给出不同的数字——证据就在你面前。

实时演示:四个坐标系同时显示

在下方网格区域上移动鼠标。四个坐标系同时更新。这是理解它们差异的最快方法——你可以亲眼看到 screenXclientX 大,因为它从物理显示器角落开始计算,而不是浏览器视口。

在此区域移动鼠标

0
0
0
0
0
0
0
0
试着把页面滚动到下方再移动鼠标——观察 pageY 如何增长,但 clientY 保持不变。

基础:一分钟搞懂 mousemove

JavaScript 中所有的鼠标坐标都从 mousemove 事件开始。你绑定一个监听器,读取事件对象,就能获得 X 和 Y 值。这就是整个基础:

document.addEventListener('mousemove', function(e) {
  console.log(e.clientX, e.clientY);
});

事件对象 e 携带一堆坐标属性。你实际会用到的有四个:offsetXclientXpageXscreenX(加上对应的 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

如果你还在为 mousemovetouchmovetouchstart 分别写处理函数,你在做三倍的工作却得到更差的结果。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 在父页面中位置的值,不是真实显示器。clientXpageX 在 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

继续探索: 现在你了解了 JavaScript 方面的知识,在不同的场景中看看这些坐标的实际应用:

鼠标位置检测器 — 实时显示四个坐标系并支持点击捕获的工具

如何查找像素坐标 — Windows、macOS、Linux 和浏览器中的平台方法

物理像素与逻辑像素 — 为什么 CSS 像素和设备像素坐标不同

屏幕坐标指南 — 屏幕坐标概念完整参考

返回首页