英文原文:Writing Fast,Memory-Efficient JavaScript,编译:伯乐在线——戴嘉华
许多Javascript引擎都是为了快速运行大型的JavaScript程序而特别设计的,例如Google的V8引擎(Chrome浏览器,Node均使用该引擎)。在你的开发过程中,如果你关心你程序的内存和性能的话,你应该了解并意识到,在你的代码背后,浏览器的JavaScript引擎中到底发生了什么事情。
不论的V8,SpiderMonkey(Firefox),Carakan(Opera),Chakra(IE)或者其它类型的引擎。了解引擎背后的一些运行机制可以帮助你更好的优化你的应用程序。这并不是意味着你只为一种浏览器或者一种引擎进行程序的优化,而且,永远不要这样做。
然而,你应该问自己下面这些问题:
● 我应该做点什么才能让我的代码更高效地运行。
● 流行的JavaScript引擎(通常)是怎么进行优化的。
● 有什么是引擎无法进行优化的,还有,垃圾回收器是不是按照我预想的那样,回收了我不需要的内存空间。
在我们编写高效、快速的代码的时候,有许多常见的陷阱。在这篇文章当中,我们会去探索一些方法,让你的代码拥有更加良好的性能,我们也会为这些代码提供测试样例。
JavaScript在V8引擎中是如何工作的?
虽然在没有彻底了解JavaScript引擎的情况下,开发出大型的应用程序是有可能的,这就像车主开过车却没有看过引擎盖背后的东西一样。把我选择的Chrome浏览器作为例子,我将会谈谈它的JavaScript引擎的工作机制。V8引擎,是由几个核心的部分组成的。
● 一个基本的编译器(basecompiler),在你的代码运行之前,它会分析你的JavaScript代码并且生成本地的机器码,而不是通过字节码的方式来运行,也不是简单地解释它。这种机器码起初是没有被高度优化的。
● V8通过对象模型(objectmodel)来表达你的对象。对象是在JavaScript中是以关联数组的方式呈现的,但是在V8引擎中,它们是通过隐藏类(hiddenclasses)的方式来表示的。这是一种可以优化查找的内部类型机制(internaltypesystem)。
● 一个运行期剖析器(runtimeprofiler),它会监视正在运行的系统,并且标识出“热点”函数(“hot”function),也就是那些最后会花费大量运行时间的代码。
● 一个优化编译器(optimizingcompiler),重新编译并优化运行期剖析器所标识“热点”代码,然后执行优化,例如,把代码进行内联化(inlining)(也就是在函数被调用的地方用函数主体去取代)。
● V8引擎支持逆优化(deoptimization),意味着如果优化编译器发现在某些假定的情况下,把一些已经优化的代码进行了过度的优化,它就会把它门从生成的代码中抽离出来。
● V8拥有垃圾回收器。理解它是如何运作的和理解如何优化你的JavaScript代码同等重要。
垃圾回收
垃圾回收是一种内存管理机制。垃圾回收器的概念是,它会尝试去重新分配已经不需要的对象所占据的内存空间。在如JavaScript拥有垃圾回收机制的语言中,如果你的程序中仍然存在指向一个对象的引用,那么该对象将不会被回收。
在大多数的情况下,我们没有必要去手动得解除对象的引用(de-referencing)。只要简单地把变量放在它们应该的地方(在理想的情况下,变量应该尽量为局部变量,也就是说,在它们被使用的函数中声明它们,而不是在更外层的作用域),垃圾就能正确地被回收。
在JavaScript中强制进行垃圾回收是一件不可能的事情,而且你也不会想这样做。因为垃圾回收的过程是由运行期所控制的,回收器通常知道垃圾回收的最佳时机在什么时候。
关于解除引用的误解
在网上不少关于JavaScript的内存分配问题的讨论中,关键字delete被频繁提出。虽然它本意是用来删除映射(map)中的键(keys),但是不少的开发者认为也可以使用它来强制解除引用。在可能的情况下,尽量避免使用delete。在下面的例子中,删除o.x在的代码背后会发生一些弊大于利的事情,因为它会改变o的隐藏类,并且把它转化成一般的对象,而这些一般对象会更慢。
1
2
3
|
var
o = { x: 1 };
delete
o.x;
// true
o.x;
// undefined
|
也就是说,在现在流行的JavaScript库中,你几乎肯定能找到delete删除引用的身影——它也确实存在这个语言目的。这里提出来的主旨是,让大家尽量避免在运行期改变热点对象(hotobjects)的结构。JavaScript引擎可以检测出这种的“热点”对象并尝试去优化它们,如果在对象的生命期中没有遇到重大的结构改变,引擎的检测和优化过程会来得更加容易,而使用delete则会触发对象结构上的这种改变。
不少人对null的使用上也存在误解。将一个对象的引用设为null,并不是意味着“清空”该对象,而是将该引用指向null。用o.x=null比用delete要好,但这甚至可能不是必要的。
1
2
3
4
|
var
o = { x: 1 };
o =
null
;
o;
// null
o.x
// TypeError
|
如果被删除的引用是指向对象的最后一个引用,那么该对象就满足了垃圾回收的资格。如果该引用不是指向对象的最后一个引用,那么该对象仍然可以被获取,而不会被垃圾回收。
另外要重点注意的是,要意识到,在你页面的生命期中,全局变量不会被垃圾回收器所清理。只要你的页面保持打开状态,JavaScript运行期中的全局对象就会常驻在内存当中。
1
|
var
myGlobalNamespace = {};
|
只有当你刷新页面,导航到不同的页面,关闭选项卡,或关闭你的浏览器,全局变量才会被清理。当函数作用域变量超出作用域范围,它就会被清理。当函数完全结束,并且再没有任何引用指向其中的变量,函数中的变量会被清理。
经验法则
为了给垃圾回收器尽早,尽量多地回收对象的机会,不要保留你不再需要的对像。这种情况大多会自动发生;这里有几件事是要谨记的:
● 就像之前所说的那样,一个比手动解除引用更好的选择是,在恰当的作用域中使用变量。也就是说,用可以自动从作用域中剔除的函数局部变量,去取代要手动清空的全局变量。这意味着你的代码会更加的整洁且要担忧的事情会更少。
● 确保要及时注销掉你不再需要的监听事件。特别是对那些必然要删除的DOM对象。
● 如果你正在使用本地数据缓存的话,确保要清除数据缓存或者使用老化机制(agingmechanism),以免保存了大量你不大可能复用的数据。
函数
接下来,让我们看看函数。正如我们所说的,垃圾回收是通过重新分配已经无法通过引用获得的内存块(对象)来工作的。为了更好地说明这一点,这里有一些例子。
1
2
3
4
|
function
foo() {
var
bar =
new
LargeObject();
bar.someCall();
}
|
当foo函数结束的时候,bar指向的对象就会自动地被垃圾回器所获取,因为已经没有任何引用指向该对象了。
对比以下代码:
1
2
3
4
5
6
7
8
|
function
foo() {
var
bar =
new
LargeObject();
bar.someCall();
return
bar;
}
// somewhere else
var
b = foo();
|
现在我们有了一个指向该对象的引用,这个引用会在该次调用中保留下来,直到调用者将b赋值给其他东西(或者b超出了作用域范围)。
闭包
现在我们来看看一个返回内部函数的函数,那个内部函数可以访问到更外层的作用域,即使外部函数已经执行完毕。这基本上就是一个闭包——一种可以使用设置在特殊上下文中的变量的表现。例如:
1
2
3
4
5
6
7
8
9
10
11
|
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(n)来运行。
让我们来看另外一个例子。这里,我们可以访问到largeStr吗?
1
2
3
4
5
6
|
var
a =
function
() {
var
largeStr =
new
Array(1000000).join(
'x'
);
return
function
() {
return
largeStr;
};
}();
|
答案是肯定的,我们可以通过a()来访问到它,所以它不会被回收。我们看看这个会怎么样:
1
2
3
4
5
6
7
|
var
a =
function
() {
var
smallStr =
'x'
;
var
largeStr =
new
Array(1000000).join(
'x'
);
return
function
(n) {
return
smallStr;
};
}();
|
我们再也不能访问到它了,它会成为垃圾回收的候选对象。
定时器
最糟糕的状况之一是内存在循环中,或者在setTimeout()/setInterval()中泄露,但这相当的常见。
考虑下面的例子:
1
2
3
4
5
6
7
8
9
|
var
myObj = {
callMeMaybe:
function
() {
var
myRef =
this
;
var
val = setTimeout(
function
() {
console.log(
'Time is running out!'
);
myRef.callMeMaybe();
}, 1000);
}
};
|
如果我们这样运行:
1
|
myObj.callMeMaybe();
|
开始定时器,我们会看到每秒钟显示“Timeisrunningout!”然后如果我们运行下面代码:
1
|
myObj =
null
;
|
定时器仍然运作。myObj不会被垃圾回收,因为传入setTimout的闭包函数仍然需要它来保证正常运作。反过来,闭包函数保留了指向myObj的引用,因为它通过myRef来获取了该对象。如果我们把该闭包函数传入其他任何的函数,同样的事情一样会发生,函数中仍然会存在指向对象的引用。
同样值得牢牢记住的是,在setTimeout/setInterval的调用中的引用,例如函数引用,在运行完成之前是不会被垃圾回收的。
注意性能陷阱
很重要的一点是,除非你真正需要,否则没有必要优化你的代码,这个怎么强调都不为过。在大量的微基准测试中,你可以很轻易地发现,在V8引擎中N比M更加的优化,但是如果在真实的代码模型或者在真正的应用程序中进行测试,那些优化的实际影响可能比你期望的要小得多。
假设现在我们想要建立的一个模块:
● 通过数字ID取出本地存储的数据资源。
● 用获得的数据生成表格内容。
● 为每个表格单元添加事件处理,每当用户点击表格单元,切换表格单元的class。
即使这个问题解决起来很直观,但是有一些困难的因素。我们如何去存储这些数据,如何可以高效地生成一个表格并把它添加到DOM中去,如何优化地处理这个表格的事件处理?
第一个(也是幼稚的)采取的方案可能是将每块可获取的数据存放在一个对象中,然后把所有对象集合到一个数组当中。有的人可能会用jQuery去循环访问数据然后把生成表格内容,然后把它添加到DOM中。最后,有的人可能会用使用事件绑定添加点击我们需要的点击事件。
注意:这不是你应该做的事情:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
var
moduleA =
function
() {
return
{
data: dataArrayObject,
init:
function
() {
this
.addTable();
this
.addEvents();
},
addTable:
function
() {
for
(
var
i = 0; i < rows; i++) {
$tr = $(
'
);
for
(
var
j = 0; j <
this
.data.length; j++) {
$tr.append(
'
);
}
$tr.appendTo($tbody);
}
},
addEvents:
function
() {
$(
'table td'
).on(
'click'
,
function
() {
$(
this
).toggleClass(
'active'
);
});
}
};
}();
|
代码简单,但它完成了我们需要的工作。
在这种情况下,我们唯一要迭代的只是ID,在一个标准的数组当中,数字属性可以更简单地表示出来。有趣的是,直接用DocumentFragment和原生的DOM方法生成表格内容,比你用jQuery(上面的jQuery用法)更加的优化。当然,使用事件委托通常比为每个td都进行事件绑定会有更好的性能。
注意jQuery内部确实使用DocumentFragment进行了优化,但在我们的例子中,代码中在循环中调用append(),每一次调用都要进行额外的操作,所以在这个例子中,它达到优化效果可能并不大。希望这应该不会是一个痛处,但是一定要用基准测试来确保自己的代码没有问题。
在我们的例子当中,添加这些以上的优化会得到一些不错(预期)的性能收益。相对于简单的绑定,事件委托提供了相当好的改进,且选择用documentFragment会是一个真正的性能助推器。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
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'
);
});
}
};
}();
|
我们可能会寻找其他的方案来提高性能。你可能在某些文章中了解到用原型模式比用模块模式更加优化(我们不久前已经证明了事实并非如此),或者了解到JavaScript模板框架是经过高度的优化的。有时它们的确是这样,但是使用它们只是为了代码拥有更强的可读性。同时,还有预编译!让我们测试一下,实际上这有多少是能带来真正优化的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
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();
|
正如结果所示,在这种情况下所带来的性能效益是微不足道的。选择模板和原型不会真正提供得比我们原来拥有的东西更多的东西。据说,性能并不是现代开发者所真正使用它们的原因——而是它给你的代码库所带来的可读性,继承模型,以及可维护性。
更复杂的问题包括如何高效地在canvas上绘制图像,和如何使用或不使用类型数组去操作像素数据。
在你的代码使用它们之前,要给你的微基准测试一个结束前的检验。你们其中有些人可能会回想起JavaScript模板语言shoot-off和它的之后扩展版的shoot-off。如果你想确保测试不会被现实的应用程序的中你不想见到的约束所影响——请在真实的代码中和优化一起测试。
V8优化技巧
同时详细的陈列每一个V8的每一种优化显然超出了本文的讨论范围,其中有许多特定的优化技巧都值得注意。记住以下的一些建议你就可以减少你写出低性能的代码的机会。
● 特定的模式会导致V8放弃优化。例如使用try-catch,就会导致这种情况的发生。如果想要了解跟多关于什么函数可以被优化,什么函数不可以,你可以使用V8引擎中附带的D8shell实用程序中的–trace-optfile.js。
● 如果你关心运行速度,那么就要尽量保持你的函数的功能的单一性,也就是说,确保变量(包括属性,数组,和函数参数)永远只是相同隐藏类的包含对象。例如,永远不要干这种事:
● 不要从未初始化的或已经被删除的元素上加载内容。这样做可能对你的程序运行结果不会造成影响。但是它会使得程序运行得更慢。
● 不要写过于庞大的函数,因为他们更难被优化。
如果想知道更多的优化技巧,可以观看DanielClifford的GoogleI/O大会上的演讲BreakingtheJavaScriptSpeedLimitwithV8,它同时也涵盖了上面我们所说的优化技巧。OptimizingForV8—ASeries也同样值得一读。
对象和数组:我应该用哪一个?
● 如果你想存储一组数字,或者一系列的同类型对象的话,那么就使用数组。
● 如果你想要的是一个语义上的有不同属性(不同类型)的对象,那么就使用包含属性的对象。这样从内存上来说会相当的高效,而且运行也相当的迅速。
● 用整数做索引的元素,不管它们是存储在数组还是对象中,都会比那些需通过迭代来获取的对象属性要快得多。
● 对象中的属性相当复杂:它们可以被setter所创建,拥有不同的可枚举性和可写性。数组中的元素不能有这样的定制性——它们只有存在或者不存在的状态。在一个引擎的层面,从组织表示结构的内存角度上来说,这允许有更多的优化。当一个数组中包含有数字的时候,这样会相当有好处。例如,当你需要一个向量,不要用一个包含有x,y,z属性的对象,用一个数组来存储就可以了。
使用对象的技巧
● 用构造函数构造对象。这样可以保证所有的由该构造函数构造的对象都具有相同的隐藏类,而且可以有助于避免修改这些隐藏类。有个附加的好处就是,它会比Object.create()稍快。
● 在你的程序中,对象的类型数目以及它们的复杂程度是没有限制的(不难理解的是:长原型链会可能会导致有害的结果,那些只有少数属性的对象的特殊表现就是,它们会比那些更大的对象运行得要快一点)。对于“热点”对象,尽量保持原型链的简短,以及属性数目较少。
对象的复制
对于应用的开发者来说,对象的复制是一个常见的问题。虽然基础测试可能表明V8在不同的情况下对这类问题都处理得很好,但是,当你要复制任何东西的时候,仍然需要小心。复制大的东西通常是缓慢的——所以不要这样做。JavaScript中的for…in循环处理这种事情特别的糟糕,因为它拥有可怕的规范,这使得它在任何引擎中处理任何对象,都不会获得良好的速度。
当你一定要在一段性能要求苛刻的代码中复制对象(并且你无法摆脱这种状况),那么就用数组或者一个自定义的“拷贝构造函数”,帮你逐一明确地复制对象的每一个属性。这可能是实现的最快的方式:
1
2
3
4
5
|
function
clone(original) {
this
.foo = original.foo;
this
.bar = original.bar;
}
var
copy =
new
clone(original);
|
模块模式中的缓存函数
在模块模式中缓存你的函数可以带来性能的提高。看看下面的例子,因为它总是会强制进行成员函数的复制,你习惯看到的变化可能会更慢。
这里有个关于原型对比模块模式的性能测试。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
|
// 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
|
使用数组的技巧
接下来我们来关谈论一下关于数组的一些技巧。通常情况下,不要删除数组的元素。否则会使得数组内部表现形式发生转变,从而变得更慢。当键变得稀疏的时候,V8会最终把元素转换成更慢的字典模式。
数组字面量
用数组字面量创建数组是有用的,因为它们会给VM一些暗示,让它知道数组的大小和类型。字面量通常对规模不大的数组是好处的。
1
2
3
4
5
6
7
8
|
// Here V8 can see that you want a 4-element array containing numbers:
var
a = [1, 2, 3, 4];
// Don't do this:
a = [];
// Here V8 knows nothing about the array
for
(
var
i = 1; i <= 4; i++) {
a.push(i);
}
|
存储单一类型VS混合类型
在同一个数组中存储不同类型的数据(例如,数字,字符串,undefined,或者true/false),从来不是一个好主意(也就是像这样,vararr=[1,“1”,undefined,“true”])。
类型推断的性能测试
我们可以从结果中看出,ints数组是最快的。
稀疏数组VS满数组
当你使用稀疏数组的时候,要意识到,在它们中访问元素的效率要比在满数组中要慢得多。这是因为如果数组中只有少数元素,V8不会为元素从新分配连续的内存空间。它们会被一个字典所管理,这样可以节约内存空间,但是会消耗访问时间。
稀疏数组对比满数组的测试
满数组的加法和无0的数组的加法实际上是最快的。而一个满数组中是否含有0对它的运行效率没有影响。
塞满的数组VS多孔的数组
避免数组中的“孔”(可能通过删除元素或者用a[x]=foo,而x>a.length来创建的孔)。在一个“满”的数组中,即使是仅仅是一个元素被删除掉,也会变得慢得多。
塞满的数组对比多孔数组的测试
预分配数组VS运行时分配
不要根据数组最大的大小预分配一个大数组(例如大于64K的元素),应该让你的数组在运行的时候自我分配。在我们进行对这个技巧的性能测试之前,请记住,这只适合部分JavaScript浏览器。
在Nitro引擎(Safari)使用预分配的数组会更有好处。但是,在其他的引擎中(V8,SpiderMonkey),非预分配会更高效。
预分配数组的测试
1
2
3
4
5
6
7
8
9
10
11
|
// Empty array
var
arr = [];
for
(
var
i = 0; i < 1000000; i++) {
arr[i] = i;
}
// Pre-allocated array
var
arr =
new
Array(1000000);
for
(
var
i = 0; i < 1000000; i++) {
arr[i] = i;
}
|
优化你的应用程序
在web应用程序的世界里面,速度是一切。没有用户希望一个电子表格程序需要几秒钟的时间去计算一列数据的和,或者用一分钟的时间去得到表格的汇总信息。这就为什么你需要压榨你代码中的每一点的性能的原因,这有时可能会苛刻。
虽然理解和提高你的应用程序性能是有用的,但是它依然很困难。我们给出下面几步建议去解决你程序性能的瓶颈:
● 测量它:找出你应用程序中慢的地方(~45%)
● 理解它:发现实际的问题是什么(~45%)
● 解决它!(~10%)
一些推荐的工具和技术可以协助你进行这个过程。
基准测试
有许多方法可以测试JavaScript代码片断的性能——一般的设想是:基准测试就是简单地对两个时间戳进行比较。这样的模式已经被jsPerf团队所指出,并且应用在在SunSpider和Kraken的基准测试套件当中。
1
2
3
4
5
6
7
8
9
|
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;
|
这里,测试代码被放在一个循环当中,并且运行一个设定的次数(例如六次)。接下来,用结束的时间去减开始开始的时间。这样,就可以测试出循环中操作所消耗的时间。
然而,这里对基准测试的工作过度简化了,尤其是如果你想在多个浏览器和环境中进行基准测试。垃圾回收本身会影响到你的测试结果。即使你使用了像window.preformance这样的解决方案,你仍然需要对那些陷阱做出相应的考虑。
不管你是简单地对你部分的代码进行基准测试,还是编写一个测试套件,或写一个基准测试的类库。关于JavaScript基准测试实际要做的事情比你想象的要多。如果你想获得关于基准测试更多的细节指导,我强烈建议你阅读MathiasBynens和John-DavidDalton编的JavaScriptBenchmarking
性能分析
Chrome的开发者工具提供了对JavaScript性能分析良好的支持。你可以使用这些特性去检测那些函数消耗了你大部分的性能,然后你就可以对它们进行优化。这是很重要的一点,因为即使你的代码库中一点很小的改变都可以影响到你的整体性能。
性能分析以获取你代码当前性能的基线开始,你可以从时间轴上发现它。它会告诉你你的代码花费了多长的运行时间。Profiles选项卡提供了一个更好的视角去观察我们的应用程序内部到底放生了什么事情。JavaScriptCPUprofile展示了我们的代码到底占用了多少CPU的时间,CSSselectorprofile告诉我们选择器查找元素所花费的时间,Heapsnapsshots让我们知道我们的对象占用了多少内存。
使用这些工具,我们可以抽离,调整和重新分析来度量我们的对特定函数或操作的改变是否真正起到了性能优化的效果。
想得到关于性能分析更好的介绍,可以阅读ZackGrossbart的JavaScriptProfilingWithTheChromeDeveloperTools,
提示:在理想情况下,如果你想保证的你性能分析没有受到你所安装的扩展程序或者应用所影响。以可以用usingthe–user-data-dir
避免内存泄露——用三快照(THREESNAPSHOT)技术发现问题
在Google内部,Chrome的开发者工具被例如Gmail这样的团队大量使用,可以帮助我们发现并解决内存泄露问题。
我们团队关注的一些内存数据,包括私有内存使用,JavaScript堆大小,DOM节点数目,内存清理,事件监听数目和垃圾回收器的运行情况。如果你对事件驱动架构比较熟悉,你或许比较有兴趣了解最常见问题之一就是,我们过去常常用listen(),unlisten()(闭包),和缺失的dispose()去处理事件监听对象。
幸运的是,DevTools可以帮我们解决其中的一些问题,强烈建议阅读LoreenaLee的很棒的一个展示,它记录了如何使用“三快照技术”找出DevTools中的内存泄露。
这项技术的要点是,记录你应用程序中的一些行为,强制进行垃圾回收,检测DOM的节点数目是否会返回到你所期望的基线,然后分析三个堆上的快照来决定内存是否泄露。
单页面程序的内存管理
内存管理对于现代的单页面应用程序(例如AngularJS,Backbone,Ember)相当的重要,因为它们几乎从来不会刷新页面。这就意味着内存泄露会相当的明显和迅速。这在移动单页面应用程序中存在相当大的陷阱,因为内存的限制,且存在大量的例如email客户端这样的长期运行的程序或者社交网络应用程序。能力越大,责任越大。(推荐阅读:《每位开发人员都应铭记的10句编程谚语》)
有许多方法可以避免这中情况。在Backbone中,确保你总是用dispose()(当前可以在Backbone(edge)中使用)来处理了旧的视图和引用。这个函数是最近新增的,可以移除任何添加到视图的“event”对象的所有处理函数,以及视图作为第三个参数(回调上下文)传入的任何集合或者模型的事件监听器,dispose()同样可以被视图的remove()所调用,当元素从页面中被移除时管理主要的基本内存清理工作。其他的类库,如Ember,当它检测到元素已经从视图中被移除的时候,它会移除相应的观察者,以避免内存泄露。
DerickBailey给了我们一些明智的建议:
“除了了解事件处理在引用层面是如何工作的,在JavaScript按照标准规则来管理你的内存,一切就会没问题。如果你想向一个塞满用户数据的Backbone集合中加载数据,如果你想那些集合稍后被清理并不占据内存,你必须移除集合所有的引用以及其中独立的对象。一旦你移除所有的引用,清理工作就可以进行。这就是标准的JavaScript垃圾回收规则”
在这篇文章中,Derick涵盖了在使用Backbone.js中许多常见的内存陷阱以及解决方案。
这里也有一篇由FelixGeisendrfer编写关于如何调试Node中的内存泄露的指导,很值得一读,尤其它形成了你的更广泛SPA堆里面的一部分。
最小化重排
当浏览器为了重绘(re-rendering)必须重新计算元素的位置和几何形状的时候,我们把这个过程称作重排(reflow)。在浏览器中,重排是一个用户阻塞的操作,所以了解如何去提高重排的性能会很有帮助。
你应该用批量处理的方法去触发重排或重绘,并且要有节制地使用这些方法。尽量不进行DOM操作也很重要。用轻量级的DocumentFragment(文本片段)对象来达到这样的效果。你可以把它当做是获取部分DOM树的方法,或者是创建新的文本“片段”的方法。对比不断地向DOM树添加节点,我们使用文本片段构建起我们需要的内容并且只进行一次DOM插入操作。这样可以避免过度的重排。
例如,我们写了一个向一个元素中添加20个div函数。简单地添加每个div到元素中会触发20次重排。
1
2
3
4
5
6
7
8
|
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添加到元素中的时候,文本片段中所有的子节点都会被添加到元素中,这样只会触发仅仅一次重排。
1
2
3
4
5
6
7
8
9
10
11
|
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);
}
|
你可以在MaketheWebFaster,JavaScriptMemoryOptimization和FindingMemoryLeaks.阅读到更多关于这方面的主题。
JavaScript内存泄露检测器
为了帮助发现JavaScript内存泄露,我两个谷歌的同事(MarjaHlttandJochenEisinger)开发了一个和Chrome开发者工具共同使用的工具(具体来说,就是一个远程检测协议),可以检索堆快照和探测出哪些对象导致了内存泄露。
有一篇文章完整地介绍了怎么使用这个工具,我鼓励你去看看或者去看LeakFinder的项目主页。
更多信息:也许你想知道为什么这个工具没有集成到我们的开发者工具当中。有两个原因,第一,它最初是为了帮助我们在Closure库中为我们检测特定的内存场景。第二,它作为一个扩展工具来使用将会更有意义(甚至可以作为一个扩展程序,如果我们可以适当地获得堆性能分析扩展程序的API的话)。
V8标签:调试性能&垃圾回收
Chrome支持通过js-flags直接传入一些标签到V8引擎中来获取关于引擎优化过程中更多细节。例如,这可以追溯V8的优化:
1
|
"/Applications/Google Chrome/Google Chrome"
--js-flags=
"--trace-opt --trace-deopt"
|
Windows用户可以运行chrome.exe–js-flags=”–trace-opt–trace-deopt”
在开发你的应用程序的过程中,这些V8标签会有所帮助:
● trace-opt–记录已经被优化的函数,已经显示优化器无法识别并且跳过的代码。
● trace-deopt–记录运行过程中需要逆优化的代码列表。
● trace-gc–记录每次进行垃圾回收的跟踪线。
V8的tick-processing脚本用*标注已经进行过优化的函数,用~标记没有被优化的函数。
如果你想了解更多关于V8引擎标签以及V8内部工作原理的话,我强烈建议你浏览一篇关于V8引擎内部工作原理的文章,它总结了一些目前为止最好的资源。
高精度时间以及导航计时API
高精度时间(HRT)是一个不受系统时钟以及用户调整影响的亚毫秒级的JavaScript接口。它提供了比newDate和Date.now()更为精准的时间测量。这样可以帮助我们写出性能良好的基础测试。
HRT目前在Chrome(稳定版)中可以通过window.performance.webkitNow()来获得,但是前缀在ChromCanary中被省略了,可以通过window.performance.now()来获取。PaulIrish在HTML5Rocks中写了一篇关于HRT的文章。
所以,我们现在知道了目前的时间,但是如果我们需要API给出更精确的时间去测量web中的性能呢?
现在,我们有个NavigationTimingAPI的东西可以使用。这个API提供了一个简单的方法去获取当页面加载完毕并展示给用户时的精确和详细的时间测量。时间信息可以通过window.performance.timing暴露出来,你可以在控制台中简单地使用它:
观察上面的数据,我们可以抽离出一些相当有用的信息。例如,网络延迟为responseEnd-fetchStart,从服务器加载页面时间为loadEventEnd-responseEnd,以及导航和页面加载之间的的耗时为loadEventEnd-navigationStart。
正如你所看到的,一个performance.memory属性同样可以提供例如总堆大小的JavaScript内存使用情况。
关于导航计时API更多的细节,你可以阅读SamDutton的一篇相当好的文章MeasuringPageLoadSpeedWithNavigationTiming.
ABBOUT:MEMORY和ABOUT:TRACING
Chrome中的about:tracing提供了有效的视图,帮助我们观察浏览器的性能,记录Chrome如每个线程,选项卡,和进程的所有活动。
这个工具真正的有用的地方是可以允许你获取Chrome的浏览器引擎盖下的分析数据,然后你可以恰当地调整你的JavaScript程序,或者优化你资源加载过程。
LilliThompson有一篇写给游戏开发者的文章,关于如何使用about:tracing去分析WebGL游戏。这篇文章对于普通的JavaScripters依然适用。
Chrome中使用about:memory也很有帮助,因为它显示了每个选项卡精确的内存使用,这样可以有效的跟踪潜在的内存泄露。
总结
正如我们所见,在JavaScript的引擎世界里面,有许多的隐藏的性能陷阱,事实上并没有性能提高的银弹。只有当你在(现实世界的)测试环境中结合一系列的优化,你才会意识到最大的性能获益。但是即使这样,理解引擎内部原理以及优化你的代码可以给你提高你应用程序性能的灵感。
测量它,理解它,解决它。不断重复这个过程。
记得关注优化,但要避免一些小的优化从而获得更大的便利。例如,一些开发者在循环中为便利而是用.forEach和Object.keys来代替for和forin,即使它们更慢,但是在可以接受范围内,它们更为方便。你需要一个清醒的头脑去分析你的应用程序中哪些是需要优化的,哪些是不需要的。
同样,意识到即使JavaScript引擎不断变得更快,一个真证的瓶颈其实是DOM。重排和重绘的最小化是相当重要的,所以记住如果不是非不得已,不要触碰DOM。并且关注网络情况。HTTP请求是珍贵的,尤其是在移动终端,你应该使用HTTP缓存来减少资源的消耗。
记住所有这一切可以确保你已经获得这篇文章的大部分信息。我希望这会对你有所帮助!
英文原文:Writing Fast,Memory-Efficient JavaScript,编译:伯乐在线——戴嘉华
译文链接:http://blog.jobbole.com/30550/