应用/重播MutationRecord更改(MutationObserver API)

And*_*Hin 9 javascript webkit mutation-observers

我已经附加了MutationObserver一个DOM并且正在监视更改.

我收到通知并收到一个MutationRecord对象,其中包含对已更改内容的描述.

是否有支持/标准/简单方法MutationRecord再次应用更改?换句话说,使用该MutationRecord对象"重放"对DOM的更改?

任何帮助赞赏!

Piw*_*oli 0

我无法控制自己,在注意到这个问题是在 6 年前的 2016 年提出的,所以我制作了一个“DomRecorder Lite”。该程序记录了目标元素内可能发生的 DOM 突变事件的三种类型,在本例中为一些div. 您可以使用该工具并尝试:

  • 添加元素
  • 更改选定元素的文本
  • 删除选定的元素
  • 重播选定的动作
  • 重播所有动作

可能有一两个错误,但我认为概念证明无论如何都是存在的。

我还制作了一个小视频,演示了程序的工作原理(当我按下编辑按钮时,会出现要求输入文本的提示,但不会记录):https://www.veed.io/view/7325e363-ac5c -4c9b-8138-cd990b253372


我们使用两个名为MutationEvent和 的自定义类DOMRecorder

突变事件

MutationEvent 负责存储有关观察者突变事件的信息,例如事件是什么 ( TEXTCHANGED | INSERT | REMOVE)、与事件相关的 DOM 元素数据以及通用data数据,以防我们需要向事件附加一些额外的数据。

MutationEvent 还负责根据事件类型重播自身。我制作这个 PoC 是为了支持上面写的三种类型的事件。

这种INSERT情况有些有趣,因为用户能够重播一个事件(不清除记录的包装元素)或重播所有事件(首先清除记录的包装元素)。

当仅重放一个 INSERT 事件时,我们不能直接插入保存到 MutationEvent 的元素,因为它在 DOM 中与最初发起该事件的元素具有相同的 id,因此我们创建了这种“actor” element”,它是原始元素(及其子内容)的克隆,但具有不同的 id。

如果我们重播所有事件,则首先清除记录的包装元素,这意味着我们不必担心这个问题。

case 'INSERT':
  elem = document.querySelector(`#${$targetid}`);
  if(elem) {
    let actorElement = null;
    
    if(clean) {
      actorElement = this.element.cloneNode(true);
      actorElement.id = 'd-' + Math.random().toString(16).slice(2, 8);
    }
    else {
      actorElement = this.element;
    }
    elem.append(actorElement);
    success = true;
  }
  break;
Run Code Online (Sandbox Code Playgroud)

DOM记录器

DOMRecorder 是一切事物的核心。它的主要职责是观察MutationObserver记录元素的 DOM 变化并MutationEvents根据这些变化进行创建。其他职责包括重播单个或所有MutationEvent已存储的内容。在此 PoC 中,DOMRecorder 还负责更新页面上的一些选择输入,这可能不是 100% 合法的分离关注点的方法,但这与这里无关。


同样,这只是如何实现重放的概念证明MutationEvents,因此支持的事件数量有限。这个 PoC 也没有对深度 DOM 子树修改采取立场,但在编码之后,我并不太害怕想到它。 查看编辑

有支持/标准/简单的方法吗

可能不会。至少想不出这样的标准有多少用途。有一些库可以记录用户在网页上的交互,但这些库不需要记录/重放实际的 DOM 修改,只需要记录/重放可能导致 DOM 修改的交互。

还应该记住,一般来说,实现一个监视更改并发出某种更改事件来告诉您“这刚刚更改”的系统比实现一个能够重新启动的系统要容易得多。 -创建这些更改。前者实际上不需要关心任何地方以任何形式发生的变化以外的任何事情,但对于后者,在重新创建时需要考虑很多因素。

列出一些:

  • 你应该重新创造一些应该是独一无二的东西吗?如果重播的变化与记录的不完全一样,这真的是重播吗?
  • 如果“重播”的开始状态与“录制”开始时的状态不同怎么办?
  • 您还应该记录重播的内容吗?
  • 您如何处理事件之间的局部关系?
  • 如何处理事件和 DOM 之间的全局关系?
  • 如果我创建一个元素,更改它的文本,删除该元素并想再次重播文本更改事件,会发生什么?
  • 当深度嵌套元素的父元素被删除或以某种方式更改而使其无法再容纳嵌套元素时,会发生什么情况?
  • 如果我们决定删除深层嵌套元素的父元素应该删除整个元素分支,那么这应该是其本身的事件,还是分支上每个过时子元素的多个删除事件?
  • 如何跟踪CSS变化,尝试过之后发现并不像看上去那么简单

编辑

所以我添加了子树插入和删除的事件。这意味着现在可以在其他元素内添加子元素。适用于顶级元素的函数也应适用于任何子元素,或者子元素,或者子元素的子元素,...

子树

实现起来有点棘手,这里最大的问题是跟踪父元素 id。看,当一个元素被创建时,会为其生成一些 id。要将一个元素插入到另一个现有元素中,我们显然需要知道它的 id。现在这看起来似乎没有问题,用户选择的父元素有一些 id,所以我们只需获取它并将元素插入其中即可。

一切似乎都正常,但我们在重播事件时遇到了问题,并且子树插入不起作用。原因是当程序重播事件(特别是事件)时SUBINSERT,它对父元素使用了错误的 id。这是因为重放函数在开始重放之前会清除记录的包装器,因此重放期间创建的所有元素都会获得新的 id,因此SUBINSERT在重放期间将不知道父元素 id 是什么。

为了解决这个问题,我为每个事件添加了第二个“静态内部 ID”:

constructor(recorderInstance, event, element, data)
{
    this.static_replay_id = getId();
    ...
}
Run Code Online (Sandbox Code Playgroud)

这个 id 从录制到重播都不会改变,所以当我们想要将一个元素作为子元素插入到某个父元素时,我们按事件执行以下操作:

  1. 获取 的 id mutation.target,即父元素的 id
  2. 假设既然这个父元素存在,那么它也必须已经存在一个事件
  3. mutation.target通过id获取事件的static_replay_id
  4. 添加此事件的static_replay_idassubtree_targetSUBINSERT

getStaticIdForMutationTarget:

getStaticIdForMutationTarget(targetid)
{
    return this.events.find((evt) => evt.element.id === targetid).static_replay_id;
}
Run Code Online (Sandbox Code Playgroud)

当我们重播该SUBINSERT事件时,我们通过保存的内容获取父元素的当前 DOM id static_replay_id

elem = document.querySelector(`#${$targetid}div#${this.recorderInstance.getEventTargetByStaticReplayId(this.data.subtree_target)}`);
Run Code Online (Sandbox Code Playgroud)

getEventTargetByStaticReplayId:

getEventTargetByStaticReplayId(replay_id)
{
  let targetid = null;
  
  for(let i = 0; i < this.events.length; i++) {
    let evt = this.events[i];
    
    if(!"static_replay_id" in evt.data)
      continue;
    
    if(evt.static_replay_id === replay_id) {
      targetid = evt.element.id;
      break;
    }
  }
  
  return targetid;
}
Run Code Online (Sandbox Code Playgroud)

编辑2

添加了对应用和重播内联 CSS 样式事件的支持,并更改了长事件名称字符串,例如“SUBINSERT”或“SUBREMOVE”->“SUBINS”、“SUBREM”,以便 UI 工作得更好。

还修复了一些有关突变事件目标和子树元素的错误。还必须重新实现更改元素文本节点文本的工作方式。这里最有趣的一点是,出于某种原因,如果我们尝试使用 更改元素的 textnode 的文本elem.childNodes[0].value = text,MutationObserver 将无法捕获它 - 我想它不算作任何类型的“DOM 树中的更改”。

// Can't use elem.textContent / innerText / innerHTML...
// might accidentally overwrite element's subtree

// Can't do it like this, because MutationObserver can't see this (why??)
// elem.childNodes[0].value = text;

// This is the way
elem.replaceChild(document.createTextNode(text), elem.childNodes[0]);
Run Code Online (Sandbox Code Playgroud)

此时我注意到重播事件时需要一个“清理”过程:

// Used to clean "dirtied" elements before replaying.
// Consider the event flow:
// 1. Create element X
// 2. Change X CSS or subinsert to X
// 3. Remove X
// 4. Replay
// Without cleanup, during replay at step 1, the created element
// will already have "later" CSS changes and subinserts applied to it ahead of time
// because of ""the way we do things"".
// This fixes that issue
__clean() 
{
  // OLD CLEANUP METHOD
  /*while (this.element.lastElementChild) {
    this.element.lastElementChild.style = "";
    this.element.removeChild(this.element.lastElementChild);
  }
  this.element.style = "";
  this.element.innerText = this.element.id;*/
  
  // NEW CLEANUP METHOD (don't have to care what changed)
  // Reset event's element
  this.element = this.originalElement.cloneNode(true);   
  // Must remember to set the original id back
  // Or else it will be the clone's id
  this.element.id = this.originalId;
}
Run Code Online (Sandbox Code Playgroud)

正如代码片段注释中所解释的,存在某些“事件流”,这可能会导致错误的重播结果,例如可以说,某些事件/更改提前应用于元素。

为了解决这个问题,每个事件在实际重播之前都会执行内部“清理过程”。如果实现有点不同,这可能不需要,但现在必须这样做。

当谈到“到底什么需要清洁?”这个问题时,老实说我不确定。据我所知,我们似乎必须清理任何事件可能修改元素的所有内容。

老方法

目前这意味着:

  • 清除子元素的 DOM 子树
  • 重置元素内联样式
  • 将元素的innerText重置为原来的内容(它是id)

新方法

我认为我们可以通过创建每个事件非原始相关元素的克隆,然后在重播之前用原始未更改的克隆替换事件的元素来解决清理过程中需要重置的内容:

class MutationEvent
{
  constructor(recorderInstance, event, element, data)
  {
    this.originalId = this.element.id;
    this.originalElement = this.element.cloneNode(true);
    // Important to change the id to "hide" the clone
    this.originalElement.id = getId("o-");
    ...
  }
}
Run Code Online (Sandbox Code Playgroud)

无论如何,这是代码:

case 'INSERT':
  elem = document.querySelector(`#${$targetid}`);
  if(elem) {
    let actorElement = null;
    
    if(clean) {
      actorElement = this.element.cloneNode(true);
      actorElement.id = 'd-' + Math.random().toString(16).slice(2, 8);
    }
    else {
      actorElement = this.element;
    }
    elem.append(actorElement);
    success = true;
  }
  break;
Run Code Online (Sandbox Code Playgroud)