编写快速、高效的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的隐藏类,并且把它转化成一般的对象,而这些一般对象会更慢。
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也同样值得一读。
高精度时间以及导航计时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也很有帮助,因为它显示了每个选项卡精确的内存使用,这样可以有效的跟踪潜在的内存泄露。
英文原文:Writing Fast,Memory-EfficientJavaScript,编译:伯乐在线——戴嘉华