Selection API 与 getClientRects():从“选中文本”到“精准定位浮层/高亮”
在做富文本编辑器、划词翻译、批注系统、气泡菜单(Bold/Link)、代码阅读器高亮时,你经常会遇到两个问题:
- 我怎么拿到用户当前选中的文本范围?
- 我怎么把一个浮层(菜单/提示/按钮)“贴”在选区旁边,而且多行也要准?
答案通常就是:Selection API + Range.getClientRects()。
你可以把它理解为:
Selection 负责“描述用户选了哪里”,getClientRects() 负责“把选区变成一组可用于定位的矩形”。
1. 核心概念:Selection、Range、锚点与焦点
1.1 Selection 是什么?
window.getSelection() 会返回一个 Selection 对象(可能为 null)。它表示浏览器当前的“选区状态”,包含:
- 锚点(anchor):选区起点(用户按下鼠标或开始选择的位置)
- 焦点(focus):选区终点(用户松开鼠标的位置)
- 是否折叠(isCollapsed):是否只是一个光标(没有选中内容)
常用字段:
selection.anchorNode/selection.anchorOffsetselection.focusNode/selection.focusOffsetselection.isCollapsedselection.rangeCount
1.2 Range 是什么?
Range 表示文档中的一个“连续范围”(起点 + 终点),并且起点/终点永远按 DOM 顺序排列(与用户从左往右选还是从右往左选无关)。
常用方法:
selection.getRangeAt(0):取出第 0 个 rangerange.cloneRange():复制一个 range(用于不破坏原选区的计算)
规范允许一个
Selection同时包含多个Range,但多数浏览器只支持一个。实际工程中通常只处理getRangeAt(0)。
1.3 Selection / Range / Rects 的关系图
2. getClientRects():它到底返回了什么?
getClientRects() 返回一个 DOMRectList,里面是一组 DOMRect(矩形),每个矩形包含:
left/top/right/bottomwidth/height
并且这些坐标都是相对于视口(viewport)的 CSS 像素。
2.1 Element.getClientRects() vs Range.getClientRects()
它们名字一样,但语义不同:
element.getClientRects():返回该元素的 CSS 盒子碎片(比如一个inline元素跨多行,会有多个 rect)range.getClientRects():返回该 range 覆盖到的 选区碎片(多行选区通常会有多个 rect)
对比一下你会更好记:
| 你想测量什么 | 用哪个 API | 典型用途 |
|---|---|---|
| 一个元素在屏幕上的位置/碎片 | Element.getClientRects() | inline 标签多行定位、文本 fragment 标注 |
| 一段选区在屏幕上的位置/碎片 | Range.getClientRects() | 划词菜单、批注定位、多行高亮 |
2.2 getBoundingClientRect() 与 getClientRects() 怎么选?
getBoundingClientRect():返回“包围盒”(一个大矩形)getClientRects():返回“碎片矩形”(多个小矩形)
当选区跨多行时,包围盒可能很“虚胖”(把中间的空白也包进去),这时用 getClientRects() 更精准。
3. 实战:划词后把气泡菜单定位到选区末尾
目标:用户选中文本后,显示一个浮层菜单,并把它贴在选区末尾(更像常见的“复制/翻译”气泡)。
3.1 HTML/CSS(示意)
<div id="selection-bubble" class="bubble" hidden>复制</div>
.bubble {
position: absolute;
z-index: 9999;
padding: 6px 10px;
border-radius: 8px;
background: #111;
color: #fff;
font-size: 12px;
transform: translate(8px, 8px);
}
3.2 JS:用 Range.getClientRects() 找“最后一个矩形”
const bubble = document.querySelector('#selection-bubble');
function updateBubble() {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
bubble.hidden = true;
return;
}
const range = selection.getRangeAt(0);
const rects = Array.from(range.getClientRects()).filter(
(r) => r.width > 0 || r.height > 0,
);
if (rects.length === 0) {
bubble.hidden = true;
return;
}
// 多行选区:通常最后一个 rect 更接近用户“松手位置”
const last = rects[rects.length - 1];
const left = last.right + window.scrollX;
const top = last.bottom + window.scrollY;
bubble.style.left = `${left}px`;
bubble.style.top = `${top}px`;
bubble.hidden = false;
}
let rafId = 0;
document.addEventListener('selectionchange', () => {
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(updateBubble);
});
// 可选:点击空白处时隐藏
document.addEventListener('pointerdown', (e) => {
if (!bubble.contains(e.target)) bubble.hidden = true;
});
如果你的浮层是 position: fixed,就不要加 scrollX/scrollY(因为 DOMRect 已经是相对视口的)。
如果你的浮层是 position: absolute 放在 body 里,才需要加滚动偏移。
4. 实战:用 getClientRects() 做“多行高亮遮罩”
很多“批注/搜索命中高亮”的实现不是去改文字颜色,而是画一个覆盖层矩形(更灵活,支持圆角、动画、点击穿透)。
4.1 思路
- 取出
range.getClientRects() - 对每个 rect 画一个
div,绝对定位到对应区域 - overlay 容器
pointer-events: none,避免挡住正文交互
4.2 代码(简化示例)
function renderHighlight(range, container) {
container.innerHTML = '';
const rects = Array.from(range.getClientRects()).filter(
(r) => r.width > 0 && r.height > 0,
);
for (const rect of rects) {
const el = document.createElement('div');
el.style.position = 'absolute';
el.style.left = `${rect.left + window.scrollX}px`;
el.style.top = `${rect.top + window.scrollY}px`;
el.style.width = `${rect.width}px`;
el.style.height = `${rect.height}px`;
el.style.background = 'rgba(255, 230, 120, 0.55)';
el.style.borderRadius = '6px';
container.appendChild(el);
}
}
getClientRects() 会触发布局测量(layout),频繁调用可能导致卡顿。
建议:合并触发(rAF)、只在需要时测量、不要在滚动/输入的每个事件里无脑测。
5. 折叠选区(光标)定位:为什么有时拿不到 rect?
当 selection.isCollapsed === true(只有光标)时:
range.getClientRects()往往是空列表range.getBoundingClientRect()在不同场景/浏览器里表现不完全一致
5.1 一个实用的“测光标位置”思路(可能会短暂修改 DOM)
在可编辑区域(如 contenteditable)里,你可以插入一个临时标记来测量:
function getCaretRectFromCollapsedRange(selection) {
if (!selection || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
if (!range.collapsed) return range.getBoundingClientRect();
const marker = document.createElement('span');
marker.textContent = '\u200b'; // 零宽字符
const tempRange = range.cloneRange();
tempRange.insertNode(marker);
const rect = marker.getBoundingClientRect();
marker.remove();
// 恢复选区(避免插入/删除影响选中状态)
selection.removeAllRanges();
selection.addRange(range);
return rect;
}
这个方案会触发 DOM 变更(可能影响 MutationObserver、导致滚动抖动、触发输入法问题)。
更推荐在编辑器内部做(你能控制渲染与副作用),在纯阅读页谨慎使用。
6. 常见坑与最佳实践清单
6.1 selectionchange 事件太“吵”
selectionchange 可能在用户拖拽过程中频繁触发。建议:
- 用
requestAnimationFrame合并多次触发(上文已示例) - 或者只在
pointerup/keyup后再读取一次
6.2 选区可能不在你的容器里
如果页面上有多个可选区域(正文、侧边栏、代码块),你通常只想响应某一个区域的选区:
function isRangeInside(range, rootEl) {
const node = range.commonAncestorContainer;
const el = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
return !!el && rootEl.contains(el);
}
6.3 <input> / <textarea> 的选区不走 Selection API
文本框的选择应使用:
input.selectionStart/input.selectionEndinput.setSelectionRange(start, end)
而“选区矩形”则需要更复杂的“镜像文本测量”方案(把文本复制到隐藏容器并同步样式),不属于 Selection + getClientRects() 的直接覆盖范围。
6.4 坐标系别搞混
DOMRect:相对视口absolute浮层:通常需要+ scrollX/scrollYfixed浮层:通常不需要加滚动偏移
6.5 多行选区定位:末尾 rect 只是“经验法则”
在一些场景(比如用户从右向左反向选择),你可能更想贴近 focus 端。此时可以:
- 读取
selection.focusNode/focusOffset - 基于焦点端构造一个更小的 range 来测量(更精确,但实现更复杂)
面试高频问答
1)Selection 和 Range 的区别是什么?
Selection:浏览器当前的选区状态(可能为空、可能折叠、可能包含多个 Range)Range:文档中的一个具体范围(起点/终点按 DOM 顺序排列),可用于删除/插入/包裹内容,也能用于测量(getClientRects)
2)为什么多行选区用 getClientRects() 更合适?
因为 getBoundingClientRect() 只给一个大包围盒,多行时会把中间空白也包含进去;getClientRects() 返回每一行/碎片的矩形,更适合画高亮、把气泡贴到行尾。
3)DOMRect 的坐标是相对谁的?如何换算成页面坐标?
相对视口(viewport)。换算到页面坐标一般是:
pageX = rect.left + window.scrollXpageY = rect.top + window.scrollY
4)selectionchange 触发很频繁,怎么优化?
- 用
requestAnimationFrame合并多次触发(只在一帧内计算一次) - 或只在
pointerup/keyup等“结束选择”的时机计算
5)<textarea> 的选区能用 window.getSelection() 吗?
不行。<input>/<textarea> 的选区需要用 selectionStart/selectionEnd 系列 API;如果还想得到选区的屏幕矩形,需要额外的文本测量方案(镜像容器)。