MutationObserver因为触发太晚而丢失了DOM上下文。

15

我的代码简化版本:

<div id="d">text<br><hr>text</div>

<script>
    // Called when DOM changes.
    function mutationCallback(mutations) {
        // assert(mutations.length === 3);
        var insertImg = mutations[0];
        console.log(insertImg.previousSibling.parentNode);  // Null!
        console.log(insertImg.nextSibling.parentNode); // Null!
        // Can't determine where img was inserted!
    }
  
    // Setup
    var div = document.getElementById('d');
    var br = div.childNodes[1];
    var hr = div.childNodes[2];
    var observer = new MutationObserver(mutationCallback);
    observer.observe(div, {childList: true, subtree: true});

    // Trigger DOM Changes.
    var img = document.createElement('img');
    div.insertBefore(img, hr);
    div.removeChild(hr);
    div.removeChild(br); // mutationCallback() is first called after this line.
</script>

我正在使用Mutation Observers来捕获DOM更改,以在另一个更改时更新一个文档实例。由于mutation observer函数直到<img>的上一个和下一个兄弟节点被删除后才被调用,因此mutationCallback函数无法确定插入位置。在Chrome、FF和IE11中重现。
另一种方法是遍历整个文档以查找更改,但这将抵消使用Mutation Observers的性能优势。

2
你为什么要首先使用.parentNodemutations[0].target给出了p,而mutations[0].removedNodes[0]给出了x。然后,由于这是一个单独的变化,mutations[1].target给出了div,而mutations[1].removedNodes[0]给出了p。这些信息足以重构发生的情况。 - loganfsmyth
我正在将一个文档的更改克隆到另一个文档中。第一个文档中的操作可以以任何顺序发生。因此,我不能简单地将mutations[1].target硬编码到我的解决方案中。 - EricP
1
完全正确,但是我想说的是,您需要将“mutations”数组中的每个项完全重放到另一个文档中,并且每个单独的“mutations”项告诉您哪个节点发生了变化以及有关它的哪些信息发生了变化,因此通常不需要更多的信息。 - loganfsmyth
1
除此之外,如果您要删除一个节点,通常不关心其中的内容会发生什么。"删除孙子节点,然后删除子节点"的情况甚至不应该发生...但如果确实发生了,并且您正在克隆更改,则"删除孙子节点"不会影响结果文档,因此可以忽略它。 - cHao
@loganfsmyth:这是我最初的假设,但它不能这样工作。当我接收到第一个变化时,我看到 x 已从 p 节点中移除,但此时 p 节点已经没有父级。因此这不足够的信息。当发生这种情况时,我不知道要查看 mutations[1],因为我不知道DOM更改的顺序。 - EricP
显示剩余4条评论
4个回答

5

mutations数组是某个目标发生的所有突变的完整列表。 这意味着,对于任意元素,想要知道其父节点在该突变发生时是什么,您必须查看后续的突变,以确定何时发生了父节点的突变。

var target = mutations[0].target
var parentRemoveMutation = mutations
 .slice(1)
 .find(mutation => mutation.removedNodes.indexOf(target) !== -1);
var parentNode = parentRemoveMutation 
  ? parentRemoveMutation.target // If the node was removed, that target is the parent
  : target.parentNode; // Otherwise the existing parent is still accurate.

正如你所看到的,这是针对第一个变异体硬编码的,你可能需要逐个为列表中的每个项目执行此操作。由于必须进行线性搜索,因此这不会很好地扩展。您还可以潜在地运行完整的变异列表以首先构建元数据。
所有这些都说了,似乎问题的核心在于,在理想的世界中您真的不应该关心父级。例如,如果您正在同步两个文档,则可以考虑使用WeakMap跟踪元素,因此针对每个可能的元素,从被突变的文档到同步文档中的每个元素都有一个映射。然后,当发生变异时,您只需使用Map查找原始文档中相应的元素,并在不需要查看父级的情况下在原始文档上再现更改。

如果使用WeakMap解决方案,会比较麻烦,因为每次插入操作后都必须不断更新引用。但如果没有更好的解决方案被发布,我将把它标记为已接受的解决方案。 - EricP
我尝试使用Weakmap同步解决方案,但一直无法成功。考虑使用insertBefore将<b></b>添加到document.body中的情况,然后在<b> 中添加"text"。第一次变异时,我看到在原始文档中存在<b>text</b>,并将其复制到镜像文档中。但是第二次变异会再次添加"text"。但是,如果我只在第一步中添加空的 <b></b>,而没有添加子节点,则会失败,如果在将<b>添加到body之前,已将"text"作为<b>的子节点添加。总之,我没有办法知道是否要克隆节点的子项。 - EricP
1
我不完全理解你的例子,但我的建议是你可以看一下 https://github.com/rafaelw/mutation-summary 这个项目以及它的工具 https://github.com/rafaelw/mutation-summary/blob/master/util/tree-mirror.ts。虽然我自己没有使用过镜像,但我已经使用过 MutationSummary,它似乎运行得还不错。 - loganfsmyth

2
在评论中,您说您的目标是将更改从一个文档复制到另一个文档。正如loganfsmyth所建议的那样,最好的方法是保持(弱)映射将原始节点映射到它们的克隆,并在每次克隆新节点时更新该映射。这样,您的变异观察器可以按照它们出现在变异列表中的顺序逐个处理变异,并对镜像节点执行相应的操作。
尽管您声称这不应该特别复杂,但实现起来并不简单。由于一个简单的代码片段往往比千言万语更有说服力,这里有一个简单的示例,可以将任何更改从一个div复制到另一个div:

var observed = document.getElementById('observed');
var mirror = document.getElementById('mirror');

var observer = new MutationObserver( updateMirror );
observer.observe( observed, { childList: true } );

var mirrorMap = new WeakMap ();

function updateMirror ( mutations ) {
  console.log( 'observed', mutations.length, 'mutations:' );
  
  for ( var mutation of mutations ) {
    if ( mutation.type !== 'childList' || mutation.target !== observed ) continue;
    
    // handle removals
    for ( var node of mutation.removedNodes ) {
      console.log( 'deleted', node );
      mirror.removeChild( mirrorMap.get(node) );
      mirrorMap.delete(node);  // not strictly necessary, since we're using a WeakMap
    }
    
    // handle insertions
    var next = (mutation.nextSibling && mirrorMap.get( mutation.nextSibling ));
    for ( var node of mutation.addedNodes ) {
      console.log( 'added', node, 'before', next );
      var copy = node.cloneNode(true);
      mirror.insertBefore( copy, next );
      mirrorMap.set(node, copy);
    }    
  }
}

// create some test nodes
var nodes = {};
'fee fie foe fum'.split(' ').forEach( key => {
  nodes[key] = document.createElement('span');
  nodes[key].textContent = key;
} );

// make some insertions and deletions
observed.appendChild( nodes.fee );  // fee
observed.appendChild( nodes.fie );  // fee fie
observed.insertBefore( nodes.foe, nodes.fie );  // fee foe fie
observed.insertBefore( nodes.fum, nodes.fee );  // fum fee foe fie
observed.removeChild( nodes.fie );  // fum fee foe
observed.removeChild( nodes.fee );  // fum foe
#observed { background: #faa }
#mirror { background: #afa }
#observed span, #mirror span { margin-right: 0.3em }
<div id="observed">observed: </div>
<div id="mirror">mirror: </div>

至少对我来说,在Chrome 65上,这个工作完美。控制台指示,正如预期的那样,变异观察器回调被调用一次,并带有六个变异列表。
observed 6 mutations:
added <span>fee</span> before null
added <span>fie</span> before null
added <span>foe</span> before <span>fie</span>
added <span>fum</span> before <span>fee</span>
deleted <span>fie</span>
deleted <span>fee</span>

由于对这些突变进行镜像处理,原始div和其镜像都以“fum”和“foe”这两个span按照顺序结束。

1
更好的想法是检查addedNodesremovedNodes数组。它们包含HTML元素的Nodelist,其中previousSiblingnextSibling属性指向变异后现在确切的前一个和后一个元素。请注意保留HTML标签。
    var insertImg = mutations[0];

    var insertImg = mutations[0].addedNodes[0];

<div id="d">text<br><hr>text</div>

<script>
    // Called when DOM changes.
    function mutationCallback(mutations) {
        // assert(mutations.length === 3);
        var insertImg = mutations[0].addedNodes[0];
        console.log(insertImg);
        console.log(insertImg.previousSibling);
        console.log(insertImg.nextSibling);
    }
  
    // Setup
    var div = document.getElementById('d');
    var br = div.childNodes[1];
    var hr = div.childNodes[2];
    var observer = new MutationObserver(mutationCallback);
    observer.observe(div, {childList: true, subtree: true});

    // Trigger DOM Changes.
    var img = document.createElement('img');
    d.insertBefore(img, hr);
    d.removeChild(hr);
    d.removeChild(br); // mutationCallback() is first called after this line.
</script>


这是一个很好的想法,但我想到了一个会失败的情况:假设我使用insertBefore三次来插入节点B,然后在节点B之前插入节点A,在节点B之后插入节点C。在这种情况下,当要求插入B时,我的目标文档将会感到困惑,因为A和C尚不存在于其中。 - EricP
你可以将这个元素移到数组的末尾,在最后进行处理,可能使用一个队列。 - Munim Munna

0

DOM操作,如插入、删除或移动元素都是同步的。

因此,只有在所有连续的同步操作完成后,您才能看到结果。

因此,您需要异步执行变更。以下是一个简单的示例:

// Called when DOM changes.
function mutationCallback(mutations) {
    var insertImg = mutations[0];
    console.log('mutation callback', insertImg.previousSibling.parentNode.outerHTML);
    console.log('mutation callback', insertImg.nextSibling.parentNode.outerHTML);
}

// Setup
var div = document.getElementById('d');
var br = div.childNodes[1];
var hr = div.childNodes[2];
var img = document.createElement('img');

var observer = new MutationObserver(mutationCallback);
observer.observe(div, {childList: true, subtree: true});

// Trigger DOM Changes.
setTimeout(function() {
  console.log('1 mutation start')
  d.insertBefore(img, hr);
  setTimeout(function (){
    console.log('2 mutation start')
    div.removeChild(hr);
    setTimeout(function (){
      console.log('3 mutation start')
      div.removeChild(br);
    }, 0)
  }, 0)
}, 0)
<div id="d">text<br><hr>text</div>

或者更复杂的例子,涉及到 Promises 和 async/await:

(async function () {
  function mutation(el, command, ...params) {
    return new Promise(function(resolve, reject) {
      el[command](...params)
      console.log(command)
      resolve()
    })
  }

  await mutation(div, 'insertBefore', img, hr)
  await mutation(div, 'removeChild', hr)
  await mutation(div, 'removeChild', br)
})()

[ https://jsfiddle.net/tt5mz8zt/ ]

的内容与编程有关。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接