Range 接口表示一个包含节点与文本节点的一部分的文档片段。(——MDN官网的定义)
Selection 和 Range 都是现代浏览器原生提供的对象和 API,一般通称为 Selection 和 Range API(接口)。Selection(选区)是“视图状态”(用户在屏幕上高亮了什么),Range(范围)是“数据模型”(描述选了什么)。换言之,Range 是 Selection 的基本概念,Range 的本质是对一对“边界点”的描述——范围的起点和范围的终点。通俗说来,Selection 是用户“拖蓝”的视觉效果,Range 则是“拖蓝”背后的数据描述;另外,很重要的,Selection 和 Range 的关系是包含关系,具体而言,Selection 是 Range 的容器,尽管有时候 Range 可以不依托显性提供的 Selection 而独立存在。
通常,Selection 和 Range 总是一同出现的:先获取 Selection 对象,然后通过 Range 来做什么。其一,使用 Range 读取基于 Selection 的相关数据(例如锚点和锚点偏移量、焦点和焦点偏移量等),其二,创建新的范围并添加到 Selection 对象中。不过,Range 在特定场合可以不依赖 Selection,可以独立使用。学习 Range 需要了解其构造、属性和方法。
一、Range的获取和构造
// 获取选区 Range(参数为选区序号)
Selection.getRangeAt(index)
// 创建 Range 方法一
document.createRange()
// 创建 Range 方法二
new Range()
二、Range的常用属性
- collapsed : 只读,返回 Range 的起始位置和终止位置是否相同(true/false)
- commonAncestorContainer :只读,返回完整包含 startContainer 和 endContainer 的最深一级的节点
- endContainer :只读,返回包含 Range 终点的节点
- endOffset :只读,返回 Range 终点在 endContainer 中的位置
- startContainer :只读,返回包含 Range 开始的节点
- startOffset :只读,返回 Range 在 startContainer 中的起始位置
三、Range的常用方法
- collapse() :将 Range 折叠到其边界的端点
- compareBoundaryPoints() :将 Range 的边界与另一个 Range 的边界进行比较
- compareNode() :返回一个常量,表示节点是否在 Range 的前、后、中、外
- comparePoint() :返回 -1、0 或 1,分别表示端点在 Range 之前、内部还是之后
- cloneContents() :返回一个复制 Range 中所有节点的文档片段
- cloneRange() :返回一个拥有和原 Range 对象相同端点的克隆的 Range 对象
- createContextualFragment() :返回从给定的代码字符串创建的文档片段
- deleteContents() :从 Document 中移除 Range 内容
- detach() :将 Range 从使用状态释放,提高性能
- extractContents() :将 Range 的内容从文档树移动到一个文档片段
- getBoundingClientRect() :返回一个 DOMRect 对象,其绑定了 Range 的整个内容;这将是通过 range.getClientRects() 返回所有边界矩形集合的其中之一
- getClientRects() :返回一个 DOMRect 列表对象,该列表汇总了 Range 中所有元素的 Element.getClientRects() 返回结果
- isPointInRange() :返回一个 boolean,表示点端点是否在 Range 中
- insertNode() :在 Range 开头插入一个节点
- intersectsNode() :返回一个 boolean,表示给定的节点是否与 Range 相交
- selectNode() :设置 Range 包含某个节点及其他的内容
- selectNodeContents() :设置 Range 包含某个节点的内容
- setEnd() :设置 Range 的终点
- setStart() :设置 Range 的起点
- setEndAfter() :以另一个节点为基准,设置 Range 的终点位置
- setEndBefore() :以另一个节点为基准,设置 Range 的终点位置
- setStartAfter() :以另一个节点为基准,设置 Range 的起点位置
- setStartBefore() :以另一个节点为基准,设置 Range 的起点位置
- surroundContents() :将 Range 中的内容移动到一个新的节点
- toString() :返回 Range 中的文本
四、应用实例
【例一】在简单文本节点中创建选区范围并“拖蓝”
<!-- 下面的 p 标签仅包含一个 #Text 节点,本例目标是将 “花朝论坛” 拖蓝 -->
<p id="p1">花朝论创建于哪一年?</p>
<p><button onclick="selectText(p1,0,4)">创建选区(0~4)</button></p>
<script>
// 创建选区函数(仅适用于理想型文本节点 —— #Text节点)
// 参数 start :选区范围起点位置;end :选区范围结束位置
// 例如,0~4表示选取范围从文本节点第一个字符的左边起算,到第4个字符的右边结束
function selectText(elm, start, end) {
const range = new Range(); // 创建范围
const node = p1.firstChild; // 指定节点(p1的第一个文本节点)
range.setStart(node, start); // 范围从 node 节点的第 start 位置开始
range.setEnd(node, end); // 范围从 node 节点的第 end 偏移量结束
const sel = window.getSelection(); // 获取选区对象
sel.removeAllRanges(); // 清除选区之前已有全部 Range 范围
sel.addRange(range); // 在选区中添加新创建的 Range
}
</script>
当确定待处理的节点是文本节点(#text),Range 的 setStart(节点, 位置) 和 setEnd(节点, 位置) 方法中的位置参数以字符索引做参照,0 表示节点内第一个字符的前面的位置(就像在新建一个Word文档时输入光标的位置),1 表示节点内第一个字符和第二个字符之间的位置,2 表示第二个字符和第三个字符之间的位置,其它以此类推。
【例二】在复杂的混合节点环境中创建选区
<!-- 下面的 p 标签有文本节点和元素节点,环境复杂,共 5 个子节点 -->
<!-- 节点索引0. #text : 才思敏捷、多才多艺的 -->
<!-- 节点索引1. B :<b>樵歌</b> -->
<!-- 节点索引2. #text :是 -->
<!-- 节点索引3. MARK :<mark>花潮论坛</mark> -->
<!-- 节点索引4. #text :的联合创建者之一 -->
<p id="p2">才思敏捷、多才多艺的<b>樵歌</b>是<mark>花潮论坛</mark>的联合创建者之一</p>
<p><button onclick="selectText(p2,0,4)">创建选区(0~4)</button></p>
<script>
// 创建选区函数(适用于混合节点环境,若子节点是#text会全选)
// 参数 start :选区范围起点节点;end :选区范围结束节点(数孩子节点索引而不是字符索引)
// 例如,0~4表示选取范围从0号索引节点(对应第一个孩子节点)前起算,
// 到第4个孩子节点(对应第五个孩子节点)之前结束
function selectText(elm, start, end) {
const sel = window.getSelection(); // 获取选区对象
const range = new Range(); // 创建范围
const total = p2.childNodes.length; // 获取子节点总数
//console.log(total); // -> 5
if (end > total) end = total; // 结束节点索引号不能超出总子节点数量
range.setStart(p2, start); // 范围从 p2 中索引号号是第 start 孩子节点前开始
range.setEnd(p2, end); // 范围从 p2 中索引号是第 end 孩子节点前结束
sel.removeAllRanges(); // 清除选区已有全部 Range 范围
sel.addRange(range); // 添加新创建的 Range
}
</script>
如果确定容器内的子节点为混合节点,对子节点的处理变为复杂,Range 的 setStart 和 setEnd 方法的位置参数表示的是子节点在容器中的索引序号,序号从 0 开始,换言之,位置参数不是指代字符索引,而是指向子节点序号。这是 Selection + Range API的浏览器底层设计。
如果需要精准按字符索引实现拖蓝或读取拖蓝数据,需要遍历所有子节点、找出最底层的 #text 并通过相应算法加以实现。
【例三】利用 Range 在指定节点前添加新的文本节点
<style>
#mdiv { margin: 20px auto 0; width: 600px; font-size: 30px; border: 1px solid gray; padding: 12px; }
</style>
<div id="mdiv" title="点击补全句子">只怕有心人</div>
<script>
const mdiv = document.getElementById('mdiv');
mdiv.onclick = () => {
//console.log(mdiv.childNodes.length); // -> 1
const txt = '世上无难事 '; // 待创建文本节点内容
if (mdiv.innerText.includes(txt)) return; // 避免重复补全
const txtNode = document.createTextNode(txt); // 创建文本节点
const range = new Range(); // 创建新 Range 对象
range.selectNode(mdiv.firstChild); // 选择 mdiv 容器第一个子节点为 range
range.insertNode(txtNode); // range 前插入新创建的文本节点
//console.log(mdiv.childNodes.length); // -> 2
};
</script>
Range 的 selectNode() 方法将 Range 设置为包含整个 Node 及其内容。insertNode() 方法在 Range 的起始位置插入新的节点。
【例四】借助 Range 创建范围删除表格指定行
<style>
#tab { width: 600px; border-collapse: collapse; }
#tab th, #tab td { border: 1px solid #ccc; padding: 8px; }
#tab th { background: #ddd; }
</style>
<p><button onclick="delTabRow();">删除表格的最后一行</button></p>
<table id="tab">
<tr>
<th>表头单元格一</th>
<th>表头单元格二</th>
</tr>
<tr>
<td>内容1</td>
<td>内容2</td>
</tr>
<tr>
<td>内容3</td>
<td>内容4</td>
</tr>
<tr>
<td>内容5</td>
<td>内容6</td>
</tr>
<tr>
<td>内容7</td>
<td>内容8</td>
</tr>
</table>
<script>
// 删除表格最后一行
function delTabRow() {
const tab = document.getElementById('tab'); // 获得表格dom实体
const rest = tab.rows.length - 1; // 最后一行
if (rest <= 0) return; // 如果表格仅剩一行放过(不执行下面的代码)
const row = tab.rows[rest]; // 获取表格的第后一行
const range = document.createRange(); // 创建 Range 范围
range.setStartBefore(row); // Range 起点设在表格最后一行的前边
range.setEndAfter(row); // Range 终点设在表格最后一行的后边
range.deleteContents(); // 删除 Range 范围包裹的内容(即 <tr>...</tr>
}
</script>
Range 对象的 setStartBefore(节点) 和 setStartAfter(节点) 方法在指定节点前、后创建范围,deleteContents() 方法则删除范围内的内容。
【例五】搬运元素节点
<style>
.mydiv { margin: 20px; padding: 12px; border: 1px solid gray; width: 600px; min-height: 100px; }
.mydiv > p { margin: 0; padding: 0; }
</style>
<h2>单击第一个方框</h2>
<div id="d1" class="mydiv" onclick="moveline();">
<p>红豆生南国,</p>
<p>春来发几枝。</p>
<p>愿君多采撷,</p>
<p>此物最相思。</p>
</div>
<div id="d2" class="mydiv"></div>
<script>
const rawHTML = d1.innerHTML; // d1 原始代码
// 逐行移动p标签到另一个方框
function moveline() {
let lock = false; // 搬运锁
// 如果搬完了
if (d1.innerHTML.trim() === '') {
d1.innerHTML = rawHTML; // 重置 d1 内容
d2.innerHTML = ''; // d2 清空
lock = true; // 上锁
}
if (lock) return; // 上锁状态下不执行下面的任务
// 核心 :将 d1 的第一个 p 标签移动到 d2
const ps = d1.querySelectorAll('p'); // 获得所有 p 标签节点
if (ps.length < 1) return; // p 标签没了就不要继续干活(会犯错误)
const range = document.createRange(); // 创建 Range 范围
range.selectNode(ps[0]); // Range 选择第一个 p 节点
const frg = range.extractContents(); // 提取 p 节点内容(会从原始宿主中删除节点)加入文档碎片
d2.appendChild(frg); // 文档碎片追加给 d2
}
</script>
Range 的 extractContents() 方法提取 Range 范围内的节点(可以是文本或元素,例如上例的第一个 p 标签),并删除原始范围,返回的是文档碎片(相当于 DocumentFragment API 生成的碎片),该碎片可以追加给另外的目标元素。
Range 方法较多并且基本都很抽象、复杂,不过学习难度不在其所提供的方法自身,这些方法通过潜心学习并不断实践总能融会贯通,难的是编程方法——比如如何将相关知识有机结合起来去实现预定目标。
本文提供的几个实例不可能覆盖所有的 Range 知识,若能从中获得一些启发和灵感便达到本文的目的。