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

use*_*170 -2 javascript dom closures weak-references

对于一个玩具示例,假设我有一个时钟小部件:

\n

\r\n
\r\n
{\n  const clockElem = document.getElementById(\'clock\');\n\n  const timefmt = new Intl.DateTimeFormat(\n    \'default\', { timeStyle: \'medium\', });\n\n  setInterval(() => {\n    const d = new Date;\n    console.log(\'tick\', d, clockElem);\n    clockElem.querySelector(\'p\').innerHTML =\n      timefmt.format(d);\n  }, 1000);\n\n  clockElem.querySelector(\'button\')\n    .addEventListener(\'click\', ev => {\n      clockElem.remove();\n    });\n}
Run Code Online (Sandbox Code Playgroud)\r\n
<div id="clock">\n  <button>Remove</button>\n  <p></p>\n</div>
Run Code Online (Sandbox Code Playgroud)\r\n
\r\n
\r\n

\n

当我单击按钮删除时钟时,setInterval回调仍然被调用。回调闭包强力持有 DOM 节点,这意味着它的资源无法被释放。还有来自按钮事件处理程序的循环引用;尽管也许这个可以由 engine\xe2\x80\x99s 循环收集器处理。话又说回来,也许不是。

\n

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

\n

\r\n
\r\n
const weakCapture = (captures, func) => {\n  captures = captures.map(o => new WeakRef(o));\n  return (...args) => {\n    const objs = [];\n    for (const wr of captures) {\n      const o = wr.deref();\n      if (o === void 0)\n        return;\n      objs.push(o);\n    }\n    return func(objs, ...args);\n  }\n};\n\nconst finregTimer = new FinalizationRegistry(\n  timerId => clearInterval(timerId));\n\n{\n  let clockElem = document.getElementById(\'clock\');\n\n  const timefmt = new Intl.DateTimeFormat(\n    \'default\', { timeStyle: \'medium\', });\n\n  const timerId = setInterval(\n    weakCapture([clockElem], ([clockElem]) => {\n      const d = new Date;\n      console.log(\'tick\', d);\n      clockElem.querySelector(\'p\').innerHTML =\n        timefmt.format(d);\n    }), 1000);\n  \n  finregTimer.register(clockElem, timerId);\n\n  clockElem.querySelector(\'button\')\n    .addEventListener(\'click\',\n      weakCapture([clockElem], ([clockElem], ev) => {\n        clockElem.remove();\n      }));\n\n  clockElem = null;\n\n  // now clockElem should be held strongly only by the DOM\n}
Run Code Online (Sandbox Code Playgroud)\r\n
<div id="clock">\n  <button>Remove</button>\n  <p></p>\n</div>\n<button onclick="+\'9\'.repeat(1e9)">Try to force GC</button>
Run Code Online (Sandbox Code Playgroud)\r\n
\r\n
\r\n

\n

但这似乎不起作用。即使在clockElem删除节点后, \xe2\x80\x98tick\xe2\x80\x99 仍会继续记录到控制台,这意味着WeakRef尚未清空,这意味着某些东西似乎仍然持有对 的强引用clockElem。鉴于 GC 不能保证立即运行,当然,我预计会有一些延迟,但即使当我尝试通过运行像+\'9\'.repeat(1e9)在控制台中那样占用大量内存的代码来强制 GC 时,弱引用也不会被清除(尽管这足以强制在更琐碎的情况下(例如new WeakRef({})),GC 和清除弱引用。这种情况在 Chromium (118.0.5993.117) 和 Firefox (115.3.0esr) 中都会发生。

\n

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

\n

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

\n

Koo*_*Inc 7

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

因此,结束计时器函数将使该元素成为可垃圾回收的(当然,如果没有其他引用的话)。

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

对于时钟示例(更改setInterval为更易于管理setTimeout)。

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);
}
Run Code Online (Sandbox Code Playgroud)
#log2Screen {
  color: green;
}
Run Code Online (Sandbox Code Playgroud)
<div id="clock">
  <button id="remove">Remove</button>
  <p></p>
</div>
<div id="log2Screen"></div>
Run Code Online (Sandbox Code Playgroud)

替代方案 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);
}
Run Code Online (Sandbox Code Playgroud)
#log2Screen {
  color: green;
}
Run Code Online (Sandbox Code Playgroud)
<div id="clock">
  <button id="remove">Remove</button>
  <p></p>
</div>
<div id="log2Screen"></div>
Run Code Online (Sandbox Code Playgroud)

#clock替代方案 2:您可以将元素的分配定位在计时器函数中div#clock,并且仅在仍连接到 DOM时才继续计时器。

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)}`);
}
Run Code Online (Sandbox Code Playgroud)
#log2Screen {
  color: green;
}
Run Code Online (Sandbox Code Playgroud)
<div id="clock">
  <button id="remove">Remove</button>
  <p></p>
</div>
<div id="log2Screen"></div>
Run Code Online (Sandbox Code Playgroud)