DOM 内存泄漏

Joel Webber,GWT 团队

更新时间:2009 年 1 月

你可能会问自己:“为什么我必须使用位域来接收 DOM 事件?”,你可能会问自己:“为什么我不能直接向元素添加事件监听器?”如果你发现自己问了这些问题,可能该深入研究 DOM 事件和内存泄漏的浑浊深渊了。

如果你正在从头开始创建小部件(直接使用 DOM 元素,而不是简单地创建复合小部件),那么事件处理的设置通常如下所示

class MyWidget extends Widget {
  public MyWidget() {
    setElement(DOM.createDiv());
    sinkEvents(Event.ONCLICK);
  }

  public void onBrowserEvent(Event evt) {
    switch (DOM.eventGetType(evt)) {
      case Event.ONCLICK:
        // Do something insightful.
        break;
    }
  }
}

这可能看起来有点奇怪,但这是有原因的。为了理解这一点,你可能首先需要学习一下浏览器内存泄漏。网络上有一些不错的资源

所有这些的最终结果是在某些浏览器中,任何涉及 JavaScript 对象和 DOM 元素(或其他本机对象)的引用循环都有一个令人讨厌的趋势,即永远不会被垃圾回收。之所以如此阴险,是因为这是一种在 JavaScript UI 库中非常常见的创建模式。

想象一下以下(原始 JavaScript)示例

function makeWidget() {
  var widget = {};
  widget.someVariable = "foo";
  widget.elem = document.createElement ('div');
  widget.elem.onclick = function() {
    alert(widget.someVariable);
  };
}

现在,我并不是说你会真正用这种方式构建 JavaScript 库,但这可以说明问题。这里创建的引用循环是

widget -> elem(native) -> closure -> widget

有很多不同的方法会导致相同的问题,但它们往往都会形成一个类似于这样的循环。除非你手动执行(通常是通过清除 onclick 处理程序),否则这个循环永远不会被打破。

开发人员尝试解决这个问题的方法有很多。其中比较常见的一种是在 window.onunload 被触发时遍历 DOM,清除所有事件监听器。这存在两个问题

  • 它不会清除不再存在于 DOM 中的元素上的事件。
  • 它不处理长期运行的应用程序,而长期运行的应用程序越来越普遍。

GWT 的解决方案

在设计 GWT 时,我们决定内存泄漏是绝对不可接受的。你不会容忍桌面应用程序中出现严重的内存泄漏,浏览器应用程序也应该如此。不过,这也带来了一些有趣的问题。为了避免创建任何内存泄漏,任何可能需要被垃圾回收的小部件都不能与本机元素形成引用循环。没有办法找出“如果小部件没有参与引用循环,它将何时被回收”。因此,在 GWT 术语中,小部件在与 DOM 分离时不能参与循环。

我们如何强制执行这一点?每个小部件都有一个唯一的“根”元素。每当小部件被附加时,我们都会从元素到小部件创建一个唯一的“反向引用”(即 elem.__listener = widget,在 DOM.setEventListener() 中执行)。这将在小部件被附加时设置,并在小部件被分离时清除。

这让我们回到了 sinkEvents() 方法中使用的奇怪位域。如果你查看 DOM.sinkEvents() 的实现,你会发现它会执行类似于以下的操作

elem.onclick = (bits & 0x00001) ? $wnd.__dispatchEvent : null;

每个元素的事件都指向一个中央调度函数,该函数会查找目标元素的 __listener 扩展名,以便调用 onBrowserEvent()。这样做的妙处在于,它允许我们设置和清除一个扩展名,从而清除任何潜在的事件泄漏。

这意味着在实践中,只要你不使用 JSNI 设置任何引用循环,你就无法编写一个会在 GWT 中泄漏内存的应用程序。我们会在每次发布时都进行仔细的测试,以确保我们没有在底层代码中执行任何操作来引入新的内存泄漏。

当然,缺点是你不能直接将事件监听器挂钩到是小部件元素子元素的元素。相反,你必须在小部件本身接收事件,并找出它是来自哪个子元素。

但这比在用户机器上泄漏大量内存要好,对吧?