DOM 变动观察器 Mutation observer
MutationObserver
是一个内建对象,它观察 DOM 元素,并在检测到更改时触发回调。
我们将首先看一下语法,然后探究一个实际的用例,以了解它在什么地方有用。
语法
MutationObserver
使用简单。
首先,我们创建一个带有回调函数的观察器:
let observer = new MutationObserver(callback);
然后将其附加到一个 DOM 节点:
observer.observe(node, config);
config
是一个具有布尔选项的对象,该布尔选项表示“将对哪些更改做出反应”:
childList
——node
的直接子节点的更改,subtree
——node
的所有后代的更改,attributes
——node
的特性(attribute),attributeFilter
—— 特性名称数组,只观察选定的特性。characterData
—— 是否观察node.data
(文本内容),
其他几个选项:attributeOldValue
—— 如果为true
,则将特性的旧值和新值都传递给回调(参见下文),否则只传新值(需要attributes
选项),characterDataOldValue
—— 如果为true
,则将node.data
的旧值和新值都传递给回调(参见下文),否则只传新值(需要characterData
选项)。
然后,在发生任何更改后,将执行“回调”:更改被作为一个 MutationRecord 对象列表传入第一个参数,而观察器自身作为第二个参数。
MutationRecord 对象具有以下属性:type
—— 变动类型,以下类型之一:"attributes"
:特性被修改了,"characterData"
:数据被修改了,用于文本节点,"childList"
:添加/删除了子元素。
target
—— 更改发生在何处:"attributes"
所在的元素,或"characterData"
所在的文本节点,或"childList"
变动所在的元素,addedNodes/removedNodes
—— 添加/删除的节点,previousSibling/nextSibling
—— 添加/删除的节点的上一个/下一个兄弟节点,attributeName/attributeNamespace
—— 被更改的特性的名称/命名空间(用于 XML),oldValue
—— 之前的值,仅适用于特性或文本更改,如果设置了相应选项attributeOldValue
/characterDataOldValue
。
例如,这里有一个<div>
,它具有contentEditable
特性。该特性使我们可以聚焦和编辑元素。
<div contentEditable id="elem">Click and <b>edit</b>, please</div>
<script>
let observer = new MutationObserver(mutationRecords => {
console.log(mutationRecords); // console.log(the changes)
});
// 观察除了特性之外的所有变动
observer.observe(elem, {
childList: true, // 观察直接子节点
subtree: true, // 及其更低的后代节点
characterDataOldValue: true // 将旧的数据传递给回调
});
</script>
如果我们在浏览器中运行上面这段代码,并聚焦到给定的 <div>
上,然后更改 <b>edit</b>
中的文本,console.log
将显示一个变动:
mutationRecords = [{
type: "characterData",
oldValue: "edit",
target: <text node>,
// 其他属性为空
}];
如果我们进行更复杂的编辑操作,例如删除 <b>edit</b>
,那么变动事件可能会包含多个变动记录:
mutationRecords = [{
type: "childList",
target: <div#elem>,
removedNodes: [<b>],
nextSibling: <text node>,
previousSibling: <text node>
// 其他属性为空
}, {
type: "characterData"
target: <text node>
// ...变动的详细信息取决于浏览器如何处理此类删除
// 它可能是将两个相邻的文本节点 "edit " 和 ", please" 合并成一个节点,
// 或者可能将它们留在单独的文本节点中
}];
因此,MutationObserver
允许对 DOM 子树中的任何更改作出反应。
用于集成
在什么时候可能有用?
想象一下,你需要添加一个第三方脚本,该脚本不仅包含有用的功能,还会执行一些我们不想要的操作,例如显示广告 <div class="ads">Unwanted ads</div>
。
当然,第三方脚本没有提供删除它的机制。
使用 MutationObserver
,我们可以监测到我们不需要的元素何时出现在我们的 DOM 中,并将其删除。
还有一些其他情况,例如第三方脚本会将某些内容添加到我们的文档中,并且我们希望检测出这种情况何时发生,以调整页面,动态调整某些内容的大小等。
MutationObserver
使我们能够实现这种需求。
用于架构
从架构的角度来看,在某些情况下,MutationObserver
有不错的作用。
假设我们正在建立一个有关编程的网站。自然地,文章和其他材料中可能包含源代码段。
在 HTML 标记(markup)中的此类片段如下所示:
...
<pre class="language-javascript"><code>
// 这里是代码
let hello = "world";
</code></pre>
...
为了提高可读性,同时对其进行美化,我们将在我们的网站上使用 JavaScript 语法高亮显示库,例如 Prism.js。为了使用 Prism 对以上代码片段进行语法高亮显示,我们调用了 Prism.highlightElem(pre)
,它会检查此类 pre
元素的内容,并为这些元素添加特殊的标签(tag)和样式,以进行彩色语法高亮显示,类似于你在本文的示例中看到的那样。
那么,我们应该在什么时候执行该高亮显示方法呢?我们可以在 DOMContentLoaded
事件中执行,或者将脚本放在页面的底部。DOM 就绪后,我们可以搜索元素 pre[class*="language"]
并对其调用 Prism.highlightElem
:
// 高亮显示页面上的所有代码段
document.querySelectorAll('pre[class*="language"]').forEach(Prism.highlightElem);
到目前为止,一切都很简单,对吧?我们找到 HTML 中的代码片段并高亮显示它们。
现在让我们继续。假设我们要从服务器动态获取资料。我们将 在本教程的后续章节 中学习进行此操作的方法。目前,只需要关心我们从网络服务器获取 HTML 文章并按需显示:
let article = /* 从服务器获取新内容 */
articleElem.innerHTML = article;
新的 article
HTML 可能包含代码段。我们需要对其调用 Prism.highlightElem
,否则它们将不会被高亮显示。
对于动态加载的文章,应该在何处何时调用 Prism.highlightElem
?
我们可以将该调用附加到加载文章的代码中,如下所示:
let article = /* 从服务器获取新内容 */
articleElem.innerHTML = article;
let snippets = articleElem.querySelectorAll('pre[class*="language-"]');
snippets.forEach(Prism.highlightElem);
……但是,想象一下,如果代码中有很多地方都是在加载内容:文章,测验和论坛帖子等。我们是否需要在每个地方都附加一个高亮显示调用,以在内容加载完成后,高亮内容中的代码。那很不方便。
并且,如果内容是由第三方模块加载的,该怎么办?例如,我们有一个由其他人编写的论坛,该论坛可以动态加载内容,并且我们想为其添加语法高亮显示。没有人喜欢修补第三方脚本。
幸运的是,还有另一种选择。
我们可以使用 MutationObserver
来自动检测何时在页面中插入了代码段,并高亮显示它们。
因此,我们在一个地方处理高亮显示功能,从而使我们无需集成它。
动态高亮显示示例
这是一个工作示例。
如果你运行这段代码,它将开始观察下面的元素,并高亮显示现在此处的所有代码段:
let observer = new MutationObserver(mutations => {
for(let mutation of mutations) {
// 检查新节点,有什么需要高亮显示的吗?
for(let node of mutation.addedNodes) {
// 我们只跟踪元素,跳过其他节点(例如文本节点)
if (!(node instanceof HTMLElement)) continue;
// 检查插入的元素是否为代码段
if (node.matches('pre[class*="language-"]')) {
Prism.highlightElement(node);
}
// 或者可能在子树的某个地方有一个代码段?
for(let elem of node.querySelectorAll('pre[class*="language-"]')) {
Prism.highlightElement(elem);
}
}
}
});
let demoElem = document.getElementById('highlight-demo');
observer.observe(demoElem, {childList: true, subtree: true});
用法:
let demoElem = document.getElementById('highlight-demo');
// 动态插入带有代码段的内容
demoElem.innerHTML = `下面是一个代码段:
<pre class="language-javascript"><code> let hello = "world!"; </code></pre>
<div>另一个代码段:</div>
<div>
<pre class="language-css"><code>.class { margin: 5px; } </code></pre>
</div>
`;
现在我们有了 MutationObserver
,它可以跟踪观察到的元素中的,或者整个 document
中的所有高亮显示。我们可以在 HTML 中添加/删除代码段,而无需考虑高亮问题。
其他方法
有一个方法可以停止观察节点:
observer.disconnect()
—— 停止观察。
当我们停止观察时,观察器可能尚未处理某些更改。在种情况下,我们使用:observer.takeRecords()
—— 获取尚未处理的变动记录列表,表中记录的是已经发生,但回调暂未处理的变动。
这些方法可以一起使用,如下所示:
// 如果你关心可能未处理的近期的变动
// 那么,应该在 disconnect 前调用获取未处理的变动列表
let mutationRecords = observer.takeRecords();
// 停止跟踪变动
observer.disconnect();
...
observer.takeRecords()
返回的记录被从处理队列中移除
回调函数不会被observer.takeRecords()
返回的记录调用。
垃圾回收
观察器在内部对节点使用弱引用。也就是说,如果一个节点被从 DOM 中移除了,并且该节点变得不可访问,那么它就可以被垃圾回收。
观察到 DOM 节点这一事实并不能阻止垃圾回收。
选择 Selection 和范围 Range
在本章中,我们将介绍文档中的选择以及在表单字段(如 <input>
)中的选择。
JavaScript 可以访问现有的选择,选择/取消全部或部分 DOM 节点的选择,从文档中删除所选部分,将其包装到一个标签(tag)中,等。
你可以在本章末尾的“总结”部分找到一些常见的使用方式。可能就已经满足了你当前的需求,但如果你阅读全文,将会有更多收获。
底层的(underlying)Range
和 Selection
对象很容易掌握,因此,你不需要任何诀窍便可以使用它们做你想要做的事儿。
范围
选择的基本概念是 Range:本质上是一对“边界点”:范围起点和范围终点。
在没有任何参数的情况下,创建一个 Range
对象:
let range = new Range();
然后,我们可以使用 range.setStart(node, offset)
和 range.setEnd(node, offset)
来设置选择边界。
正如你可能猜到的那样,我们将进一步使用 Range
对象进行选择,但首先让我们创建一些这样的对象。
选择部分文本
有趣的是,这两种方法中的第一个参数 node
都可以是文本节点或元素节点,而第二个参数的含义依赖于此。
如果 node
是一个文本节点,那么 offset
则必须是其文本中的位置。
例如,对于给定的 <p>Hello</p>
,我们可以像下面这样创建一个包含字母 “ll” 的范围:
<p id="p">Hello</p>
<script>
let range = new Range();
range.setStart(p.firstChild, 2);
range.setEnd(p.firstChild, 4);
// 对 range 进行 toString 处理,range 则会把其包含的内容以文本的形式返回
console.log(range); // ll
</script>
在这里,我们获取 <p>
的第一个子节点(即文本节点)并指定其中的文本位置:
选择元素节点
或者,如果 node
是一个元素节点,那么 offset
则必须是子元素的编号。
这对于创建包含整个节点的范围很方便,而不是在其文本中的某处停止。
例如,我们有一个更复杂的文档片段:
<p id="p">Example: <i>italic</i> and <b>bold</b></p>
这是它的 DOM 结构,包含元素和文本节点:
让我们为
"Example: <i>italic</i>"
设置一个范围。正如我们所看到的,这个短语正好由
<p>
的索引为 0
和 1
的两个子元素组成。- 起点以
<p>
作为父节点node
,0
作为偏移量。
因此,我们可以将其设置为range.setStart(p, 0)
。 - 终点也是以
<p>
作为父节点node
,但以2
作为偏移量(它指定最大范围,但不包括offset
)。
因此,我们可以将其设置为range.setEnd(p, 2)
。
示例如下,如果你运行它,你可以看到文本被选中:
<p id="p">Example: <i>italic</i> and <b>bold</b></p>
<script>
let range = new Range();
range.setStart(p, 0);
range.setEnd(p, 2);
// 范围的 toString 以文本形式返回其内容,不带标签
console.log(range); // Example: italic
// 将此范围应用于文档选择,后文有解释
document.getSelection().addRange(range);
</script>
起始和结束的节点可以不同
我们不是必须在setStart
和setEnd
中使用相同的节点。一个范围可能会跨越很多不相关的节点。唯一要注意的是终点要在起点之后。
选择更大的片段
让我们在示例中选择一个更大的片段,像这样:
我们已经知道如何实现它了。我们只需要将起点和终点设置为文本节点中的相对偏移量即可。
我们需要创建一个范围,它:
- 从
<p>
的第一个子节点的位置 2 开始(选择 "Example: " 中除前两个字母外的所有字母) - 到
<b>
的第一个子节点的位置 3 结束(选择 “bold” 的前三个字母,就这些):
<p id="p">Example: <i>italic</i> and <b>bold</b></p>
<script>
let range = new Range();
range.setStart(p.firstChild, 2);
range.setEnd(p.querySelector('b').firstChild, 3);
console.log(range); // ample: italic and bol
// 使用此范围进行选择(后文有解释)
window.getSelection().addRange(range);
</script>
正如你所看到的,选择我们想要的范围其实很容易实现。
如果我们想将节点作为一个整体,我们可以将元素传入 setStart/setEnd
。否则,我们可以在文本层级上进行操作。
range 属性
我们在上面的示例中创建的 range
对象具有以下属性:
startContainer
,startOffset
—— 起始节点和偏移量,- 在上例中:分别是
<p>
中的第一个文本节点和2
。
- 在上例中:分别是
endContainer
,endOffset
—— 结束节点和偏移量,- 在上例中:分别是
<b>
中的第一个文本节点和3
。
- 在上例中:分别是
collapsed
—— 布尔值,如果范围在同一点上开始和结束(所以范围内没有内容)则为true
,- 在上例中:
false
- 在上例中:
commonAncestorContainer
—— 在范围内的所有节点中最近的共同祖先节点,- 在上例中:
<p>
- 在上例中:
选择范围的方法
有许多便利的方法可以操纵范围。
我们已经见过了 setStart
和 setEnd
,这还有其他类似的方法。
设置范围的起点:
setStart(node, offset)
将起点设置在:node
中的位置offset
setStartBefore(node)
将起点设置在:node
前面setStartAfter(node)
将起点设置在:node
后面
设置范围的终点(类似的方法):setEnd(node, offset)
将终点设置为:node
中的位置offset
setEndBefore(node)
将终点设置为:node
前面setEndAfter(node)
将终点设置为:node
后面
从技术上讲,setStart/setEnd
可以做任何事,但是更多的方法提供了更多的便捷性。
在所有这些方法中,node
都可以是文本或者元素节点:对于文本节点,偏移量offset
跨越的是很多字母,而对于元素节点则跨越的是很多子节点。
更多创建范围的方法:selectNode(node)
设置范围以选择整个node
selectNodeContents(node)
设置范围以选择整个node
的内容collapse(toStart)
如果toStart=true
则设置 end=start,否则设置 start=end,从而折叠范围cloneRange()
创建一个具有相同起点/终点的新范围
编辑范围的方法
创建范围后,我们可以使用以下方法操作其内容:
deleteContents()
—— 从文档中删除范围中的内容extractContents()
—— 从文档中删除范围中的内容,并将删除的内容作为 DocumentFragment 返回cloneContents()
—— 复制范围中的内容,并将复制的内容作为 DocumentFragment 返回insertNode(node)
—— 在范围的起始处将node
插入文档surroundContents(node)
—— 使用node
将所选范围中的内容包裹起来。要使此操作有效,则该范围必须包含其中所有元素的开始和结束标签:不能像<i>abc
这样的部分范围。
使用这些方法,我们基本上可以对选定的节点执行任何操作。
点击按钮运行所选内容上的方法,点击 "resetExample" 进行重置。
<p id="p">Example: <i>italic</i> and <b>bold</b></p>
<p id="result"></p>
<script>
let range = new Range();
// 下面演示了上述的每个方法:
let methods = {
deleteContents() {
range.deleteContents()
},
extractContents() {
let content = range.extractContents();
result.innerHTML = "";
result.append("extracted: ", content);
},
cloneContents() {
let content = range.cloneContents();
result.innerHTML = "";
result.append("cloned: ", content);
},
insertNode() {
let newNode = document.createElement('u');
newNode.innerHTML = "NEW NODE";
range.insertNode(newNode);
},
surroundContents() {
let newNode = document.createElement('u');
try {
range.surroundContents(newNode);
} catch(e) { console.log(e) }
},
resetExample() {
p.innerHTML = `Example: <i>italic</i> and <b>bold</b>`;
result.innerHTML = "";
range.setStart(p.firstChild, 2);
range.setEnd(p.querySelector('b').firstChild, 3);
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
}
};
for(let method in methods) {
document.write(`<div><button onclick="methods.${method}()">${method}</button></div>`);
}
methods.resetExample();
</script>
还有比较范围的方法,但是很少使用。当你需要它们时,请参考 规范 或 MDN 手册。
选择
Range
是用于管理选择范围的通用对象。尽管创建一个 Range
并不意味着我们可以在屏幕上看到一个内容选择。
我们可以创建 Range
对象并传递它们 —— 但它们并不会在视觉上选择任何内容。
文档选择是由 Selection
对象表示的,可通过 window.getSelection()
或 document.getSelection()
来获取。一个选择可以包括零个或多个范围。至少,Selection API 规范 是这么说的。不过实际上,只有 Firefox 允许使用 Ctrl+click (Mac 上用 Cmd+click) 在文档中选择多个范围。
其他浏览器最多支持 1 个范围。正如我们将看到的,某些 Selection
方法暗示可能有多个范围,但同样,在除 Firefox 之外的所有浏览器中,范围最多是 1。
选择属性
如前所述,理论上一个选择可能包含多个范围。我们可以使用下面这个方法获取这些范围对象:
getRangeAt(i)
—— 获取第i
个范围,i
从0
开始。在除 Firefox 之外的所有浏览器中,仅使用0
。
此外,还有更方便的属性。
与范围类似,选择的起点被称为“锚点(anchor)”,终点被称为“焦点(focus)”。
主要的选择属性有:anchorNode
—— 选择的起始节点,anchorOffset
—— 选择开始的anchorNode
中的偏移量,focusNode
—— 选择的结束节点,focusOffset
—— 选择开始处focusNode
的偏移量,isCollapsed
—— 如果未选择任何内容(空范围)或不存在,则为true
。rangeCount
—— 选择中的范围数,除 Firefox 外,其他浏览器最多为1
。
选择和范围的起点和终点对比
选择(selection)的锚点/焦点和Range
的起点和终点有一个很重要的区别。
正如我们所知道的,Range
对象的起点必须在其终点之前。
但对于选择,并不总是这样的。
我们可以在两个方向上使用鼠标进行选择:“从左到右”或“从右到左”。
换句话说,当按下鼠标按键,然后它在文档中向前移动时,它结束的位置(焦点)将在它开始的位置(锚点)之后。
例如,如果用户使用鼠标从 “Example” 开始选择到 “italic”:
但是,我们也可以从前向后进行相同的选择:从 “italic” 到 “Example”(从前向后),这样它结束的位置(焦点)将在它开始的位置(锚点)之前。
选择事件
有一些事件可以跟踪选择:
elem.onselectstart
—— 当在元素elem
上(或在其内部)开始选择时。例如,当用户在元素elem
上按下鼠标按键并开始移动指针时。- 阻止默认行为取消了选择的开始。因此,从该元素开始选择变得不可能,但该元素仍然是可选择的。用户只需要从其他地方开始选择。
document.onselectionchange
—— 当选择发生变化或开始时。- 请注意:此处理程序只能在
document
上设置。它跟踪的是document
中的所有选择。
- 请注意:此处理程序只能在
选择跟踪演示
下面是一个小例子,它跟踪了 document
上当前的选择,并将选择边界显示出来:
<p id="p">Select me: <i>italic</i> and <b>bold</b></p>
From <input id="from" disabled> – To <input id="to" disabled>
<script>
document.onselectionchange = function() {
let selection = document.getSelection();
let {anchorNode, anchorOffset, focusNode, focusOffset} = selection;
// anchorNode 和 focusNode 通常是文本节点
from.value = `${anchorNode?.data}, offset ${anchorOffset}`;
to.value = `${focusNode?.data}, offset ${focusOffset}`;
};
</script>
复制所选内容有两种方式:
- 我们可以使用
document.getSelection().toString()
来获取其文本形式。 - 此外,想要复制整个 DOM 节点,例如,如果我们需要保持其格式不变,我们可以使用
getRangeAt(...)
获取底层的(underlying)范围。Range
对象还具有cloneContents()
方法,该方法会拷贝范围中的内容并以DocumentFragment
的形式返回,我们可以将这个返回值插入到其他位置。
下面是将所选内容复制为文本和 DOM 节点的演示:
<p id="p">Select me: <i>italic</i> and <b>bold</b></p>
Cloned: <span id="cloned"></span>
<br>
As text: <span id="astext"></span>
<script>
document.onselectionchange = function() {
let selection = document.getSelection();
cloned.innerHTML = astext.innerHTML = "";
// 从范围复制 DOM 节点(这里我们支持多选)
for (let i = 0; i < selection.rangeCount; i++) {
cloned.append(selection.getRangeAt(i).cloneContents());
}
// 获取为文本形式
astext.innerHTML += selection;
};
</script>
选择方法
我们可以通过添加/移除范围来处理选择:
getRangeAt(i)
—— 获取从0
开始的第 i 个范围。在除 Firefox 之外的所有浏览器中,仅使用0
。addRange(range)
—— 将range
添加到选择中。如果选择已有关联的范围,则除 Firefox 外的所有浏览器都将忽略该调用。removeRange(range)
—— 从选择中删除range
。removeAllRanges()
—— 删除所有范围。empty()
——removeAllRanges
的别名。
还有一些方便的方法可以直接操作选择范围,而无需中间的Range
调用:collapse(node, offset)
—— 用一个新的范围替换选定的范围,该新范围从给定的node
处开始,到偏移offset
处结束。setPosition(node, offset)
——collapse
的别名。collapseToStart()
—— 折叠(替换为空范围)到选择起点,collapseToEnd()
—— 折叠到选择终点,extend(node, offset)
—— 将选择的焦点(focus)移到给定的node
,位置偏移offset
,setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset)
—— 用给定的起点anchorNode/anchorOffset
和终点focusNode/focusOffset
来替换选择范围。选中它们之间的所有内容。selectAllChildren(node)
—— 选择node
的所有子节点。deleteFromDocument()
—— 从文档中删除所选择的内容。containsNode(node, allowPartialContainment = false)
—— 检查选择中是否包含node
(若第二个参数是true
,则只需包含node
的部分内容即可)
对于大多数需求,这些方法就够了,无需访问底层的(underlying)Range
对象。
例如,选择段落<p>
的全部内容:
<p id="p">Select me: <i>italic</i> and <b>bold</b></p>
<script>
// 从 <p> 的第 0 个子节点选择到最后一个子节点
document.getSelection().setBaseAndExtent(p, 0, p, p.childNodes.length);
</script>
使用范围来完成同一个操作:
<p id="p">Select me: <i>italic</i> and <b>bold</b></p>
<script>
let range = new Range();
range.selectNodeContents(p); // 或者也可以使用 selectNode(p) 来选择 <p> 标签
document.getSelection().removeAllRanges(); // 清除现有选择(如果有的话)
document.getSelection().addRange(range);
</script>
如要选择一些内容,请先移除现有的选择
如果在文档中已存在选择,则首先使用removeAllRanges()
将其清空。然后添加范围。否则,除 Firefox 外的所有浏览器都将忽略新范围。
某些选择方法例外,它们会替换现有的选择,例如setBaseAndExtent
。
表单控件中的选择
诸如 input
和 textarea
等表单元素提供了 专用的选择 API,没有 Selection
或 Range
对象。由于输入值是纯文本而不是 HTML,因此不需要此类对象,一切都变得更加简单。
属性:
input.selectionStart
—— 选择的起始位置(可写),input.selectionEnd
—— 选择的结束位置(可写),input.selectionDirection
—— 选择方向,其中之一:“forward”,“backward” 或 “none”(例如使用鼠标双击进行的选择),
事件:input.onselect
—— 当某个东西被选择时触发。
方法:input.select()
—— 选择文本控件中的所有内容(可以是textarea
而不是input
),input.setSelectionRange(start, end, [direction])
—— 在给定方向上(可选),从start
一直选择到end
。input.setRangeText(replacement, [start], [end], [selectionMode])
—— 用新文本替换范围中的文本。
可选参数start
和end
,如果提供的话,则设置范围的起点和终点,否则使用用户的选择。
最后一个参数selectionMode
决定替换文本后如何设置选择。可能的值为:"select"
—— 将选择新插入的文本。"start"
—— 选择范围将在插入的文本之前折叠(光标将在其之前)。"end"
—— 选择范围将在插入的文本之后折叠(光标将紧随其后)。"preserve"
—— 尝试保留选择。这是默认值。
现在,让我们看看这些方法的实际使用。
示例 跟踪选择
例如,此段代码使用 onselect
事件来跟踪选择:
<textarea id="area" style="width:80%;height:60px">
Selecting in this text updates values below.
</textarea>
<br>
From <input id="from" disabled> – To <input id="to" disabled>
<script>
area.onselect = function() {
from.value = area.selectionStart;
to.value = area.selectionEnd;
};
</script>
请注意:
onselect
是在某项被选择时触发,而在选择被删除时不触发。- 根据 规范,表单控件内的选择不应该触发
document.onselectionchange
事件,因为它与document
选择和范围不相关。一些浏览器会生成它,但我们不应该依赖它。
示例 移动光标
我们可以更改 selectionStart
和 selectionEnd
,二者设定了选择。
一个重要的边界情况是 selectionStart
和 selectionEnd
彼此相等。那正是光标位置。或者,换句话说,当未选择任何内容时,选择会折叠在光标位置。
因此,通过将 selectionStart
和 selectionEnd
设置为相同的值,我们可以移动光标。
例如:
<textarea id="area" style="width:80%;height:60px">
Focus on me, the cursor will be at position 10.
</textarea>
<script>
area.onfocus = () => {
// 设置零延迟 setTimeout 以在浏览器 "focus" 行为完成后运行
setTimeout(() => {
// 我们可以设置任何选择
// 如果 start=end,则光标就会在该位置
area.selectionStart = area.selectionEnd = 10;
});
};
</script>
示例 修改选择
如要修改选择的内容,我们可以使用 input.setRangeText()
方法。当然,我们可以读取 selectionStart/End
,并在了解选择的情况下更改 value
的相应子字符串,但是 setRangeText
功能更强大,通常更方便。
那是一个有点复杂的方法。使用其最简单的单参数形式,它可以替换用户选择的范围并删除该选择。
例如,这里的用户的选择将被包装在 *...*
中:
<input id="input" style="width:200px" value="Select here and click the button">
<button id="button">Wrap selection in stars *...*</button>
<script>
button.onclick = () => {
if (input.selectionStart == input.selectionEnd) {
return; // 什么都没选
}
let selected = input.value.slice(input.selectionStart, input.selectionEnd);
input.setRangeText(`*${selected}*`);
};
</script>
使用更多参数,我们可以设置范围 start
和 end
。
在下面这个示例中,我们在输入文本中找到 "THIS"
,将其替换,并保持替换文本的选中状态:
<input id="input" style="width:200px" value="Replace THIS in text">
<button id="button">Replace THIS</button>
<script>
button.onclick = () => {
let pos = input.value.indexOf("THIS");
if (pos >= 0) {
input.setRangeText("*THIS*", pos, pos + 4, "select");
input.focus(); // 聚焦(focus),以使选择可见
}
};
</script>
示例 在光标处插入
如果未选择任何内容,或者我们在 setRangeText
中使用了相同的 start
和 end
,则仅插入新文本,不会删除任何内容。
我们也可以使用 setRangeText
在“光标处”插入一些东西。
这是一个按钮,按下后会在光标位置插入 "HELLO"
,然后光标紧随其后。如果选择不为空,则将其替换(我们可以通过比较 selectionStart!=selectionEnd
来进行检查,为空则执行其他操作):
<input id="input" style="width:200px" value="Text Text Text Text Text">
<button id="button">Insert "HELLO" at cursor</button>
<script>
button.onclick = () => {
input.setRangeText("HELLO", input.selectionStart, input.selectionEnd, "end");
input.focus();
};
</script>
使不可选
要使某些内容不可选,有三种方式:
- 使用 CSS 属性
user-select: none
。
<style>
#elem {
user-select: none;
}
</style>
<div>Selectable <div id="elem">Unselectable</div> Selectable</div>
这样不允许选择从 elem
开始。但是用户可以在其他地方开始选择,并将 elem
包含在内。
然后 elem
将成为 document.getSelection()
的一部分,因此选择实际发生了,但是在复制粘贴中,其内容通常会被忽略。
2. 防止 onselectstart
或 mousedown
事件中的默认行为。
<div>Selectable <div id="elem">Unselectable</div> Selectable</div>
<script>
elem.onselectstart = () => false;
</script>
这样可以防止在 elem
上开始选择,但是访问者可以在另一个元素上开始选择,然后扩展到 elem
。
当同一行为上有另一个事件处理程序触发选择时(例如 mousedown
),这会很方便。因此我们禁用选择以避免冲突,仍然允许复制 elem
内容。
3. 我们还可以使用 document.getSelection().empty()
来在选择发生后清除选择。很少使用这种方法,因为这会在选择项消失时导致不必要的闪烁。
MORE
事件循环 微任务与宏任务
浏览器中 JavaScript 的执行流程和 Node.js 中的流程都是基于 事件循环 的。
理解事件循环的工作方式对于代码优化很重要,有时对于正确的架构也很重要。
在本章中,我们首先介绍事件循环工作方式的理论细节,然后介绍该知识的实际应用。
事件循环
事件循环 的概念非常简单。它是一个在 JavaScript 引擎等待任务,执行任务和进入休眠状态等待更多任务这几个状态之间转换的无限循环。
引擎的一般算法:
- 当有任务时:
- 从最先进入的任务开始执行。
- 休眠直到出现任务,然后转到第 1 步。
当我们浏览一个网页时就是上述这种形式。JavaScript 引擎大多数时候不执行任何操作,它仅在脚本/处理程序/事件激活时执行。
任务示例:
- 当外部脚本
<script src="...">
加载完成时,任务就是执行它。 - 当用户移动鼠标时,任务就是派生出
mousemove
事件和执行处理程序。 - 当安排的(scheduled)
setTimeout
时间到达时,任务就是执行其回调。 - ……诸如此类。
设置任务 —— 引擎处理它们 —— 然后等待更多任务(即休眠,几乎不消耗 CPU 资源)。
一个任务到来时,引擎可能正处于繁忙状态,那么这个任务就会被排入队列。
多个任务组成了一个队列,即所谓的“宏任务队列”(v8 术语):
例如,当引擎正在忙于执行一段script
时,用户可能会移动鼠标而产生mousemove
事件,setTimeout
或许也刚好到期,以及其他任务,这些任务组成了一个队列,如上图所示。
队列中的任务基于“先进先出”的原则执行。当浏览器引擎执行完script
后,它会处理mousemove
事件,然后处理setTimeout
处理程序,依此类推。
到目前为止,很简单,对吧?
两个细节:
- 引擎执行任务时永远不会进行渲染(render)。如果任务执行需要很长一段时间也没关系。仅在任务完成后才会绘制对 DOM 的更改。
- 如果一项任务执行花费的时间过长,浏览器将无法执行其他任务,例如处理用户事件。因此,在一定时间后,浏览器会抛出一个如“页面未响应”之类的警报,建议你终止这个任务。这种情况常发生在有大量复杂的计算或导致死循环的程序错误时。
以上是理论知识。现在,让我们来看看如何应用这些知识。
用例一:拆分 CPU 过载任务
假设我们有一个 CPU 过载任务。
例如,语法高亮(用来给本页面中的示例代码着色)是相当耗费 CPU 资源的任务。为了高亮显示代码,它执行分析,创建很多着了色的元素,然后将它们添加到文档中 —— 对于文本量大的文档来说,需要耗费很长时间。
当引擎忙于语法高亮时,它就无法处理其他 DOM 相关的工作,例如处理用户事件等。它甚至可能会导致浏览器“中断(hiccup)”甚至“挂起(hang)”一段时间,这是不可接受的。
我们可以通过将大任务拆分成多个小任务来避免这个问题。高亮显示前 100 行,然后使用 setTimeout
(延时参数为 0)来安排(schedule)后 100 行的高亮显示,依此类推。
为了演示这种方法,简单起见,让我们写一个从 1
数到 1000000000
的函数,而不写文本高亮。
如果你运行下面这段代码,你会看到引擎会“挂起”一段时间。对于服务端 JS 来说这显而易见,并且如果你在浏览器中运行它,尝试点击页面上其他按钮时,你会发现在计数结束之前不会处理其他事件。
let i = 0;
let start = Date.now();
function count() {
// 做一个繁重的任务
for (let j = 0; j < 1e9; j++) {
i++;
}
alert("Done in " + (Date.now() - start) + 'ms');
}
count();
浏览器甚至可能会显示一个“脚本执行时间过长”的警告。
让我们使用嵌套的 setTimeout
调用来拆分这个任务:
let i = 0;
let start = Date.now();
function count() {
// 做繁重的任务的一部分 (*)
do {
i++;
} while (i % 1e6 != 0);
if (i == 1e9) {
alert("Done in " + (Date.now() - start) + 'ms');
} else {
setTimeout(count); // 安排(schedule)新的调用 (**)
}
}
count();
现在,浏览器界面在“计数”过程中可以正常使用。
单次执行 count
会完成工作 (*)
的一部分,然后根据需要重新安排(schedule)自身的执行 (**)
:
- 首先执行计数:
i=1...1000000
。 - 然后执行计数:
i=1000001..2000000
。 - ……以此类推。
现在,如果在引擎忙于执行第一部分时出现了一个新的副任务(例如onclick
事件),则该任务会被排入队列,然后在第一部分执行结束时,并在下一部分开始执行前,会执行该副任务。周期性地在两次count
执行期间返回事件循环,这为 JavaScript 引擎提供了足够的“空气”来执行其他操作,以响应其他的用户行为。
值得注意的是这两种变体 —— 是否使用了setTimeout
对任务进行拆分 —— 在执行速度上是相当的。在执行计数的总耗时上没有多少差异。
为了使两者耗时更接近,让我们来做一个改进。
我们将要把调度(scheduling)移动到count()
的开头:
let i = 0;
let start = Date.now();
function count() {
// 将调度(scheduling)移动到开头
if (i < 1e9 - 1e6) {
setTimeout(count); // 安排(schedule)新的调用
}
do {
i++;
} while (i % 1e6 != 0);
if (i == 1e9) {
alert("Done in " + (Date.now() - start) + 'ms');
}
}
count();
现在,当我们开始调用 count()
时,会看到我们需要对 count()
进行更多调用,我们就会在工作前立即安排(schedule)它。
如果你运行它,你很容易注意到它花费的时间明显减少了。
为什么?
这很简单:你应该还记得,多个嵌套的 setTimeout
调用在浏览器中的最小延迟为 4ms。即使我们设置了 0
,但还是 4ms
(或者更久一些)。所以我们安排(schedule)得越早,运行速度也就越快。
最后,我们将一个繁重的任务拆分成了几部分,现在它不会阻塞用户界面了。而且其总耗时并不会长很多。
用例二:进度指示
对浏览器脚本中的过载型任务进行拆分的另一个好处是,我们可以显示进度指示。
正如前面所提到的,仅在当前运行的任务完成后,才会对 DOM 中的更改进行绘制,无论这个任务运行花费了多长时间。
从一方面讲,这非常好,因为我们的函数可能会创建很多元素,将它们一个接一个地插入到文档中,并更改其样式 —— 访问者不会看到任何未完成的“中间态”内容。很重要,对吧?
这是一个示例,对 i
的更改在该函数完成前不会显示出来,所以我们将只会看到最后的值:
<div id="progress"></div>
<script>
function count() {
for (let i = 0; i < 1e6; i++) {
i++;
progress.innerHTML = i;
}
}
count();
</script>
……但是我们也可能想在任务执行期间展示一些东西,例如进度条。
如果我们使用 setTimeout
将繁重的任务拆分成几部分,那么变化就会被在它们之间绘制出来。
这看起来更好看:
<div id="progress"></div>
<script>
let i = 0;
function count() {
// 做繁重的任务的一部分 (*)
do {
i++;
progress.innerHTML = i;
} while (i % 1e3 != 0);
if (i < 1e7) {
setTimeout(count);
}
}
count();
</script>
现在 div
显示了 i
的值的增长,这就是进度条的一种。
用例三:在事件之后做一些事情
在事件处理程序中,我们可能会决定推迟某些行为,直到事件冒泡并在所有级别上得到处理后。我们可以通过将该代码包装到零延迟的 setTimeout
中来做到这一点。
在 创建自定义事件 一章中,我们看到过这样一个例子:自定义事件 menu-open
被在 setTimeout
中分派(dispatched),所以它在 click
事件被处理完成之后发生。
menu.onclick = function() {
// ...
// 创建一个具有被点击的菜单项的数据的自定义事件
let customEvent = new CustomEvent("menu-open", {
bubbles: true
});
// 异步分派(dispatch)自定义事件
setTimeout(() => menu.dispatchEvent(customEvent));
};
宏任务和微任务
除了本章中所讲的 宏任务(macrotask) 外,还有在 微任务(Microtask) 一章中提到的 微任务(microtask)。
微任务仅来自于我们的代码。它们通常是由 promise 创建的:对 .then/catch/finally
处理程序的执行会成为微任务。微任务也被用于 await
的“幕后”,因为它是 promise 处理的另一种形式。
还有一个特殊的函数 queueMicrotask(func)
,它对 func
进行排队,以在微任务队列中执行。
每个宏任务之后,引擎会立即执行微任务队列中的所有任务,然后再执行其他的宏任务,或渲染,或进行其他任何操作。
例如,看看下面这个示例:
setTimeout(() => alert("timeout"));
Promise.resolve()
.then(() => alert("promise"));
alert("code");
这里的执行顺序是怎样的?
code
首先显示,因为它是常规的同步调用。promise
第二个出现,因为then
会通过微任务队列,并在当前代码之后执行。timeout
最后显示,因为它是一个宏任务。
更详细的事件循环图示如下(顺序是从上到下,即:首先是脚本,然后是微任务,渲染等):
微任务会在执行任何其他事件处理,或渲染,或执行任何其他宏任务之前完成。
这很重要,因为它确保了微任务之间的应用程序环境基本相同(没有鼠标坐标更改,没有新的网络数据等)。
如果我们想要异步执行(在当前代码之后)一个函数,但是要在更改被渲染或新事件被处理之前执行,那么我们可以使用queueMicrotask
来对其进行安排(schedule)。
这是一个与前面那个例子类似的,带有“计数进度条”的示例,但是它使用了queueMicrotask
而不是setTimeout
。你可以看到它在最后才渲染。就像写的是同步代码一样:
<div id="progress"></div>
<script>
let i = 0;
function count() {
// 做繁重的任务的一部分 (*)
do {
i++;
progress.innerHTML = i;
} while (i % 1e3 != 0);
if (i < 1e6) {
queueMicrotask(count);
}
}
count();
</script>
Web Workers
对于不应该阻塞事件循环的耗时长的繁重计算任务,我们可以使用 Web Workers。
这是在另一个并行线程中运行代码的方式。
Web Workers 可以与主线程交换消息,但是它们具有自己的变量和事件循环。
Web Workers 没有访问 DOM 的权限,因此,它们对于同时使用多个 CPU 内核的计算非常有用。