英文原文:Writing Fast,Memory-Efficient JavaScript,编译:伯乐在线——戴嘉华
许多JavaScript引擎都是为了快速运行大型的JavaScript程序而特别设计的,例如Google的V8引擎(Chrome浏览器,Node.js均使用该引擎)。在你的开发过程中,如果你关心你程序的内存和性能的话,你应该了解并意识到,在你的代码背后,浏览器的JavaScript引擎中到底发生了什么事情。
不论V8、SpiderMonkey(Firefox)、Carakan(Opera)、Chakra(IE)或者其它类型的引擎,了解引擎背后的一些运行机制可以帮助你更好的优化你的应用程序。这并不是意味着你只为一种浏览器或者一种引擎进行程序的优化,而且,永远不要这样做。
然而,你应该问自己下面这些问题:
- 我应该做点什么才能让我的代码更高效地运行?
- 流行的JavaScript引擎(通常)是怎么进行优化的?
- 有什么是引擎无法进行优化的?还有,垃圾回收器是不是按照我预想的那样,回收了我不需要的内存空间?
在我们编写高效、快速的代码的时候,有许多常见的陷阱。在这篇文章当中,我们会去探索一些方法,让你的代码拥有更加良好的性能,我们也会为这些代码提供测试样例。
JavaScript在V8引擎中是如何工作的?
虽然在没有彻底了解JavaScript引擎的情况下,开发出大型的应用程序是有可能的,这就像车主开过车却没有看过引擎盖背后的东西一样。把我选择的Chrome浏览器作为例子,我将会谈谈它的JavaScript引擎的工作机制。V8引擎,是由几个核心的部分组成的。
- 一个基本的编译器(baseCompiler),在你的代码运行之前,它会分析你的JavaScript代码并且生成本地的机器码,而不是通过字节码的方式来运行,也不是简单地解释它。这种机器码起初是没有被高度优化的。
- V8通过对象模型(objectModel)来表达你的对象。对象在JavaScript中是以关联数组的方式呈现的,但是在V8引擎中,它们是通过隐藏类(hiddenClasses)的方式来表示的。这是一种可以优化查找的内部类型机制(internalTypeSystem)。
- 一个运行期剖析器(runtimeProfiler),它会监视正在运行的系统,并且标识出“热点”函数(hotFunction),也就是那些最后会花费大量运行时间的代码。
- 一个优化编译器(optimizingCompiler),重新编译并优化运行期剖析器所标识“热点”代码,然后执行优化,例如,把代码进行内联化(inlining)(也就是在函数被调用的地方用函数主体去取代)。
- V8引擎支持逆优化(deoptimization),意味着如果优化编译器发现在某些假定的情况下,把一些已经优化的代码进行了过度的优化,它就会把它门从生成的代码中抽离出来。
- V8拥有垃圾回收器。理解它是如何运作的和理解如何优化你的JavaScript代码同等重要。
垃圾回收
垃圾回收是一种内存管理机制。垃圾回收器的概念是,它会尝试去重新分配已经不需要的对象所占据的内存空间。在如JavaScript拥有垃圾回收机制的语言中,如果你的程序中仍然存在指向一个对象的引用,那么该对象将不会被回收。
在大多数的情况下,我们没有必要去手动解除对象的引用(de-referencing)。只要简单地把变量放在它们应该的地方(在理想的情况下,变量应该尽量为局部变量,也就是说,在它们被使用的函数中声明它们,而不是在更外层的作用域),垃圾就能正确地被回收。
在JavaScript中强制进行垃圾回收是一件不可能的事情,而且你也不会想这样做。因为垃圾回收的过程是由运行期所控制的,回收器通常知道垃圾回收的最佳时机在什么时候。
关于解除引用的误解
在网上不少关于JavaScript的内存分配问题的讨论中,关键字delete被频繁提出。虽然它本意是用来删除映射(map)中的键(keys),但是不少的开发者认为也可以使用它来强制解除引用。在可能的情况下,尽量避免使用delete。在下面的例子中,删除o.x在的代码背后会发生一些弊大于利的事情,因为它会改变o的隐藏类,并且把它转化成一般的对象,而这些一般对象会更慢。
var o = {
x: 1
};
delete o.x; // true
o.x; // undefined
也就是说,在现在流行的JavaScript库中,你几乎肯定能找到delete删除引用的身影——这也的确是它在这个语言中存在的目的。这里提出来的主旨是,让大家尽量避免在运行期改变热点对象(hotObjects)的结构。JavaScript引擎可以检测出这种“热点对象”并尝试去优化它们,如果在对象的生命期中没有遇到重大的结构改变,引擎的检测和优化过程会来得更加容易,而使用delete则会触发对象结构上的这种改变。
不少人对null的使用上也存在误解。将一个对象的引用设为null,并不是意味着“清空”该对象,而是将该引用指向null。用o.x=null比用delete要好,但这甚至可能不是必要的。
var o = {
x: 1
};
o = null;
o; // null
o.x // TypeError
如果被删除的引用是指向对象的最后一个引用,那么该对象就满足了垃圾回收的资格。如果该引用不是指向对象的最后一个引用,那么该对象仍然可以被获取,而不会被垃圾回收。
另外要重点注意的是,要意识到,在你页面的生命期中,全局变量不会被垃圾回收器所清理。只要你的页面保持打开状态,JavaScript运行期中的全局对象就会常驻在内存当中。
var myGlobalNamespace = {};
只有当你刷新页面,导航到不同的页面,关闭选项卡,或关闭你的浏览器,全局变量才会被清理。当函数作用域变量超出作用域范围,它就会被清理。当函数完全结束,并且再没有任何引用指向其中的变量,函数中的变量会被清理。
经验法则
为了给垃圾回收器尽早、尽量多地回收对象的机会,不要保留你不再需要的对像,这种情况大多会自动清理。这里有几件事是要谨记的:
- 就像之前所说的那样,一个比手动解除引用更好的选择是,在恰当的作用域中使用变量。也就是说,用可以自动从作用域中剔除的函数局部变量,去取代要手动清空的全局变量。这意味着你的代码会更加的整洁且要担忧的事情会更少。
- 确保要及时注销掉你不再需要的监听事件,特别是对那些必然要删除的DOM对象。
- 如果你正在使用本地数据缓存的话,确保要清除数据缓存或者使用老化机制(agingmechanism),以免保存了大量你不大可能复用的数据。
函数
接下来,让我们看看函数。正如我们所说的,垃圾回收是通过重新分配已经无法通过引用获得的内存块(对象)来工作的。为了更好地说明这一点,这里有一些例子。
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();
现在我们有了一个指向该对象的引用,这个引用会在该次调用中保留下来,直到调用者将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(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();
开始定时器,我们会看到每秒钟显示“Timeisrunningout!”然后如果我们运行下面代码:
myObj = null;
定时器仍然运作。myObj不会被垃圾回收,因为传入setTimout的闭包函数仍然需要它来保证正常运作。反过来,闭包函数保留了指向myObj的引用,因为它通过myRef来获取了该对象。如果我们把该闭包函数传入其他任何的函数,同样的事情一样会发生,函数中仍然会存在指向对象的引用。
同样值得牢牢记住的是,在setTimeout/setInterval
的调用中的引用,例如函数引用,在运行完成之前是不会被垃圾回收的。
注意性能陷阱
很重要的一点是,除非你真正需要,否则没有必要优化你的代码,这个怎么强调都不为过。在大量的微基准测试中,你可以很轻易地发现,在V8引擎中N比M更加的优化,但是如果在真实的代码模型或者在真正的应用程序中进行测试,那些优化的实际影响可能比你期望的要小得多。
假设现在我们想要建立的一个模块:
- 通过数字ID取出本地存储的数据资源。
- 用获得的数据生成表格内容。
- 为每个表格单元添加事件处理,每当用户点击表格单元,切换表格单元的class。
即使这个问题解决起来很直观,但是有一些困难的因素。我们如何去存储这些数据,如何可以高效地生成一个表格并把它添加到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(上面的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');
});
}
};
}();
我们可能会寻找其他的方案来提高性能。你可能在某些文章中了解到用原型模式比用模块模式更加优化(我们不久前已经证明了事实并非如此),或者了解到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();
正如结果所示,在这种情况下所带来的性能效益是微不足道的。选择模板和原型不会真正提供得比我们原来拥有的东西更多的东西。据说,性能并不是现代开发者所真正使用它们的原因——而是它给你的代码库所带来的可读性,继承模型,以及可维护性。
更复杂的问题包括如何高效地在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循环处理这种事情特别的糟糕,因为它拥有可怕的规范,这使得它在任何引擎中处理任何对象,都不会获得良好的速度。
当你一定要在一段性能要求苛刻的代码中复制对象(并且你无法摆脱这种状况),那么就用数组或者一个自定义的“拷贝构造函数”,帮你逐一明确地复制对象的每一个属性。这可能是实现的最快的方式:
function clone(original) {
this.foo = original.foo;
this.bar = original.bar;
}
var copy = new clone(original);
模块模式中的缓存函数
在模块模式中缓存你的函数可以带来性能的提高。看看下面的例子,因为它总是会强制进行成员函数的复制,你习惯看到的变化可能会更慢。
这里有个关于原型对比模块模式的性能测试。
// 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一些暗示,让它知道数组的大小和类型。字面量通常对规模不大的数组是好处的。
// 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),从来不是一个好主意(也就是像这样:var arr = [1, "1", undefined, "true"]
)。
我们可以从结果中看出,ints数组是最快的。
稀疏数组VS满数组
当你使用稀疏数组的时候,要意识到,在它们中访问元素的效率要比在满数组中要慢得多。这是因为如果数组中只有少数元素,V8不会为元素从新分配连续的内存空间。它们会被一个字典所管理,这样可以节约内存空间,但是会消耗访问时间。
满数组的加法和无0的数组的加法实际上是最快的。而一个满数组中是否含有0对它的运行效率没有影响。
塞满的数组VS多孔的数组
避免数组中的“孔”(可能通过删除元素或者用a[x]=foo,而x>a.length来创建的孔)。在一个“满”的数组中,即使是仅仅是一个元素被删除掉,也会变得慢得多。
预分配数组VS运行时分配
不要根据数组最大的大小预分配一个大数组(例如大于64K的元素),应该让你的数组在运行的时候自我分配。在我们进行对这个技巧的性能测试之前,请记住,这只适合部分JavaScript浏览器。
在Nitro引擎(Safari)使用预分配的数组会更有好处。但是,在其他的引擎中(V8,SpiderMonkey),非预分配会更高效。
// 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;
}