A.4 应对内存泄漏的风险

JavaScript使用一种称为垃圾收集的技术来管理分配给它的内存。这与C这样的低级语言不同,C要求程序员明确地预定内存空间,并在这些内存不再使用时释放它们。其他语言,比如Objective-C,实现了一个引用计数系统来辅助程序员完成这些工作。通过这个引用计数系统,程序员能够了解到有多少个程序块使用了一个特定的内存段,因而可以在不需要时清除这些内存段。另一方面,JavaScript是一种高级语言,它一般是通过后台来维护这种计数系统。

当JavaScript代码生成一个新的内存驻留项时(比如一个对象或函数),系统就会为这个项留出一块内存空间。因为这个对象可能会被传递给很多函数,并且会被指定给很多变量,所以很多代码都会指向这个对象的内存空间。JavaScript会跟踪这些指针,当最后一个指针废弃不用时,这个对象占用的内存会被释放。以图A-1中的指针链接为例。

A.4 应对内存泄漏的风险 - 图1

图 A-1

图中的对象A有一个属性指向B,而B也有一个属性指向C。即使当前作用域中只有对象A有效,但由于指针的关系所有3个对象都必须保留在内存中。当离开A的当前作用域时(例如代码执行到声明A的函数的末尾处),垃圾收集器就可以释放A占用的内存。此时,由于没有什么指向B,因此B可以释放,最后,C也可以释放。

然而,当对象间的引用关系变得复杂(如图A-2所示)时,处理起来也会更加困难。

A.4 应对内存泄漏的风险 - 图2

图 A-2

这里,我们又为对象C添加了一个引用B的属性。在这种情况下,当A释放时,仍然有来自C的指针指向B。这种引用循环需要由JavaScript进行特殊的处理,但必须考虑到整个循环与作用域中的其他变量已经处于隔离状态。

A.4.1 避免意外的引用循环

闭包可能会导致在不经意间创建引用循环。因为函数是必须保存在内存中的对象,所以位于函数封闭环境中的所有变量也需要保存在内存中:

代码清单A-20

  1. function outerFn() {
  2. var outerVar = {};
  3. function innerFn() {
  4. console.log(outerVar);
  5. }
  6. outerVar.fn = innerFn;
  7. return innerFn;
  8. };

这里创建了一个名为outerVar的对象,该对象在内部函数innerFn()中被引用。然后,为outerVar创建了一个指向innerFn()的属性,之后返回了innerFn()。这样就在innerFn()上创建了一个引用outerVar的闭包,而outerVar又引用了innerFn()

这会导致变量在内存中存在的时间比想象得还要长,而且又不容易被发现。但是,也可能会出现比这种情况更隐蔽的引用循环:

代码清单A-21

  1. function outerFn() {
  2. var outerVar = {};
  3. function innerFn() {
  4. console.log('hello');
  5. }
  6. outerVar.fn = innerFn;
  7. return innerFn;
  8. };

这里我们修改了innerFn(),使它不再引用outerVar。但是,这样做仍然没有断开循环。即使innerFn()不再引用outerVarouterVar也仍然位于innerFn()封闭环境中。由于闭包的原因,位于outerFn()中的所有变量都隐含地被innerFn()所引用。因此,闭包会使意外地创建这些引用循环变得易如反掌。

A.4.2 控制DOM与JavaScript的循环

上述这些情况通常不是什么问题,因为JavaScript能够检测到这些情况并在它们孤立时将其清除。然而,旧版本IE中存在一种难以处理的引用循环问题。当一个循环中同时包含DOM元素和常规JavaScript对象时,IE无法释放任何一个对象——因为这两类对象是由不同的内存管理程序负责管理的。换句话说,除非关闭浏览器,否则这种循环在IE中永远得不到释放。为此,随着时间的推移,这可能会导致大量内存被无效地占用。导致这种循环的一个常见原因是简单的事件处理程序:

代码清单A-22

  1. $(document).ready(function() {
  2. var button = document.getElementById('button-1');
  3. button.onclick = function() {
  4. console.log('hello');
  5. return false;
  6. };
  7. });

当指定单击事件处理程序时,就创建了一个在其封闭的环境中包含button变量的闭包。而且,现在的button也包含一个指向闭包(onclick属性自身)的引用。这样,就导致了在IE中即使离开当前页面也不会释放这个循环。

为了释放内存,就需要断开循环引用,例如在关闭窗口关删除onclick属性(此时必须注意不要在window及其onunload处理程序间引入新的循环)。另外,也可以像下面这样重写代码来避免这种闭包:

代码清单A-23

  1. function hello() {
  2. console.log('hello');
  3. return false;
  4. }
  5. $(document).ready(function() {
  6. var button = document.getElementById('button-1');
  7. button.onclick = hello;
  8. });

因为hello()函数不再包含button,引用就成了单向的(从buttonhello)、不存在的循环,所以就不会造成内存泄漏了。

用jQuery化解引用循环

下面,我们通过常规的jQuery结构来编写同样的代码:

代码清单A-24

  1. $(document).ready(function() {
  2. var $button = $('#button-1');
  3. $button.click(function(event) {
  4. event.preventDefault();
  5. console.log('hello');
  6. });
  7. });

即使此时仍然会创建一个闭包,并且也会导致同前面一样的循环,但这里的代码却不会使IE发生内存泄漏。由于jQuery考虑到了内存泄漏的潜在危害,所以它会手动释放自己指定的所有事件处理程序。只要坚持使用jQuery的事件绑定方法,就无需为这种特定的常见原因导致的内存泄漏而担心。

但是,这并不意味着我们完全脱离了险境。当对DOM元素进行其他操作时,仍然要处处留心。只要是将JavaScript对象指定给DOM元素,就可能在旧版本IE中导致内存泄漏。jQuery只是有助于减少发生这种情况的可能性。

有鉴于此,jQuery为我们提供了另一个避免这种泄漏的工具。在第12章中我们曾看到过,使用.data()方法可以像使用扩展属性(expando)一样,将信息附加到DOM元素。由于这里的数据并非直接保存在扩展属性中(jQuery使用一个内部对象并通过它创建的ID来保存这里所说的数据),因此永远也不会构成引用循环,从而有效回避了内存泄漏问题。无论什么时候,当我们觉得扩展属性好像是一种方便的数据存储机制时,都应该首选.data()这种更安全可靠的替代方案。