«

»

11 2013

Writing Fast, Memory-Efficient JavaScript

纯翻译文,原文是作者Addy Osmani于2012-11-05发布于Smashing Magazine,原文地址。文中如果出现英文原文,如果不是专有名词,则表示该处的原文我不太理解,无法进行翻译。

————————————————————————————————–

如Google的V8 (Chrome, Node)之类的JavaScript引擎是专门为了大型JavaScript项目的运行而特别设计的。当你开发的时候,如果你对内存使用和性能非常敏感的话,你应当明白用户浏览器屏幕后面的JavaScript引擎里发生了些什么。

无论是V8还是SpiderMonkey (Firefox), Carakan (Opera), Chakra (IE) 或者其他什么JavaScript引擎,了解他们的运行机制将会使你能更好地优化你的应用。当然,这并不是指你需要针对某一种浏览器或者JavaScript引擎进行代码优化。永远不要这么做。

你需要经常问自己几个问题:

  • 在我的代码中还有什么地方是能做性能优化的?
  • 主流的JavaScript引擎都做了点什么优化?
  • 有什么地方是引擎无法优化的,是否GC按照我期望地把资源都回收了?

当你想写内存优化并速度快的代码的时候,你会发现有很多陷阱。在这篇文章中,我们将会展示一些经过验证的手段使得你写出的代码运行更好。

那么,JavaScript在V8中究竟是怎么运作的?

虽然在你开发大型应用的时候可能并没有完全彻底地理解JavaScript引擎,但是任何车主都会告诉你,他们至少打开过引擎罩一次来看看下面的东西。我选择Chrome作为我的浏览器,我将会稍微聊聊Chrome的JavaScript引擎。V8是由一些非常关键的部分组成的。

  • 基础编译器,它会将解析你写的JavaScript代码,并编译成原生的机器码交付执行,而不是直接执行字节码或者简单地解释执行它。这种代码并没有很好地优化过。
  • V8会将你代码中的objects解释成object model。在JavaScript中,objects是作为关系型数组进行解释的,而V8则是将它们解释为hidden classes,是一种为了更高效的查找而特别优化过的内部类型系统。
  • 运行时profiler,监控正在运行的应用,并找出“HOT”的函数(运行耗时非常长的函数)。
  • 优化过的编译器,重新编译代码,并优化在上一点中被认定为“HOT”的代码,并以类似inlining的方式进行优化(i.e. replacing a function call site with the body of the callee)。
  • V8支持反优化,meaning the optimizing compiler can bail out of code generated if it discovers that some of the assumptions it made about the optimized code were too optimistic.
  • V8有垃圾回收机制,理解这个机制和优化代码JavaScript一样重要。

垃圾回收

垃圾回收就是内存管理。简单来说就类似有一个回收人员,将系统中不再被使用的对象所占用的内存重新回收回来,使得程序得意再次利用这些内存。在拥有垃圾回收机制的语言,类似JavaScript中,被你的程序引用的对象,将不会被回收。

在大部分的情况下,手动解除对象引用是不需要的。简单地将变量放在他们应该在的地方(简单来说,就是尽量在本地作用域使用变量,i.e. inside the function where they are used versus an outer scope)。

在JavaScript中,你无法强制进行垃圾回收。你也不会想要这么做,因为垃圾回收动作是有运行时环境进行控制的,通常来说,它自己最明白什么时候应该进行垃圾回收。

对于解除引用的误解

在网络上有很多关于如何在JavaScript中回收内存的讨论,delete关键词被多次提及,尽管如此,它被建议用来删除map中的键值,某些程序员认为你可以使用这种方法强制删除引用。

尽可能地避免使用delete,在下面的例子中,delete o.x事实上造成了比优化更多的劣化。因为它将对象o的hidden class转换成了传统的慢得多的对象。

var o = { x: 1 }; 
delete o.x; // true 
o.x; // undefined

你差不多肯定可以在许多流行的JavaScript类库中找到delete的使用 – 在这门语言中,这是有特定目的的。主要的目的就是避免在运行时修改HOT对象的结构。JavaScript引擎会探测到这些HOT对象,并尝试优化它们。如果在这些对象的生命周期中,其结构并没有发生剧烈变化的话,这种优化是非常容易的。但是delete会触发这种剧烈变化。

null的使用,也经常受到误解。将对象的引用设成null并未将对象设成null,它仅仅将对象的引用设成了null。使用o.x = null要比delete o.x好,但是有的时候不一定有必要这么做。

var o = { x: 1 }; 
o = null;
o; // null
o.x // TypeError

如果当前的引用是对象的最后一个引用,则该对象将会被垃圾回收。如果该引用并非为该对象的最后一个引用,该对象将继续可用,并不会被垃圾回收。

另外很重要的一点是,全局变量在脚本的生命周期中并不会被垃圾回收。无论脚本运行多长时间,JavaScript运行时全局变量将常驻内存。

var myGlobalNamespace = {};

全局变量将会在你刷新页面,导航到别的页面,关闭tab,关闭页面的时候被清除掉。函数级变量将会在函数运行结束,且没有任何其他引用指向它的时候被清除掉。

经验原则

为了让垃圾回收机制尽可能快、尽可能多地回收内存,请不要保持任何引用到你已经不再使用的对象。下面有几点,请铭记在心:

  • 就像上面所提的,在适当的作用域使用变量。举例来说,比起使用一个全局的变量,在不需要的时候手动将其设置为null,使用一个函数作用域的变量,使得这个变量在函数结束的时候自动被回收掉,不是更好。这意味着,写得好的代码,会减少很多不必要的担心。
  • 保证不再被需要的事件监听确实地被解绑了。特别是某些绑定在DOM上的事件,在DOM对象将要被移除的时候。
  • 如果你在使用本地数据缓存,请确实地删除或者使用一个定时机制,来删除那些你存储起来的且以后不再会使用的大数据。

函数

接下来让我们来看下函数。就像我们提到过的,垃圾回收机制将会回收不再被使用的块状内存(对象)。为了更清楚地阐明这点,我们下面来举几个例子。

function foo() {
    var bar = new LargeObject();
    bar.someCall();
}

当foo函数返回的时候,bar指向的对象就已经被标记为垃圾回收可回收的对象了,因为已经不再有任何引用指向这个对象了。

与下面这个例子对比下:

function foo() {
    var bar = new LargeObject();
    bar.someCall();
    return bar;
}
 
// somewhere else
var b = foo();

 

现在有一个引用指向了foo函数返回的对象,这个对象将不会被回收,直到b被赋予一个新的对象,或b自己在作用域中失效。

闭包

当你看到一个函数返回了另一个内部函数,这个内部函数将能访问到所有外部函数的作用域内的变量,即便这个外部函数已经执行结束退出了。这就是一个基本的闭包 - 一个表达式可以和一系列设定在特殊上下文中的变量一起工作的情况。举例来说:

function sum (x) {
    function sumIt(y) {
        return x + y;
    };
    return sumIt;
}
 
// Usage
var sumA = sum(4);
var sumB = sumA(3);
console.log(sumB); // Returns 7

 

在函数sum执行上下文中创建出来的函数对象将无法被垃圾回收,因为它被全局变量sumA引用住了,且仍旧会被执行。它仍旧可以通过sumA(n)被执行到。

让我们来看另一个例子,在这里,我们怎么访问到largeStr这个变量?

var a = function () {
    var largeStr = new Array(1000000).join('x');
    return function () {
        return largeStr;
    };
}();

 

是的,你可以通过a(),来访问到。那么这个又如何?

var a = function () {
    var smallStr = 'x';
    var largeStr = new Array(1000000).join('x');
    return function (n) {
        return smallStr;
    };
}();

 

我们不再能访问到它,且它将会被垃圾回收。

定时器

最有可能造成内存泄漏的地方是在循环中,或setTimeout()/setInterval(),但是定时器的使用实在是太频繁了。

想一下下面的例子:

var myObj = {
    callMeMaybe: function () {
        var myRef = this;
        var val = setTimeout(function () { 
            console.log('Time is running out!'); 
            myRef.callMeMaybe();
        }, 1000);
    }
};

 

如果我们执行:

myObj.callMeMaybe();

 

来开启定时器,每秒,我们都会看到”Time is running out!”这句输出。如果我们接下来执行:

myObj = null;

 

定时器仍旧在运行。且myObj对象将无法被垃圾回收,因为被传给setTimeout的闭包必须被使用来执行定时器,而无法被释放。反过来说,因为myRef的原因,myObj的引用无法被释放掉。无论我们将闭包传给任何函数,情况都是如此,引用无法被释放掉。另一点必须铭记在心的是,在setTimeout / setInterval中的引用,例如某些函数,将无法被垃圾回收,直到定时器执行结束。

认识性能陷阱

如果我们创建一个模块:

  • 创建一个本地数据对象存储使用数字作为id的对象
  • 画一个表格来显示上述数据
  • 添加一个事件侦听器,在用户点击的时候改变某个格子的样式

这个功能有几个点需要考虑,即便如此,这个功能还是非常简单的。如何存储数据?如何快速地画出表格,并将它附加到DOM上?如何高效地在表格上处理事件?

一个非常快的(直接的)解决方案就是将数据存储在一个个的对象中,并组合成一个数组。然后使用jQuery来轮询数据,画出表格,最后将它附加到DOM上。最后,使用事件绑定,添加我们需要的点击事件。

记住:你不应该这么做

var moduleA = function () {
 
    return {
 
        data: dataArrayObject,
 
        init: function () {
            this.addTable();
            this.addEvents();
        },
 
        addTable: function () {
 
            for (var i = 0; i < rows; i++) {
                $tr = $('<tr></tr>');
                for (var j = 0; j < this.data.length; j++) {
                    $tr.append('<td>' + this.data[j]['id'] + '</td>');
                }
                $tr.appendTo($tbody);
            }
 
        },
        addEvents: function () {
            $('table td').on('click', function () {
                $(this).toggleClass('active');
            });
        }
 
    };
}();

 

简单,但是这种做法也完成了需求。

在这个例子中,我们唯一在轮询的,是ID,一个在标准数组中就能直接表示的数字变量。非常有趣的是,在这里直接使用DocumentFragment和原生DOM方法,会比使用jQuery更快地创建出表格。当然,使用事件委托机制显然比一个个绑定表格td上的事件要高效多了。

记住,jQuery内部是使用DocumentFragment的。但是在我们的例子中,代码是在循环中调用append函数,且每个循环的函数调用都需要使用到其他的某些变量,所以这里可能并不能够优化。这不应该是一个瓶颈,但请自己进行性能测试来保证没有问题。

在这个例子中,我们进行如下的几点改动,就能得到预期的性能提升。事件委托机制明显比单个绑定要快上很多,通过DocumentFragment来优化性能更是一个性能飞跃。

var moduleD = function () {
 
    return {
 
        data: dataArray,
 
        init: function () {
            this.addTable();
            this.addEvents();
        },
        addTable: function () {
            var td, tr;
            var frag = document.createDocumentFragment();
            var frag2 = document.createDocumentFragment();
 
            for (var i = 0; i < rows; i++) {
                tr = document.createElement('tr');
                for (var j = 0; j < this.data.length; j++) {
                    td = document.createElement('td');
                    td.appendChild(document.createTextNode(this.data[j]));
 
                    frag2.appendChild(td);
                }
                tr.appendChild(frag2);
                frag.appendChild(tr);
            }
            tbody.appendChild(frag);
        },
        addEvents: function () {
            $('table').on('click', 'td', function () {
                $(this).toggleClass('active');
            });
        }
 
    };
 
}();

 

或许我们可以找其他方式来提升性能。你或许会从其他地方听到,使用prototype的方法比使用模块的方法性能好(经过我们的验证,并没有更快),或者你听说过使用JavaScript的模板系统会大幅优化性能。某些时候,确实是如此。不过更重要的是,我们为了代码可读性而使用它们,而不是性能。并且,更重要的是,预编译!让我们通过测试和实践来进行检验。

moduleG = function () {};
 
moduleG.prototype.data = dataArray;
moduleG.prototype.init = function () {
    this.addTable();
    this.addEvents();
};
moduleG.prototype.addTable = function () {
    var template = _.template($('#template').text());
    var html = template({'data' : this.data});
    $tbody.append(html);
};
moduleG.prototype.addEvents = function () {
   $('table').on('click', 'td', function () {
       $(this).toggleClass('active');
   });
};
 
var modG = new moduleG();

 

结果证明,我们这次的优化几乎没有得到什么提升。通过模板和prototype进行优化,并没有提升什么。这说明了,现代程序员们并不是为了性能的考量而使用这些工具,而是代码可读性、继承模型带来的项目维护。

更复杂的问题包括有:更高效地使用Canvas来画图、使用或不使用类型数组操作像素数据

记得在推广这些做法之前在你的项目中进行性能测试。或许你们还想要了解:JavaScript templating shoot-off,以及extended shoot-off that followed。使用生产代码来进行测试,以期获得最正确的结论。

V8优化Tips

虽然讨论V8的优化已经超出了本文讨论内容的范畴,有几点还是需要分享的。将这几点铭记于心,你将不会写出低效率的代码。

  • 某些特定的模式将会导致V8停止优化。例如,一个try-cache块,就会导致优化停止。使用V8的命令行命令 –trace-opt file.js 来了解到底哪些函数会哪些不会被优化。
  • 如果你在乎性能的话,请保持你的函数单态。保证变量,包括成员变量、数组、函数参数保持在同样的hidden class形态。例如,不要这么写:
function add(x, y) { 
   return x+y;
} 
 
add(1, 2); 
add('a','b'); 
add(my_custom_object, undefined);

 

  • 不要加载未初始化的或已经被销毁的元素。虽然在结果上没有什么不同,但是会造成运行低效。
  • 不要编写大函数,这会造成编译器非常难于优化。

如果想要更多的tips,请参考Daniel Clifford的Google I/O大会演讲Breaking the JavaScript Speed Limit with V8Optimizing For V8 — A Series也值得一读。

对象VS数组:我该用哪个?

  • 如果你想存储一堆数字,或者一系列同类型的对象,那么使用数组。
  • 如果你的需求是一个对象,里面包含了一堆属性,且属性的类型还是多种多样的,使用一个带属性的变量。这样做内存利用率非常高效,且非常快。
  • 使用数字index的,无论是数组还是对象,速度都比使用对象里的属性进行循环要快得多
  • 对象中的属性非常复杂:他们可能通过setters创建,并比较可枚举性与可写性。在数组中的对象基本无法自定制,它们只有存在或不存在。从引擎级别来看,就组织内存来描述结果这一点来说,这样做更易于优化。特别当数组含数字类型的时候,这一点更加收益。例如,当你需要vectors的时候,不要定义一个含x、y、z三个属性的对象,使用数组来替代。

在JavaScript中,数组和对象只有一个主要差别,那就是长度属性,数组天生具有这个属性。如果你手动维护数组的长度属性的话,那么数组和对象在V8中的速度是相当的。

这段比较难理解,我附上原文:

  • If you want to store a bunch of numbers, or a list of objects of the same type, use an array.
  • If what you semantically need is an object with a bunch of properties (of varying types), use an object with properties. That’s pretty efficient in terms of memory, and it’s also pretty fast.
  • Integer-indexed elements, regardless of whether they’re stored in an array or an object, are much faster to iterate over than object properties.
  • Properties on objects are quite complex: they can be created with setters, and with differing enumerability and writability. Items in arrays aren’t able to be customized as heavily — they either exist or they don’t. At an engine level, this allows for more optimization in terms of organizing the memory representing the structure. This is particularly beneficial when the array contains numbers. For example, when you need vectors, don’t define a class with properties x, y, z; use an array instead..

There’s really only one major difference between objects and arrays in JavaScript, and that’s the arrays’ magic length property. If you’re keeping track of this property yourself, objects in V8 should be just as fast as arrays.

使用JavaScript对象的Tips

  • 使用构造函数创建对象。这保证了使用这个构造函数创建出来的对象都使用了同一个hidden class,并保证这些hidden class不被改变。此外,这比使用Object.create()稍微快一点
  • 对于你应用中使用的不通类型对象的个数,以及对象的复杂度,并没有强制限制(过长的原型链会比较慢,那些属性数量比较少的对象会比大型对象稍微快一点)。对于那些HOT对象,尽量使他们的原型链短,且字段数少。

对象克隆

对象克隆对应用开发者来说是一个普遍的问题。因为在V8上,针对各种各样的对象克隆拷贝实现方法都可以进行性能测试,所以我们在使用对象克隆的时候可以先测试然后再进行实际推广使用。拷贝大对象会很慢  -  不要这么做,在for…in循环中这么做性能更差。

当你不得不在性能敏感的代码中使用对象克隆(由于某些原因你不得不这么做),使用数组,或特别编写的“拷贝构造函数”,会一个个明确地拷贝对象属性的函数。通常来说,这么做是处理对象克隆的最快方法。

function clone(original) {
  this.foo = original.foo;
  this.bar = original.bar;
}
var copy = new clone(original);

 

模块模式编写的缓存函数

当你使用模块模式编写JavaScript代码的时候,使用函数缓存将会大幅提升你的代码性能。查看下图来了解各种情况下代码的性能变化,慢的代码总是在需要的时候创建新的成员函数。

Screen-Shot-2012-11-06-at-10.42.10

 

这是一份使用原型的代码和使用模块模式的代码之间的性能比较

// Prototypal pattern
  Klass1 = function () {}
  Klass1.prototype.foo = function () {
      log('foo');
  }
  Klass1.prototype.bar = function () {
      log('bar');
  }
 
  // Module pattern
  Klass2 = function () {
      var foo = function () {
          log('foo');
      },
      bar = function () {
          log('bar');
      };
 
      return {
          foo: foo,
          bar: bar
      }
  }
 
  // Module pattern with cached functions
  var FooFunction = function () {
      log('foo');
  };
  var BarFunction = function () {
      log('bar');
  };
 
  Klass3 = function () {
      return {
          foo: FooFunction,
          bar: BarFunction
      }
  }
 
  // Iteration tests
 
  // Prototypal
  var i = 1000,
      objs = [];
  while (i--) {
      var o = new Klass1()
      objs.push(new Klass1());
      o.bar;
      o.foo;
  }
 
  // Module pattern
  var i = 1000,
      objs = [];
  while (i--) {
      var o = Klass2()
      objs.push(Klass2());
      o.bar;
      o.foo;
  }
 
  // Module pattern with cached functions
  var i = 1000,
      objs = [];
  while (i--) {
      var o = Klass3()
      objs.push(Klass3());
      o.bar;
      o.foo;
  }
// See the test for full details

 

注意:如果你并不需要使用对象的话,就尽量不要使用对象,这回省下创建对象的很多麻烦。下面是一个不使用对象来获得性能提升的例子,http://jsperf.com/prototypal-performance/54

使用数组的Tips

下面让我们来看一些关于使用数组的tips。通常来说,不要删除数组里的元素。这会使得数组转化成一种比较慢的内部实现。当数组的键值比较稀疏的时候,最终V8会将数组转化成字典,这时候性能会更慢。

使用初始化的方法构造数组

P.S 原文这里是”Array Literals”,这个的解释请看我附加的链接,StackOverflow >> JavaScript Object Literals & Array Literals,简单来说就是使用带内容的数组值,直接初始化一个数组。

使用这种方法构造数组会给VM一些关于数组的提示,比如说这个数组的长度,以及数组的类型。特别是对小或者中尺寸的数组,效果更好。

// 在段代码让V8了解到,这个数组含有4个元素,且都是数字
var a = [1, 2, 3, 4];
 
// 不要这么做
a = []; // 这么写的话,对于这个数组V8就一点都不了解了
for(var i = 1; i <= 4; i++) {
     a.push(i);
}

使用数组来存储单一类型值和存储多种类型的值

在一个数组中存储多种类型的值不是一个好主意(数字、字符串、未定义的或布尔值),举例来说:var arr = [1, “1”, undefined, true, “true”]

下面是一个性能比较,Test of type inference performance,可以看到数组ints是最快的。

稀疏数组和全数组

P.S 原文是sparse array,这里翻译成稀疏数组,具体含义,请参考wiki,以及stackoverflow的帖子,我的理解就是一个数组中有很多键值被赋值为空,或0,或没有被初始化

当你使用稀疏数组的时候,请记住访问这种数组里的元素会比访问全数组的时候要慢得多。这是因为V8在处理这种数组的时候并没有完全分配一块内存用来进行这种数组的值存储。而是在一个字典中进行这个数组的值维护,这样做是为节省内存,虽然会增加访问的时候的时间消耗。

下面是一个测试,Test of sparse arrays versus full arrays,全数组sum以及没有0值的数组sum是最快的。全数组中是不是含有0值则不影响性能。

Packed Vs. Holey Arrays

避免在数组中制造“空洞”(删除数组元素或使用”a[x] = foo 和 x > a.length”这样的代码),即便只是从全数组中删除了一个键值,性能也会天差地别地慢得多。

查看性能测试:Test of packed versus holey arrays

预分配内存的数组与按需求增长的数组

不要预分配非常大的数组(例如:元素多余64K个的数组),而是按需求让它慢慢增长。在我们为这个tip做性能测试之前,牢记一点,这个问题是限定在某些JavaScript引擎的,而不是全部。

graph2

Nitro (Safari) 处理预分配数组非常出色,而在其他引擎(V8, SpiderMonkey)上,不使用预分配数组会更高效。测试:Test of pre-allocated arrays

优化你的应用

在网络应用的世界,速度就是一切。没有任何人希望一个表格应用在统计一个列的总和的时候需要花费N秒,或者在查看自己的消息概要的时候需要等一分钟才看得到。这就是为什么压榨你的代码中每一分性能可能性是如此的重要。提升你的应用的性能非常有用,但是也很困难。

我们推荐按下述的步骤来找出并解决性能瓶颈:

  • 评估:找到你应用的性能瓶颈(45%)
  • 理解:找出性能瓶颈的确切问题在哪里(45%)
  • 解决它们!(10%)

下面会介绍系列的工具和技术来协助我们解决问题。

Benchmarking

有很多种方法来运行并获取到JavaScript代码片段的性能 – 最简单的测试方法就是比对两个timestamp时间。一份由jsPerf做的,适合用在SunSpiderKraken的范例代码:

var totalTime,
    start = new Date,
    iterations = 1000;
while (iterations--) {
  // Code snippet goes here
}
// totalTime → the number of milliseconds taken 
// to execute the code snippet 1000 times
totalTime = new Date - start;

范例中的测试代码是运行在循环中的,运行N次。完成之后,结束时间减去开始时间,就是代码运行的时长。

然而,这种测试方法过于简单了,特别是当你想测试不同的环境和浏览器中的情况的时候。垃圾回收本身就会对你的测试结果造成影响。即便你使用window.performance这种方法,你还是必须考虑到这些陷阱。

无论你是想使用简单的方法来测试你的一些代码片段,还是编写系列的测试工具类库,关于JavaScript代码性能测试,你肯定还有很多没考虑到的地方。我强烈推荐Mathias Bynens和John-David Dalton写的JavaScript Benchmarking这篇文章。

PROFILING

Chrome的开发者工具对JavaScript profiling有非常好的支持。你可以使用这个工具非常简单地找到你的代码中耗时非常长的函数,并修复它们。这非常重要,因为即便一点点非常小的改动都有可能会对你的应用的性能造成非常大的影响。

profiling

 

接下来一段是描述如何使用profile工具和profile工具的界面变化的,我实在是觉得没必要翻译,这里就贴原文了:

Profiling starts with obtaining a baseline for your code’s current performance, which can be discovered using the Timeline. This will tell us how long our code took to run. The Profiles tab then gives us a better view into what’s happening in our application. The JavaScript CPU profile shows us how much CPU time is being used by our code, the CSS selector profile shows us how much time is spent processing selectors and Heap snapshots show how much memory is being used by our objects.

使用这个工具,我们可以孤立、调整并重新profile来观察,我们之前针对某些函数或操作做的改动是否提升了性能。

profiling2

 

请阅读Zack Grossbart的JavaScript Profiling With The Chrome Developer Tools来得到关于profiling的指导。

Tip:理想状态下,你总是希望你进行的profiling不会被你安装的Chrome扩展和应用所影响,你可以使用–user-data-dir <empty_directory>参数来启动Chrome。大部分情况下,前面描述的性能测试流程就已经足够了,不过有的时候你还会希望更多的方法和细节。这个时候V8的flag就能帮上忙了。

避免内存泄漏 – 三个快照技术来达到这个目的

在google内部,Chrome开发者工具经常被团队用来观察并定位内存泄漏,例如Gmail。

devtools

 

团队一般关心许多内存统计数据,包括内存使用、堆尺寸、DOM节点数、存储清理、事件监听数以及垃圾回收的状况。对于那些非常熟悉事件驱动架构的开发者,你应该对某些经常遇到的问题非常感兴趣,例如:listen()’s without unlisten()’s (Closure) and missing dispose()’s for objects that create event listeners。

非常幸运的,开发者工具能够帮助你定位其中的一些问题。Loreena Lee有一篇非常棒的文章,描述如何使用开发者工具来查找内存泄漏,“3 snapshot” technique

这个方法的要点是,你需要在你的应用中创建一系列的行为,来强制一次垃圾回收,然后检查DOM节点数是否恢复到你期望的基准线,并分析三张堆快照来确定你是否有内存泄漏。

SINGLE-PAGE应用的内存管理

内存管理对于编写现代single-page应用(例如AngularJS, Backbone, Ember)来说非常重要,因为它们几乎从来不刷新状态。这意味着内存泄漏会非常快。这对于移动端的single-page应用,例如邮件客户端、社交网络应用来说更致命,因为移动设备的内存一般都不会很富余。能力越强责任越大

有非常多的手段能防止这种事情的发生。在Backbone中,保证你总是使用dispose()函数处理掉不再使用的view对象和引用(参考Backbone (edge))。这个函数最近才被添加进来,在view作为第三参数(callback context)传入的时候,它会移除任何添加到views的events对象中的handlers,还有collection或者model监听器。view的remove()函数也会调用dispose()函数,因为当显示对象从银幕上移除的时候有大量清理内存的需求。其他的类库,类似Ember,当它们发现元素从view中移除的时候,会自动删除监听,来避免内存泄漏。

一些来自Derick Bailey的建议:

除了要理解事件是如何与引用一起工作的之外,程序员还必须遵守JavaScript中内存管理的基本规则,这样才能保证安全。如果当你将充满用户自定义对象数据的集合加载到Backbone的collection中的时候还希望内存会被正常释放的的话,你必须删除任何在这个collection中的单独对象,且删除任何指向这个collection的引用。一旦当你将所有的引用删除干净的时候,内存将会被正常释放掉。这就是标准的JavaScript垃圾回收机制。

在他的文章中,Derick提到了很多使用Backbone.js时会遇到的普遍内存管理陷阱,以及如何对付它们。

另外有一篇Felix Geisendörfer写的tutorial,教你如何查找NodeJs中的内存泄漏,值得一读。

最小化REFLOWS

浏览器重新计算页面文档上元素的位置与显示元素来重新绘制页面,这种行为,我们称之为reflow。reflow会阻碍用户的操作,所以了解如何优化减少reflow时间是很有帮助的。

reflow

 

程序员必须理解什么方法会触发reflow或会触发重绘,并非常谨慎小心地使用它们。非常重要的一点是尽可能少地操作DOM。我们可以通过DocumentFragment来解决这个问题,一个轻量级的document对象。试想一下如果你需要处理一部分的文档树,或在文档树中创建一个新的fragment。我们可以使用document fragment来创建好所有的dom内容,然后一次性地插入到DOM中,而不是持续地修改DOM结构。这样我们就可以尽可能地避免过分地改动DOM结构,也就不至于过度触发reflow。

举例来说,我们来写一个函数,向一个元素中添加20个div。如果直接在循环中向DOM添加20次div,将会触发20次的reflow。

function addDivs(element) {
  var div;
  for (var i = 0; i < 20; i ++) {
    div = document.createElement('div');
    div.innerHTML = 'Heya!';
    element.appendChild(div);
  }
}

 

为了避免这种情况的发生,我们可以使用DocumentFragment,然后向它里面添加div。当我们使用诸如appendChild这样的函数,将DocumentFragment添加到DOM元素中的时候,reflow只会被触发一次。

function addDivs(element) {
  var div; 
  // Creates a new empty DocumentFragment.
  var fragment = document.createDocumentFragment();
  for (var i = 0; i < 20; i ++) {
    div = document.createElement('a');
    div.innerHTML = 'Heya!';
    fragment.appendChild(div);
  }
  element.appendChild(fragment);
}

 

你可以阅读更多关于这个话题的文章,Make the Web FasterJavaScript Memory OptimizationFinding Memory Leaks

JavaScript内存泄漏侦测器

为了方便地查找JavaScript中的内存泄漏,我的两个google同事(Marja Hölttä 和 Jochen Eisinger)开发了一个和Chrome开发者工具协同工作的工具(还带有远程侦听协议),这个工具会获取堆快照,然后找出是哪个对象在泄漏内存。

leak

 

这里有篇文章指导如何使用这个工具,强烈推荐通读这篇文章或阅读Leak Finder project page这个页面。

更多信息:如果你在奇怪,这么好的工具为什么没有被集成进Chrome的开发者工具里,这里理由有两方面。这个工具一开始被开发出来的理由是为了帮助我们捕获某些闭包类库中特殊内存场景下的情况,所以它更适合做为一个外部工具(或者是Chrome的一个扩展,如果Chrome有获取堆快照的扩展接口的话),而不是开发者工具的一部分。

一些能帮助debugging优化以及垃圾回收的V8 flags

Chrome支持通过js-flags这个flag,直接向V8引擎传入一系列的flags来输出引擎优化的细节。例如,下例将会跟踪V8的优化信息:

"/Applications/Google Chrome/Google Chrome" --js-flags="--trace-opt --trace-deopt"

Windows需要使用如下方法启动Chrome,chrome.exe –js-flags=”–trace-opt –trace-deopt”。

当你在开发你的应用的时候,如下的flags可以使用:

  • trace-opt – 记录下被优化过的函数的名称,并显示优化器跳过优化的点
  • trace-deopt – 记录下一系列需要在运行时反优化的代码
  • trace-gc – 记录每一次垃圾回收的信息

V8的tick-processing脚本会将优化过的函数标记为*,而未优化的标记为~。

如果你对学习V8的flags以及V8内部如何工作的机制感兴趣的话,我强烈推荐你阅读Vyacheslav Egorov的excellent post on V8 internals,这篇文章概述了时下关于这个话题最有用的系列资源。

HIGH-RESOLUTION TIME AND NAVIGATION TIMING API

High Resolution Time (HRT) 是一个提供毫秒格式的当前时间的JavaScript接口,它不会受到系统时间和用户调整的影响。你可以将它视为一种比我们之前使用过的new Date或Date.now()还要精确的获取时间的方法。这在我们写性能测试的时候非常有帮助。

perfnow

 

当前你可以在当前的稳定版本Chrome中使用window.performance.webkitNow()这样的写法来获得HRT,而在Chrome Canary中,这个前缀已经被移除了,写作window.performance.now()。Paul Irish有一篇发布于HTML5Rocket的文章,写了更多关于HRT的内容

好了,我们现在有方法能获得到精确的当前时间了。那么是不是有一个API能告诉我们web上所花费的精确时间?

这个功能现在也已经有了,Navigation Timing API。这个函数提供了一个简便的方法来获取到页面显示给用户看所花费的精确的时间。这个API是window.performance.timing,你可以非常轻松地在控制台中调用它:

performance

看上面的截图,我们能获取到很多有用的信息。例如:

  • responseEnd-fetchStart,是网络延时
  • loadEventEnd-responseEnd,是服务器响应结束页面开始的时间
  • loadEventEnd-navigationStart,是navigation和页面加载之间的时间

正如你在上图所见的,perfomance.memory属性描述了JavaScript内存消耗,如堆尺寸。

更多关于Navigation Timing API的信息,请阅读Sam Dutton的文章,Measuring Page Load Speed With Navigation Timing

ABOUT:MEMORY AND ABOUT:TRACING

Chrome的about:tracing页面,提供了浏览器性能信息。记录下了所有的Chrome行为,包括了所有的进程、tab、处理等。

tracing

 

这个工具真正有价值的地方,是可以让你捕获到Chrome正在做什么的profiling数据,然后你就可以根据这个数据调整你JavaScript的执行,或者优化你的资源加载等等。

Lilli Thompson有一篇对于游戏开发者来说非常棒的文章,描述了如何使用about:tracing来profile Chrome的WebGl游戏。这篇文章对一般JavaScript程序员也很有用。

Chrome的about:memory页面也非常有用,因为它给出了每个tab的精确内存用量,这在追踪潜在的内存泄漏的时候非常有用。

尾声

就如我们所见的,在JavaScript引擎的世界,有很多很多性能坑,因此,在性能调优这件事情上,没有什么绝对的方法。你必须将多种优化手段集中使用在真实的测试环境上,才能获得最佳的性能优化。即便如此,理解引擎如何解释并优化你的代码还是能帮助你调整你的应用。

评估、理解、修复,然后重复。

记住,你需要关心性能优化,但是不要因为便利性的问题而选择微优化(micro-optimization)。例如,一些程序员使用forEach和Object.keys来优化for和for in循环,即便如此优化了,它们仍旧很慢。明智地选择你的应用需要什么优化而不需要什么。

另外,记住一点,JavaScript引擎持续地在优化变快,下一个瓶颈将会是DOM。你需要尽量减少Reflows和重绘的耗时,因此尽量在必须的时候才修改DOM。并且请小心使用网络资源。HTTP请求非常宝贵,特别是在移动平台,你必须使用缓存来减少资源的下载量。

希望你觉得这篇文章对你有帮助!

CREDITS

这篇文章经过Jakob Kummerow, Michael Starzinger, Sindre Sorhus, Mathias Bynens, John-David Dalton 和 Paul Irish的审核。