7

小弟也是初学JS,请教一下各位大牛!

代码片段:

(function() {
    var li = document.getElementById('nav').getElementsByTagName('li'),
        i = 0;
    for (; i < li.length; i++) {
        li[i].index = i;
        li[i].onmouseover = function() {
            for (var j = 0; j < li.length; j++) {
                li[j].getElementsByTagName('a')[0].className = '';
            }
            li[this.index].getElementsByTagName('a')[0].className = 'current';
        }
    }
}());

我就是想问下这里的 li 变量,当函数执行完成时,有没有得到内存的释放?

我个人认为觉得并没有得到释放,因为 onmouseouver 事件,闭包引用了 li 外面作用域的变量,所以垃圾回收器,标记一直是1,所以没有得到释放。

如果要想释放内存,以上代码应该怎样改呢?

M_J 114
2014-04-03 提问

查看全部 6 个回答

42

已采纳

谢邀。

首先我要说一句题主可能不爱听的话,但是我不得不说:我讨厌这种问题。我讨厌这种问题的原因有很多,但是为了不跑题我只说一点,最最实际的一点,就是成天琢磨这种问题事倍功半,得不偿失。

就题目给出的代码,根本不会造成内存泄露(下面我会证明),也没有必要释放内存,因为你的事件就绑定在 li 们的身上,你代码的意图又是一定要利用这些事件的回调函数,除非你确定在某一时刻之后不再需要这些事件监听了,否则你把 li 对象释放掉(回收)有什么意义呢?你干脆别写这些代码不就好了吗?

我们在看一些介绍内存泄露的文章时,经常会读到类似的示例代码,但那是纯演示性质的,而不是告诉你:“在实际中这是错的,你不应该这样做!”真要是这样,我们啥都别干了……(当然,有一些最佳实践是公认的,比如 null 化不需要的对象引用)。

我们应该明白“内存泄露”的本质是什么。以 V8 为例,GC(垃圾回收)的工作需要建立在跟踪“活着的对象”的基础上,所谓“活着”就是指现在占用了内存资源的,并且是我们真的在用的,比如本题中的 li 们。对应的还有一种叫做“死掉的对象”,是指已经被回收的,或者说正确的、恰当的被释放的那些对象们——这里有一个很重要的前提,就是这些对象是真的没用了!不管是在代码逻辑里没用了,还是在业务逻辑没用了,我们回收它们是因为没用了才回收,而不是因为它们占用内存而回收!!!

这就是我讨厌这个问题的本质原因,明明就是要绑定事件在这些 li 身上(代码逻辑),而且之后也肯定要一直保持它有效(业务逻辑,这是一个导航条,肯定要一直用的),却又纠结要不要回收它,蛋疼?

回过头继续说,以上两者:“活对象”和“死对象”,都不是造成内存泄露的问题,真正需要关注的,是那些“无法追踪的对象”,有时候我们称之为“幽灵对象”。这些对象的特点是:

  1. 它们活着:没有任何显式或隐式的方式将它们置为可以被 GC 回收的状态,比如 null化它,或者代码里没有对于它的引用;
  2. 它们没用:虽然它们以某种方式存在于运行环境中,但是以后都不再有任何用处了。

这样的对象才是会造成内存泄露的罪魁祸首。举个例子:

...
close: function () {
    var $this = $(this);
    $('<span class="close">X</span>')
        .prepend(this)
        .on('click', function () {
            $this.slideUp(300);
        });
}
...

这是一个 jQuery 插件的部分代码,它的作用是在 $this(无所谓这是什么,不重要)对象上插入一个 span 来充当关闭按钮,然后当 span 点击的时候,把 $this 收起来。

这里的 span 就是我们提到的“幽灵对象”,看下图:

幽灵对象

这是我们在页面上三次调用此插件后的结果,我们看到 DOM 树里产生了三个一模一样的 <span>(实际上只用到了一个),并且更要命的是,每一个都绑定了一模一样的匿名的事件回调函数。这才是内存泄露!试想如果这个部分在你的业务逻辑里是需要经常交互的,会产生多少这样无用但是活着的对象以及绑定于其上的事件回调函数对象?

这个例子非常简单,对吗?根本就不难理解,但却是在实践中最容易犯的错误!这才是每一位开发者应该关注的地方,别搞错了方向。再强调一遍:没用的活着的对象才是需要回收的垃圾,有用的活着的对象虽然占用了内存,但是不需要被回收,因为它们本该如此!

顺便插一句,如果上例中的回调函数是具名函数,情况会稍微得到缓解,因为所有注册的 click 事件的回调函数都是指向同一个具名函数,而不是每次产生一个匿名函数,这就是为什么很多代码规范里禁止过度使用匿名函数的原因,这也是一条避免内存泄露的最佳实践。

上例要如何优化?避免内存泄露不一定非得靠释放或者 null 化,在上例中我们只需要一个 span 就够用了,所以简单的判断其是否存在即可:

...
close: function () {
    var $this = $(this);

    if ($this.find('.close').length) return;    // 保护伞

    $('<span class="close">X</span>')
        .prepend(this)
        .on('click', function () {
            $this.slideUp(300);
        });
}
...

OK,我举的这个例子貌似不是题主关心的东西,但我真的不是在跑题,而是因为原题的诉求明显是自相矛盾的。如果你要的就是监听 mouseover 然后切换 .current,为什么你要把 li 对象回收掉?如果回收掉了,你要怎么继续保持监听?

另外一个大家关心的问题是:我这么写到底对不对?那这个问题要从两方面来说,一是对不对,二是好不好。

对不对?代码的实现是 OK 的,能满足业务需求,所以答案很简单:对。

好不好?这个……如果你没有谱,我们来实践一下看看从检查内存占用的角度如何检测你的代码运行效率。

一个前提:占用内存是必须的,因为你的代码总要发挥作用不是?所以别龟毛这个,这个世界不存在“占用内存为 0 且有用的”程序。

然后,我们写一个能实际运行的页面来做测试基准:

<!doctype html>
<html>
<head>
  <title> Test of Memory Leaking </title>
</head>
<body>


<ul id="nav">
    <li><a href="#"> Nav 1 </a></li>
    <li><a href="#"> Nav 2 </a></li>
    <li><a href="#"> Nav 3 </a></li>
  </ul>





<script>
  (function() {
      var li = document.getElementById('nav').getElementsByTagName('li'),
          i = 0;
      for (; i < li.length; i++) {
          li[i].index = i;
          li[i].onmouseover = function() {
              for (var j = 0; j < li.length; j++) {
                  li[j].getElementsByTagName('a')[0].className = '';
              }
              li[this.index].getElementsByTagName('a')[0].className = 'current';
          }
      }
  }());
  </script>


</body>
</html>

我们将要考量两个方面:

  1. 核实回调函数的注册是否有效,是否有进一步优化的空间。

  2. 查看内存占用:大量执行监听回调,看看内存的上涨情况是否出乎预期。

我先用开发者工具里的 Timeline,观察代码执行情况(如图)

first 5 secs

这是从默认页面(我带开了隐匿模式,因为不想让插件出现在这里混淆视听)到载入测试页面前五秒发生的事情。我们能看到什么?Well,信息量很大,不过我们可以观察到以下几件事情:

  1. 左上我切换到 Memory,可以看到这个时间段内存变化是平缓的,没有大幅波动

  2. 左中 RECORDS 栏:

    1. 页面载入时,GC 强制执行了一次,尽可能为新页面提供可用的内存
    2. 接着是页面渲染,这个我们不关心
  3. 左下 COUNTERS 栏:

    1. Documents & Nodes 很正常
    2. Listeners 显示 3,hum……也许这里值得改进(为什么一定要三个监听回调?为什么要用匿名函数??为什么???记住,每一个函数都是对象,它们会占用内存,当函数内容其实是一样的时候,为什么要重复?)

接着,我们继续测量,持续了一分多钟不断触发 mouseover 事件,结果如下图:

last 1 min

  1. Memory 开始持续上涨,但是没有明显的波动,这说明我们代码的执行占用是平缓的,没有出乎意料的地方。

    可能你比较纠结于这个上涨的图形和 2.1Mb ~ 2.3Mb 变化,但是不要忘了这是在一分钟内疯狂触发了 1100+ 次 mouseover 事件之后的结果,实际中谁会无聊到这么做?这是一次极限测试,而不是常规测试,测试的结果是好的,完全不必纠结。

  2. 中间随机抽取了部分时段的 Event 事件,如果你点开就会看到回调函数执行的占用情况。看一下右边,每一次执行占用的堆大小仅仅是 164B,完全 OK。

  3. 下面计数那里没有变化,没有额外的文档、节点以及监听回调产生,perfect!

这是一个非常非常正常的测试页面,实在没什么好担心的(我都不知道花时间在这上面有什么意义?只做这一次!)但是回到最开始的问题:好不好?的确,这段代码有值得改善的空间,也主要集中在两点:

  1. 是不是一定要把事件注册在 li,然后触发的时候再通过遍历 li 去处理所有的 a

    这不是代码逻辑的问题(当然,短短的代码两次遍历 li 集合也的确糟糕),而是对浏览器事件模型的理解问题。事实上,我们可以只把事件注册给 a 集合的共同祖先 ul#nav,然后通过事件委托(冒泡)获取当前事件对象的目标 event.target。这样无需遍历就解决了添加 .current 的问题。

    移除其他的 .current 需要遍历了,但是也不必用到 li(假设你是要把 .current 加到 a 上面去),直接使用 ul#nav 去找就可以。

  2. 是不是可以使用具名函数来分离代码逻辑,并且减少事件监听回调函数的数目?

    当然!我们应该遵循这个公认的最佳实践。首先把两步处理抽离,这样易读易维护;其次减少注册的回调函数,最终我做了以下的修改:

<!doctype html>
<html>
<head>
  <title> Test of Memory Leaking </title>
</head>
<body>


<ul id="nav">
    <li><a href="#"> Nav 1 </a></li>
    <li><a href="#"> Nav 2 </a></li>
    <li><a href="#"> Nav 3 </a></li>
  </ul>





<script>
  (function() {
      var nav = document.getElementById('nav'),
          links = nav.getElementsByTagName('a'),
          linksLength = links.length;

      var removeCurrentClasses = function () {
          for (var i = 0; i < linksLength; i++) {
              links[i].className = '';
          }
      }

      var toggleCurrentClass = function (e) {
          if (e.target.tagName === 'UL' || e.target.tagName === 'LI') {
              return;
          }
          removeCurrentClasses();
          e.target.className = 'current';
      };

      nav.onmouseover = toggleCurrentClass;
  }());
  </script>


</body>
</html>

结果如下图:

final

没什么变化?当然了,原先的代码也不存在内存泄露问题(不过也快了……),当然不会有什么显著的变化。我所做的只是几个小小的代码优化:

  1. 分离逻辑,提高可读性(个人最看重的地方)
  2. 减少遍历,减少对中止条件的求值次数
  3. 只有一次事件绑定,也只有一个监听回调(它还调用了另外一个原本混合在一起的函数,不过这个无关紧要)

这些东西在本例中对于内存占用的优化微乎其微,因为本来就没什么好优化的。

Another thing,也许你应该考虑把 .currentli 而不是 a,这样在利用 ul#nav 向下查找子节点时可以少一层,这点改进看起来太微乎其微了,不过它会给 CSS 带来些许便利,使得选择符及可控灵活性都有一些提升。而最后的代码为了不触发行为在 ul#navli 身上,做了保护层,你也可以在这层保护之内继续向下寻找特定的那个 a 为它加上 .current,我就直接返回了。在现实里我根本就不会这样设计这段代码。(又及:CSS 调整的好,可以避免 e.targetul#navli 的情况,鼠标始终只能碰到 a,不过这也是很琐碎的事情,我根本就不会考虑这种实现)

小小的一段代码我稀里哗啦写一大堆,实在是没必要,而且最终也没能满足楼主的期望,既要业务实现,还要把 li 回收掉(不过我给出的代码实际上从另外一个角度解决了这个问题,我根本就没有生成 li 对象)。不过我希望题主会觉得我写的很多看似跑题的东西是有价值的。以上所有,归根结底是为了表达以下几个点(总结):

  1. 研究内存泄露请从实际出发(类似本题的小例子或许只有理论价值,really cares?),要将代码逻辑、业务逻辑、使用场景等要素结合起来
  2. 理解内存泄露的本质:活着的没用的对象才会造成内存泄露,有用的对象不存在这么一说。
  3. 通过侦测、统计和观察数据来判断是否有存在内存泄露的地方,之后再做进一步具体处理,不要仅靠理论来猜测(现实总比理论复杂)

推广链接