当dom节点不存在的时候,怎么为它挂载事件?

2,815 阅读4分钟

背景

有时候,我们需要在没有存在的dom上挂载事件。

出现这样的场景的原因多种多样,比如在处理页面异步加载的一些第三方脚本的时候就经常会遇到,比如说,我最近需要每当用户关闭Google reCAPTCHA’s (v2) challenge 的时候,都需要更新页面UI,但是这个工具不支持这样的事件响应,所以我打算做一个事件监听,尝试querySelector访问节点时,发现节点为空,因为浏览器在页面加载的时候还没有这个节点,无法确切的知道这个dom何时会挂载。

为了进一步探讨这一点,我写了一个按钮,0到5s内再让其挂载到页面上,如果一开始就给按钮挂载事件,会得到一个错误

// Simulating lazily-rendered HTML:
setTimeout(() => {
 const button = document.createElement('button');
  button.id = 'button';
  button.innerText = 'Do Something!';

  document.body.append(button);
}, randomBetweenMs(1000, 5000));

document.querySelector('#button').addEventListener('click', () => {
 alert('clicked!')
});

// Error: Cannot read properties of null (reading 'addEventListener')

所以,上述代码在调用栈中立即执行(当然settimeout回调除外),当试图访问这个按钮的时候,是null。

可能尝试轮询?

这种情况下,通常会采用轮询获取dom直到dom出现,可能通过setInterval 或者 setTimeout 时间,像下面这样的代码

function attachListenerToButton() {
  let button = document.getElementById('button');

  if (button) {
    button.addEventListener('click', () => alert('clicked!'));
    return;
  }

 // If the node doesn't exist yet, try
 // again on the next turn of the event loop.
  setTimeout(attachListenerToButton);
}

attachListenerToButton();

或者基于promise实现

async function attachListenerToButton() {
  let button = document.getElementById('button');

  while (!button) {
  // If the node doesn't exist yet, try
  // again on the next turn of the event loop.
    button = document.getElementById('button');
    await new Promise((resolve) => setTimeout(resolve));
  }

  button.addEventListener('click'() => alert('clicked!'));
}

attachListenerToButton();

无论哪种方式,这种策略都是有不小的成本的,主要是性能方面。在这两个版本中,删除setTimeout() 会导致脚本同步执行,从而阻塞主线程以及需要在其上执行的其他任务,不会处理输入事件,tab也不能点击。

使用setTimeout()(setInterval)将会在下一次事件循环中执行,期间可以执行其他同步任务。但是始终反复占用调用栈,等待节点出现,如果你希望很好管理事件,这么做远远不够。

这时可能想到,通过增加时间间隔,减少调用对。但是在节点出现和执行上下文之间,可能存在一些意外状况,比如,正在绑定事件的时候之前,不希望用户触发事件。

MutationObserver()

MutationObserver API 已经出现一段时间了,现代浏览器也能很好的支持,主要是用作dom树发生变化时,执行一些操作。作为一个原生API,不需要担心像轮训这样的性能问题,基本代码如下

const domObserver = new MutationObserver((mutationList) => {
 // document.body has changed! Do something.
});

domObserver.observe(document.body, { childListtruesubtreetrue });

针对这个例子,可以每次当dom树变化时,查询特定节点,如果存在就添加事件绑定。

const domObserver = new MutationObserver(() => {
  const button = document.getElementById('button');

  if (button) {
    button.addEventListener('click'() => alert('clicked!'));
  }
});

domObserver.observe(document.body, { childListtruesubtreetrue });

oobserve 的参数很重要,设置childList为 true,会使观察者监视目标节点的更改,subtree: true 会导致该目标节点的子节点也会受到监视。

不管怎样,当不知道节点什么时候被注入到页面时,这种特定的配置方式是很好的,如果确认他会出现在某个元素中,将目标节点的定位设置的更小,会更加友好。

清理

根据上方代码,每次触发dom更改后,都会向同一个按钮添加一个点击事件,可以通过传入函数的方式设置click事件回调(addEventListener()不会向同一个节点设置具有相同引用的回调函数),但是强制清理观察器其实更为直观,比如直接利用observer的方法

const domObserver = new MutationObserver((_mutationList, observer) => {
  const button = document.getElementById('button');

  if (button) {
    button.addEventListener('click'() => console.log('clicked!'));

  // No need to observe anymore. Clean up!
    observer.disconnect();
  }
});

它的响应能力怎样?

第一种方案提到了怎么在响应dom更改时使用更少的时间,这其实取决于设置的时间间隔的大小,但是setTimeout() 和 setInterval() 都还是在主任务的时间循环队列中运行。

MutationObserver 在微任务队列上触发回调,所以在触发回调之前不需要等待完成的事件循环,它的响应速度更快。

在浏览器中使用performance.now()做了初步的实验,用来查看在dom挂载后,事件监听器添加到按钮所需的时间(setTimeout()设置的是无延迟的),所以看到的延迟就是时间循环本身的速度,结果如下:

ApproachDelay to Add Listener
polling~8ms
MutationObserver()~.09ms

这个性能差异很明显,用0延迟的轮询setTimeout()是 使用MutationObserver 的88倍。

总结

对比这个性能报告,MutationObserver 比轮训相比赢麻了,实际上可以探寻更多MutationObserver的使用场景。

翻译原文:www.macarthur.me/posts/use-m…