Skip to content

DOM 变动观察器(Mutation observer)

MutationObserver 是一个内建对象,它观察 DOM 元素,并在检测到更改时触发回调。

  1. 语法
    1. 首先,我们创建一个带有回调函数的观察器: let observer = new MutationObserver``(callback)``;
    2. 然后将其附加到一个 DOM 节点: observer.``observe``(node, config)``;
    3. config 是一个具有布尔选项的对象,该布尔选项表示“将对哪些更改做出反应”:
      • childList —— node 的直接子节点的更改,
      • subtree —— node 的所有后代的更改,
      • attributes —— node 的特性(attribute),
      • attributeFilter —— 特性名称数组,只观察选定的特性。
      • characterData —— 是否观察 node.data(文本内容),
    4. 其他选项
      • attributeOldValue —— 如果为 true,则将特性的旧值和新值都传递给回调(参见下文),否则只传新值(需要 attributes 选项),
      • characterDataOldValue —— 如果为 true,则将 node.data 的旧值和新值都传递给回调(参见下文),否则只传新值(需要 characterData 选项)。
    5. 在发生任何更改后,将执行“回调”:更改被作为一个 MutationRecord 对象列表传入第一个参数,而观察器自身作为第二个参数。
    6. MutationRecord 对象具有以下属性:
      • type —— 变动类型,以下类型之一:
        • "attributes":特性被修改了,
        • "characterData":数据被修改了,用于文本节点,
        • "childList":添加/删除了子元素。
      • target —— 更改发生在何处:"attributes" 所在的元素,或 "characterData" 所在的文本节点,或 "childList" 变动所在的元素,
      • addedNodes/removedNodes —— 添加/删除的节点,
      • previousSibling/nextSibling —— 添加/删除的节点的上一个/下一个兄弟节点,
      • attributeName/attributeNamespace —— 被更改的特性的名称/命名空间(用于 XML),
      • oldValue —— 之前的值,仅适用于特性或文本更改,如果设置了相应选项 attributeOldValue/characterDataOldValue
html
	<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>
  1. 用于集成
    1. 在什么时候可能有用?
      1. 需要添加一个第三方脚本,该脚本不仅包含有用的功能,还会执行一些我们不想要的操作,例如显示广告 <div class="ads">Unwanted ads</div>
    2. 第三方脚本没有提供删除它的机制。
      1. 使用 MutationObserver,我们可以监测到我们不需要的元素何时出现在我们的 DOM 中,并将其删除。
    3. 还有一些其他情况,例如第三方脚本会将某些内容添加到我们的文档中,并且我们希望检测出这种情况何时发生,以调整页面,动态调整某些内容的大小等。
  2. 用于架构
    1. 从架构的角度来看,在某些情况下,MutationObserver 有不错的作用。
    2. 假设我们正在建立一个有关编程的网站。自然地,文章和其他材料中可能包含源代码段。
    3. 对于动态加载的文章,应该在何处何时调用 Prism.highlightElem
JavaScript
let article = /* 从服务器获取新内容 */
articleElem.innerHTML = article;

let snippets = articleElem.querySelectorAll('pre[class*="language-"]');
snippets.forEach(Prism.highlightElem);
  1. 动态高亮显示示例
JavaScript
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});
  1. 其他方法
    1. 有一个方法可以停止观察节点
      • observer.disconnect() —— 停止观察。
    2. 当我们停止观察时,观察器可能尚未处理某些更改。在种情况下,我们使用
      • observer.takeRecords() —— 获取尚未处理的变动记录列表,表中记录的是已经发生,但回调暂未处理的变动。
    3. observer.takeRecords() 返回的记录被从处理队列中移除
      1. 回调函数不会被 observer.takeRecords() 返回的记录调用。
    4. 垃圾回收
      1. 观察器在内部对节点使用弱引用。也就是说,如果一个节点被从 DOM 中移除了,并且该节点变得不可访问,那么它就可以被垃圾回收。

选择(Selection)和范围(Range)

JavaScript 可以访问现有的选择,选择/取消全部或部分 DOM 节点的选择,从文档中删除所选部分,将其包装到一个标签(tag)中,等。

  1. 范围
    1. 选择的基本概念是 Range:本质上是一对“边界点”:范围起点和范围终点。
    2. 创建一个 Range 对象:let range = new Range();
    3. 我们可以使用 range.setStart(node, offset) 和 range.setEnd(node, offset) 来设置选择边界。
  2. 选择部分文本
    1. 如果 node 是一个文本节点,那么 offset 则必须是其文本中的位置。
html
<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>
  1. 选择元素节点
    1. 或者,如果 node 是一个元素节点,那么 offset 则必须是子元素的编号。
      html
      	<p id="p">Example: <i>italic</i> and <b>bold</b></p>
      
      	From <input id="start" type="number" value=1> – To <input id="end" type="number" value=4>
      	<button id="button">Click to select</button>
      	<script>
      	  button.onclick = () => {
      	    let range = new Range();
      	
      	    range.setStart(p, start.value);
      	    range.setEnd(p, end.value);
      	
      	    // 应用选择,后文有解释
      	    document.getSelection().removeAllRanges();
      	    document.getSelection().addRange(range);
      	  };
      	</script>
    2. 起始和结束的节点可以不同
      1. 我们不是必须在 setStart 和 setEnd 中使用相同的节点。一个范围可能会跨越很多不相关的节点。唯一要注意的是终点要在起点之后。
  2. 选择更大的片段
html
<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>
  1. range 属性
    • startContainerstartOffset —— 起始节点和偏移量,
      • 在上例中:分别是 <p> 中的第一个文本节点和 2
    • endContainerendOffset —— 结束节点和偏移量,
      • 在上例中:分别是 <b> 中的第一个文本节点和 3
    • collapsed —— 布尔值,如果范围在同一点上开始和结束(所以范围内没有内容)则为 true
      • 在上例中:false
    • commonAncestorContainer —— 在范围内的所有节点中最近的共同祖先节点,
      • 在上例中:<p>
  2. 选择范围的方法
    1. 设置范围的起点:
      • setStart(node, offset) 将起点设置在:node 中的位置 offset
      • setStartBefore(node) 将起点设置在:node 前面
      • setStartAfter(node) 将起点设置在:node 后面
    2. 设置范围的终点(类似的方法):
      • setEnd(node, offset) 将终点设置为:node 中的位置 offset
      • setEndBefore(node) 将终点设置为:node 前面
      • setEndAfter(node) 将终点设置为:node 后面
    3. 更多创建范围的方法:
      • selectNode(node) 设置范围以选择整个 node
      • selectNodeContents(node) 设置范围以选择整个 node 的内容
      • collapse(toStart) 如果 toStart=true 则设置 end=start,否则设置 start=end,从而折叠范围
      • cloneRange() 创建一个具有相同起点/终点的新范围
  3. 编辑范围的方法
    1. 创建范围后,我们可以使用以下方法操作其内容:
      • deleteContents() —— 从文档中删除范围中的内容
      • extractContents() —— 从文档中删除范围中的内容,并将删除的内容作为 DocumentFragment 返回
      • cloneContents() —— 复制范围中的内容,并将复制的内容作为 DocumentFragment 返回
      • insertNode(node) —— 在范围的起始处将 node 插入文档
      • surroundContents(node) —— 使用 node 将所选范围中的内容包裹起来。要使此操作有效,则该范围必须包含其中所有元素的开始和结束标签:不能像 <i>abc 这样的部分范围。
html
点击按钮运行所选内容上的方法,点击 "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>
  1. 选择
    1. Range 是用于管理选择范围的通用对象。尽管创建一个 Range 并不意味着我们可以在屏幕上看到一个内容选择。
    2. 我们可以创建 Range 对象并传递它们 —— 但它们并不会在视觉上选择任何内容。
    3. 文档选择是由 Selection 对象表示的,可通过 window.getSelection() 或 document.getSelection() 来获取。一个选择可以包括零个或多个范围。至少,Selection API 规范 是这么说的。不过实际上,只有 Firefox 允许使用 Ctrl+click (Mac 上用 Cmd+click) 在文档中选择多个范围。
  2. 选择属性
    1. ,理论上一个选择可能包含多个范围。我们可以使用下面这个方法获取这些范围对象:
      1. getRangeAt(i) —— 获取第 i 个范围,i 从 0 开始。在除 Firefox 之外的所有浏览器中,仅使用 0
    2. 主要的选择属性有:
      • anchorNode —— 选择的起始节点,
      • anchorOffset —— 选择开始的 anchorNode 中的偏移量,
      • focusNode —— 选择的结束节点,
      • focusOffset —— 选择结束处 focusNode 的偏移量,
      • isCollapsed —— 如果未选择任何内容(空范围)或不存在,则为 true 。
      • rangeCount —— 选择中的范围数,除 Firefox 外,其他浏览器最多为 1
    3. 选择和范围的起点和终点对比
      1. 选择(selection)的锚点/焦点和 Range 的起点和终点有一个很重要的区别。
      2. 当按下鼠标按键,然后它在文档中向前移动时,它结束的位置(焦点)将在它开始的位置(锚点)之后。
  3. 选择事件
    1. 有一些事件可以跟踪选择:
      • elem.onselectstart —— 当在元素 elem 上(或在其内部)开始选择时。例如,当用户在元素 elem 上按下鼠标按键并开始移动指针时。
        • 阻止默认行为取消了选择的开始。因此,从该元素开始选择变得不可能,但该元素仍然是可选择的。用户只需要从其他地方开始选择。
      • document.onselectionchange —— 当选择发生变化或开始时。
        • 请注意:此处理程序只能在 document 上设置。它跟踪的是 document 中的所有选择。
  4. 选择跟踪演示
html
	//跟踪了 `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>
  1. 选择复制演示
    1. 复制所选内容有两种方式:
      1. 我们可以使用 document.getSelection().toString() 来获取其文本形式。
      2. 此外,想要复制整个 DOM 节点,例如,如果我们需要保持其格式不变,我们可以使用 getRangeAt(...) 获取底层的(underlying)范围。Range 对象还具有 cloneContents() 方法,该方法会拷贝范围中的内容并以 DocumentFragment 的形式返回,我们可以将这个返回值插入到其他位置。
    html
    <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>
  2. 选择方法
    1. 我们可以通过添加/移除范围来处理选择:
      • getRangeAt(i) —— 获取从 0 开始的第 i 个范围。在除 Firefox 之外的所有浏览器中,仅使用 0
      • addRange(range) —— 将 range 添加到选择中。如果选择已有关联的范围,则除 Firefox 外的所有浏览器都将忽略该调用。
      • removeRange(range) —— 从选择中删除 range
      • removeAllRanges() —— 删除所有范围。
      • empty() —— removeAllRanges 的别名。
    2. 还有一些方便的方法可以直接操作选择范围,而无需中间的 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 的部分内容即可)
    3. 如要选择一些内容,请先移除现有的选择
      1. 如果在文档中已存在选择,则首先使用 removeAllRanges() 将其清空。然后添加范围。否则,除 Firefox 外的所有浏览器都将忽略新范围。
      2. 某些选择方法例外,它们会替换现有的选择,例如 setBaseAndExtent
  3. 表单控件中的选择
    1. 诸如 input 和 textarea 等表单元素提供了 专用的选择 API,没有 Selection 或 Range 对象。由于输入值是纯文本而不是 HTML,因此不需要此类对象,一切都变得更加简单。
    2. 属性:
      • input.selectionStart —— 选择的起始位置(可写),
      • input.selectionEnd —— 选择的结束位置(可写),
      • input.selectionDirection —— 选择方向,其中之一:“forward”,“backward” 或 “none”(例如使用鼠标双击进行的选择),
    3. 事件:
      • input.onselect —— 当某个东西被选择时触发。
    4. 方法:
      • input.select() —— 选择文本控件中的所有内容(可以是 textarea 而不是 input),
      • input.setSelectionRange(start, end, [direction]) —— 在给定方向上(可选),从 start 一直选择到 end
      • input.setRangeText(replacement, [start], [end], [selectionMode]) —— 用新文本替换范围中的文本。 可选参数 start 和 end,如果提供的话,则设置范围的起点和终点,否则使用用户的选择。 最后一个参数 selectionMode 决定替换文本后如何设置选择。可能的值为:
        • "select" —— 将选择新插入的文本。
        • "start" —— 选择范围将在插入的文本之前折叠(光标将在其之前)。
        • "end" —— 选择范围将在插入的文本之后折叠(光标将紧随其后)。
        • "preserve" —— 尝试保留选择。这是默认值。
  4. 示例:跟踪选择
html
<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;
  };
  • onselect 是在某项被选择时触发,而在选择被删除时不触发。
  • 根据 规范,表单控件内的选择不应该触发 document.onselectionchange 事件,因为它与 document 选择和范围不相关。一些浏览器会生成它,但我们不应该依赖它。
  1. 示例:移动光标
html
<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>```
17. **示例:修改选择**
```html
<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>
  1. 示例:在光标处插入
html
<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>
  1. 使不可选
    1. 要使某些内容不可选,有三种方式:
    html
    
    //1.使用 CSS 属性 `user-select: none`。
    <style>
    #elem {
      user-select: none;
    }
    </style>
    <div>Selectable <div id="elem">Unselectable</div> Selectable</div>
    
    //2.防止 `onselectstart` 或 `mousedown` 事件中的默认行为。
    <div>Selectable <div id="elem">Unselectable</div> Selectable</div>
    <script>
      elem.onselectstart = () => false;
    </script>
    
    
    //3.1. 我们还可以使用 `document.getSelection().empty()` 来在选择发生后清除选择。很少使用这种方法,因为这会在选择项消失时导致不必要的闪烁。

事件循环:微任务和宏任务

浏览器中 JavaScript 的执行流程和 Node.js 中的流程都是基于 事件循环 的。

  1. 事件循环
    1. 事件循环 的概念非常简单。它是一个在 JavaScript 引擎等待任务,执行任务和进入休眠状态等待更多任务这几个状态之间转换的无限循环。
    2. 引擎的一般算法:
      1. 当有任务时:从最先进入的任务开始执行。
      2. 休眠直到出现任务,然后转到第 1 步。
    3. 当我们浏览一个网页时就是上述这种形式。JavaScript 引擎大多数时候不执行任何操作,它仅在脚本/处理程序/事件激活时执行。
    4. 任务示例:
      • 当外部脚本 <script src="..."> 加载完成时,任务就是执行它。
      • 当用户移动鼠标时,任务就是派生出 mousemove 事件和执行处理程序。
      • 当安排的(scheduled)setTimeout 时间到达时,任务就是执行其回调。
      • ……诸如此类。
    5. 两个细节:
      1. 引擎执行任务时永远不会进行渲染(render)。如果任务执行需要很长一段时间也没关系。仅在任务完成后才会绘制对 DOM 的更改。
      2. 如果一项任务执行花费的时间过长,浏览器将无法执行其他任务,例如处理用户事件。因此,在一定时间后,浏览器会抛出一个如“页面未响应”之类的警报,建议你终止这个任务。这种情况常发生在有大量复杂的计算或导致死循环的程序错误时。
  2. 用例 1:拆分 CPU 过载任务 假设我们有一个 CPU 过载任务。例如,语法高亮(用来给本页面中的示例代码着色)是相当耗费 CPU 资源的任务。为了高亮显示代码,它执行分析,创建很多着了色的元素,然后将它们添加到文档中 —— 对于文本量大的文档来说,需要耗费很长时间。 以下代码:写一个从 1 数到 1000000000 的函数,而不写文本高亮。
JavaScript
	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 调用来拆分这个任务:

JavaScript
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();

把调度(scheduling)移动到 count() 的开头:

JavaScript
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();
  1. 用例 2:进度指示
html
<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>
  1. 用例 3:在事件之后做一些事情
    1. 在事件处理程序中,我们可能会决定推迟某些行为,直到事件冒泡并在所有级别上得到处理后。我们可以通过将该代码包装到零延迟的 setTimeout 中来做到这一点。
JavaScript
menu.onclick = function() {
  // ...

  // 创建一个具有被点击的菜单项的数据的自定义事件
  let customEvent = new CustomEvent("menu-open", {
    bubbles: true
  });

  // 异步分派(dispatch)自定义事件
  setTimeout(() => menu.dispatchEvent(customEvent));
};
  1. 宏任务和微任务
    1. 微任务仅来自于我们的代码。它们通常是由 promise 创建的:对 .then/catch/finally 处理程序的执行会成为微任务。微任务也被用于 await 的“幕后”,因为它是 promise 处理的另一种形式。
    2. 还有一个特殊的函数 queueMicrotask(func),它对 func 进行排队,以在微任务队列中执行。
    3. 每个宏任务之后,引擎会立即执行微任务队列中的所有任务,然后再执行其他的宏任务,或渲染,或进行其他任何操作。
JavaScript
setTimeout(() => alert("timeout"));

Promise.resolve()
  .then(() => alert("promise"));

alert("code");

执行顺序:code(同步调用)-promise(then会通过微任务队列)-timeout(宏任务)