如何防止从树中删除的 DOM 节点被虚假的强引用(例如闭包)持有?

How can I prevent a DOM node removed from its tree from being held by spurious strong references, like from closures?

提问人:user3840170 提问时间:10/31/2023 最后编辑:user3840170 更新时间:11/11/2023 访问量:310

问:

举个玩具的例子,假设我有一个时钟小部件:

{
  const clockElem = document.getElementById('clock');

  const timefmt = new Intl.DateTimeFormat(
    'default', { timeStyle: 'medium', });

  setInterval(() => {
    const d = new Date;
    console.log('tick', d, clockElem);
    clockElem.querySelector('p').innerHTML =
      timefmt.format(d);
  }, 1000);

  clockElem.querySelector('button')
    .addEventListener('click', ev => {
      clockElem.remove();
    });
}
<div id="clock">
  <button>Remove</button>
  <p></p>
</div>

当我单击按钮删除时钟时,仍然会调用回调。回调闭包牢固地保留了 DOM 节点,这意味着其资源无法被释放。还有来自按钮事件处理程序的循环引用;尽管也许可以由发动机的循环收集器处理。话又说回来,也许不是。setInterval

不用担心:我可以创建一个辅助函数,确保闭包仅通过弱引用来保存 DOM 节点,并投入以清理计时器。FinalizationRegistry

const weakCapture = (captures, func) => {
  captures = captures.map(o => new WeakRef(o));
  return (...args) => {
    const objs = [];
    for (const wr of captures) {
      const o = wr.deref();
      if (o === void 0)
        return;
      objs.push(o);
    }
    return func(objs, ...args);
  }
};

const finregTimer = new FinalizationRegistry(
  timerId => clearInterval(timerId));

{
  let clockElem = document.getElementById('clock');

  const timefmt = new Intl.DateTimeFormat(
    'default', { timeStyle: 'medium', });

  const timerId = setInterval(
    weakCapture([clockElem], ([clockElem]) => {
      const d = new Date;
      console.log('tick', d);
      clockElem.querySelector('p').innerHTML =
        timefmt.format(d);
    }), 1000);
  
  finregTimer.register(clockElem, timerId);

  clockElem.querySelector('button')
    .addEventListener('click',
      weakCapture([clockElem], ([clockElem], ev) => {
        clockElem.remove();
      }));

  clockElem = null;

  // now clockElem should be held strongly only by the DOM
}
<div id="clock">
  <button>Remove</button>
  <p></p>
</div>
<button onclick="+'9'.repeat(1e9)">Try to force GC</button>

但这似乎行不通。即使在删除节点后,“tick”仍会继续记录到控制台中,这意味着尚未清空,这意味着某些内容似乎仍然具有对 的强引用。鉴于 GC 不能保证立即运行,我当然预计会有一些延迟,但即使我尝试通过运行内存密集型代码(如在控制台中)来强制 GC,弱引用也不会被清除(尽管这足以强制 GC 并在更微不足道的情况下清除弱引用,例如)。这在 Chromium (118.0.5993.117) 和 Firefox (115.3.0esr) 中都会发生。clockElemWeakRefclockElem+'9'.repeat(1e9)new WeakRef({})

这是浏览器中的缺陷吗?或者我错过了其他一些强有力的参考?

(简而言之:这是在 JavaScript 中实现弱事件模式的尝试。

JavaScript DOM 闭包弱 引用

评论

1赞 Bergi 11/4/2023
实际上,您的代码似乎对我有用,当我在“删除”按钮之后点击Chrome devtools中的“收集垃圾”按钮时,它确实停止了日志记录的间隔
1赞 Bergi 11/10/2023
我有一种感觉,这可能也取决于优化水平。请注意,您仍然会创建一个闭包,该闭包在与 相同的范围内声明,并且您可能无法依赖此优化。您可以尝试或在函数末尾设置(使用 )。timefmtclockElemweakCapture([clockElem, timefmt], ([clockElem, timefmt]) => {clockElem = nulllet
0赞 user3840170 11/10/2023
我想我确实尝试了后者,在这个例子的私人副本中。这似乎没有区别。
0赞 Bergi 11/10/2023
"我试图通过运行内存密集型代码来强制 GC“ - 您是否真的使用分析器验证了垃圾回收发生?
0赞 user3840170 11/10/2023
我确认发生了一些垃圾回收。我还从 触发了 GC。没有区别。wr = new WeakRef({}); await +'9'.repeat(1e9); wr.deref()about:memory

答:

7赞 KooiInc 11/4/2023 #1

[Element].remove()DOM 中删除(“断开连接”),但不从内存中删除。只要没有结束,它就会继续愉快地运行,从内存中取出元素。ElementsetInterval

因此,结束 timer 函数将使元素可进行垃圾回收(当然,如果没有其他引用)。

您可以使用其 isConnected 属性检查 DOM 中是否存在元素,如果该元素不再连接,则结束计时器。

对于时钟示例,例如(更改为更易于管理)。setIntervalsetTimeout

document.addEventListener('click', ev => 
  ev.target.closest(`#clock`)?.remove());
let timer;
const log = t => document.querySelector(`#log2Screen`).textContent = t;
const clockElem = document.getElementById('clock');
const timefmt = new Intl.DateTimeFormat('default', { timeStyle: 'medium', });

run();

function run() {
  if (!clockElem.isConnected) {
    console.log(
      `div#clock exists in memory:`, 
      clockElem ); 
    // clear timer when #clock not in DOM
    return clearTimeout(timer); 
  }
  const d = timefmt.format(new Date);
  log(`tick ${d}`);
  clockElem.querySelector('p').textContent = d;
  timer = setTimeout(run, 1000);
}
#log2Screen {
  color: green;
}
<div id="clock">
  <button id="remove">Remove</button>
  <p></p>
</div>
<div id="log2Screen"></div>

备选方案 1:从 DOM 中删除元素时,可以结束计时器(以此类推,删除 )。div#clock

let timer;
document.addEventListener('click', ev => {
  ev.target.closest(`#clock`)?.remove();
  clearTimeout(timer); // <= clear timer
} );

const log = t => document.querySelector(`#log2Screen`).textContent = t;
const clockElem = document.getElementById('clock');
const timefmt = new Intl.DateTimeFormat('default', { timeStyle: 'medium', });

run();

function run() {
  const d = timefmt.format(new Date);
  log(`tick ${d}`);
  clockElem.querySelector('p').textContent = d;
  timer = setTimeout(run, 1000);
}
#log2Screen {
  color: green;
}
<div id="clock">
  <button id="remove">Remove</button>
  <p></p>
</div>
<div id="log2Screen"></div>

备选方案 2:您可以将元素的赋值定位在定时器函数中,并且仅在仍连接到 DOM 时才继续定时器。#clockdiv#clock

document.addEventListener('click', ev => {
  ev.target.closest(`#clock`)?.remove();
} );

let [timer, tick] = [, 0];
const log = t => document.querySelector(`#log2Screen`).textContent = t;
const timefmt = new Intl.DateTimeFormat('default', { timeStyle: 'medium', });

run();

function run() {
  const clockEl = document.querySelector('#clock p');
  
  if (clockEl) { // <= only run when #clock in DOM
    log(`tick ${++tick}`);
    clockEl.textContent = timefmt.format(new Date);
    return timer = setTimeout(run, 1000);
  }
  
  clearTimeout(timer);
  log(`Clock deactived on ${timefmt.format(new Date)}`);
}
#log2Screen {
  color: green;
}
<div id="clock">
  <button id="remove">Remove</button>
  <p></p>
</div>
<div id="log2Screen"></div>

-3赞 Rajnish Giri 11/8/2023 #2

这里用于删除节点的回调可用于清除声明时存储的间隔。

{
  const clockElem = document.getElementById('clock');

  const timefmt = new Intl.DateTimeFormat(
    'default', { timeStyle: 'medium', });

  const interval = setInterval(() => {
    const d = new Date;
    console.log('tick', d, clockElem);
    clockElem.querySelector('p').innerHTML =
    timefmt.format(d);
  }, 1000);

  clockElem.querySelector('button')
    .addEventListener('click', ev => {
      clearInterval(interval);
      clockElem.remove();
  });
}
<div id="clock">
  <button>Remove</button>
  <p></p>
</div>

评论

2赞 Bergi 11/9/2023
问题不在于何时/如何删除节点并清除间隔,而在于为什么弱引用和FinalisationRegistry