查看原文
其他

测试 JavaScript 函数的性能

前端大全 2020-01-12

(给前端大全加星标,提升前端技能


编译:伯乐在线/Wing 


在软件中,性能一直扮演着重要的角色。在Web应用中,性能变得更加重要,因为如果页面速度很慢的话,用户就会很容易转去访问我们的竞争对手的网站。作为专业的web开发人员,我们必须要考虑这个问题。有很多“古老”的关于性能优化的最佳实践在今天依然可行,例如最小化请求数目,使用CDN以及不编写阻塞页面渲染的代码。然而,随着越来越多的web应用都在使用JavaScript,确保我们的代码运行的很快就变得很重要。


假设你有一个正在工作的函数,但是你怀疑它运行得没有期望的那样快,并且你有一个改善它性能的计划。那怎么去证明这个假设呢?在今天,有什么最佳实践可以用来测试JavaScript函数的性能呢?一般来说,完成这个任务的最佳方式是使用内置的performance.now()函数,来衡量函数运行前和运行后的时间。


在这篇文章中,我们会讨论如何衡量代码运行时间,以及有哪些技术可以避免一些常见的“陷阱”。


Performance.now()


高分辨率时间API提供了一个名为now()的函数,它返回一个DOMHighResTimeStamp对象,这是一个浮点数值,以毫秒级别(精确到千分之一毫秒)显示当前时间。单独这个数值并不会为你的分析带来多少价值,但是两个这样的数值的差值,就可以精确描述过去了多少时间。


这个函数除了比内置的Date对象更加精确以外,它还是“单调”的,简单说,这意味着它不会受操作系统(例如,你笔记本上的操作系统)周期性修改系统时间影响。更简单的说,定义两个Date实例,计算它们的差值,并不代表过去了多少时间。


“单调性”的数学定义是“(一个函数或者数值)以从不减少或者从不增加的方式改变”。


我们可以从另外一种途径来解释它,即想象使用它来在一年中让时钟向前或者向后改变。例如,当你所在国家的时钟都同意略过一个小时,以便最大化利用白天的时间。如果你在时钟修改之前创建了一个Date实例,然后在修改之后创建了另外一个,那么查看这两个实例的差值,看上去可能像“1小时零3秒又123毫秒”。而使用两个performance.now()实例,差值会是“3秒又123毫秒456789之一毫秒”。


在这一节中,我不会涉及这个API的过多细节。如果你想学习更多相关知识或查看更多如何使用它的示例,我建议你阅读这篇文章:Discovering the High Resolution Time API。


既然你知道高分辨率时间API是什么以及如何使用它,那么让我们继续深入看一下它有哪些潜在的缺点。但是在此之前,我们定义一个名为makeHash()的函数,在这篇文章剩余的部分,我们会使用它。


function makeHash(source) {

 var hash = 0;

 if (source.length === 0) return hash;

 for (var i = 0; i < source.length; i++) {

   var char = source.charCodeAt(i);

   hash = ((hash<<5)-hash)+char;

   hash = hash & hash; // Convert to 32bit integer

 }

 return hash;

}


我们可以通过下面的代码来衡量这个函数的执行效率:


var t0 = performance.now();

var result = makeHash('Peter');

var t1 = performance.now();

console.log('Took', (t1 - t0).toFixed(4), 'milliseconds to generate:', result);


如果你在浏览器中运行这些代码,你应该看到类似下面的输出:


Took 0.2730 milliseconds to generate: 77005292


这段代码的在线演示如下所示:


记住这个示例后,让我们开始下面的讨论。


缺陷1 – 意外衡量不重要的事情


在上面的示例中,你可以注意到,我们在两次调用performance.now()中间只调用了makeHash()函数,然后将它的值赋给result变量。这给我们提供了函数的执行时间,而没有其他的干扰。我们也可以按照下面的方式来衡量代码的效率:


var t0 = performance.now();

console.log(makeHash('Peter'));  // bad idea!

var t1 = performance.now();

console.log('Took', (t1 - t0).toFixed(4), 'milliseconds');


这个代码片段的在线演示如下所示:


但是在这种情况下,我们将会测量调用makeHash(‘Peter’)函数花费的时间,以及将结果发送并打印到控制台上花费的时间。我们不知道这两个操作中每个操作具体花费多少时间, 只知道总的时间。而且,发送和打印输出的操作所花费的时间会依赖于所用的浏览器,甚至依赖于当时的上下文。


或许你已经完美的意识到console.log方式是不可以预测的。但是执行多个函数同样是错误的,即使每个函数都不会触发I/O操作。例如:


var t0 = performance.now();

var name = 'Peter';

var result = makeHash(name.toLowerCase()).toString();

var t1 = performance.now();

console.log('Took', (t1 - t0).toFixed(4), 'milliseconds to generate:', result);


同样,我们不会知道执行时间是怎么分布的。它会是赋值操作、调用toLowerCase()函数或者toString()函数吗?


缺陷 #2 – 只衡量一次


另外一个常见的错误是只衡量一次,然后汇总花费的时间,并以此得出结论。很可能执行不同的次数会得出完全不同的结果。执行时间依赖于很多因素:


  • 编辑器热身的时间(例如,将代码编译成字节码的时间)

  • 主线程可能正忙于其它一些我们没有意识到的事情

  • 你的电脑的CPU可能正忙于一些会拖慢浏览器速度的事情


持续改进的方法是重复执行函数,就像这样:


var t0 = performance.now();

for (var i = 0; i < 10; i++) {

 makeHash('Peter');

}

var t1 = performance.now();

console.log('Took', ((t1 - t0) / 10).toFixed(4), 'milliseconds to generate');


这个示例的在线演示如下所示:


这种方法的风险在于我们的浏览器的JavaScript引擎可能会使用一些优化措施,这意味着当我们第二次调用函数时,如果输入时相同的,那么JavaScript引擎可能会记住了第一次调用的输出,然后简单的返回这个输出。为了解决这个问题,你可以使用很多不同的输入字符串,而不用重复的使用相同的输入(例如‘Peter’)。显然,使用不同的输入进行测试带来的问题就是我们衡量的函数会花费不同的时间。或许其中一些输入会花费比其它输入更长的执行时间。


缺陷 #3 – 太依赖平均值


在上一节中,我们学习到的一个很好的实践是重复执行一些操作,理想情况下使用不同的输入。然而,我们要记住使用不同的输入带来的问题,即某些输入的执行时间可能会花费所有其它输入的执行时间都长。这样让我们退一步来使用相同的输入。假设我们发送同样的输入十次,每次都打印花费了多长时间。我们会得到像这样的输出:


Took 0.2730 milliseconds to generate: 77005292

Took 0.0234 milliseconds to generate: 77005292

Took 0.0200 milliseconds to generate: 77005292

Took 0.0281 milliseconds to generate: 77005292

Took 0.0162 milliseconds to generate: 77005292

Took 0.0245 milliseconds to generate: 77005292

Took 0.0677 milliseconds to generate: 77005292

Took 0.0289 milliseconds to generate: 77005292

Took 0.0240 milliseconds to generate: 77005292

Took 0.0311 milliseconds to generate: 77005292


请注意第一次时间和其它九次的时间完全不一样。这很可能是因为浏览器中的JavaScript引擎使用了优化措施,需要一些热身时间。我们基本上没有办法避免这种情况,但是会有一些好的补救措施来阻止我们得出一些错误的结论。


一种方式是去计算后面9次的平均时间。另外一种更加使用的方式是收集所有的结果,然后计算“中位数”。基本上,它会将所有的结果排列起来,对结果进行排序,然后取中间的一个值。这是performance.now()函数如此有用的地方,因为无论你做什么,你都可以得到一个数值。


让我们再试一次,这次我们使用中位数函数:


var numbers = [];

for (var i=0; i < 10; i++) {

 var t0 = performance.now();

 makeHash('Peter');

 var t1 = performance.now();

 numbers.push(t1 - t0);

}

 

function median(sequence) {

 sequence.sort();  // note that direction doesn't matter

 return sequence[Math.ceil(sequence.length / 2)];

}

 

console.log('Median time', median(numbers).toFixed(4), 'milliseconds');


缺陷 #4 – 以可预测的方式比较函数


我们已经理解衡量一些函数很多次并取平均值总会是一个好主意。而且,上面的示例告诉我们使用中位数要比平均值更好。


在实际中,衡量函数执行时间的一个很好的用处是来了解在几个函数中,哪个更快。假设我们有两个函数,它们的输入参数类型一致,输出结果相同,但是它们的内部实现机制不一样。


例如,我们希望有一个函数,当特定的字符串在一个字符串数组中存在时,函数返回true或者false,但这个函数在比较字符串时不关心大小写。换句话说,我们不能直接使用Array.prototype.indexOf方法,因为这个方法是大小写敏感的。下面是这个函数的一个实现:


function isIn(haystack, needle) {

 var found = false;

 haystack.forEach(function(element) {

   if (element.toLowerCase() === needle.toLowerCase()) {

     found = true;

   }

 });

 return found;

}

 

console.log(isIn(['a','b','c'], 'B'));  // true

console.log(isIn(['a','b','c'], 'd'));  // false


我们可以立刻发现这个方法有改进的地方,因为haystack.forEach循环总会遍历所有的元素,即使我们可以很快找到一个匹配的元素。现在让我们使用for循环来编写一个更好的版本。


function isIn(haystack, needle) {

 for (var i = 0, len = haystack.length; i < len; i++) {

   if (haystack[i].toLowerCase() === needle.toLowerCase()) {

     return true;

   }

 }

 return false;

}

 

console.log(isIn(['a','b','c'], 'B'));  // true

console.log(isIn(['a','b','c'], 'd'));  // false


现在我们来看哪个函数更快一些。我们可以分别运行每个函数10次,然后收集所有的测量结果:


function isIn1(haystack, needle) {

 var found = false;

 haystack.forEach(function(element) {

   if (element.toLowerCase() === needle.toLowerCase()) {

     found = true;

   }

 });

 return found;

}

 

function isIn2(haystack, needle) {

 for (var i = 0, len = haystack.length; i < len; i++) {

   if (haystack[i].toLowerCase() === needle.toLowerCase()) {

     return true;

   }

 }

 return false;

}

 

console.log(isIn1(['a','b','c'], 'B'));  // true

console.log(isIn1(['a','b','c'], 'd'));  // false

console.log(isIn2(['a','b','c'], 'B'));  // true

console.log(isIn2(['a','b','c'], 'd'));  // false

 

function median(sequence) {

 sequence.sort();  // note that direction doesn't matter

 return sequence[Math.ceil(sequence.length / 2)];

}

 

function measureFunction(func) {

 var letters = 'a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z'.split(',');

 var numbers = [];

 for (var i = 0; i < letters.length; i++) {

   var t0 = performance.now();

   func(letters, letters[i]);

   var t1 = performance.now();

   numbers.push(t1 - t0);

 }

 console.log(func.name, 'took', median(numbers).toFixed(4));

}

 

measureFunction(isIn1);

measureFunction(isIn2);


我们运行上面的代码, 可以得出如下的输出:


true

false

true

false

isIn1 took 0.0050

isIn2 took 0.0150


这个示例的在线演示如下所示:


到底发生了什么?第一个函数的速度要快3倍!那不是我们假设的情况。


其实假设很简单,但是有些微妙。第一个函数使用了haystack.forEach方法,浏览器的JavaScript引擎会为它提供一些底层的优化,但是当我们使用数据索引技术时,JavaScript引擎没有提供对应的优化。这告诉我们:在真正测试之前,你永远不会知道。


结论


在我们试图解释如何使用performance.now()方法得到JavaScript精确执行时间的过程中,我们偶然发现了一个基准场景,它的运行结果和我们的直觉相反。问题在于,如果你想要编写更快的web应用,我们需要优化JavaScript代码。因为计算机(几乎)是一个活生生的东西,它很难预测,有时会带来“惊喜”,所以如果了解我们代码是否运行更快,最可靠的方式就是编写测试代码并进行比较。


当我们有多种方式来做一件事情时,我们不知道哪种方式运行更快的另一个原因是要考虑上下文。在上一节中,我们执行一个大小写不敏感的字符串查询来寻找1个字符串是否在其它26个字符串中。当我们换一个角度来比较1个字符串是否在其他100,000个字符串中时,结论可能是完全不同的。


上面的列表不是很完整的,因为还有更多的缺陷需要我们去发现。例如,测试不现实的场景或者只在JavaScript引擎上测试。但是确定的是对于JavaScript开发者来说,如果你想编写更好更快的Web应用,performance.now()是一个很棒的方法。最后但并非最不重要,请谨记衡量执行时间只是“更好的代码”的一反面。我们还要考虑内存消耗以及代码复杂度。


怎么样?你是否曾经使用这个函数来测试你的代码性能?如果没有,那你是怎么来测试性能的?请在下面的评论中分享你的想法,让我们开始讨论吧!



推荐阅读

(点击标题可跳转阅读)

页面性能优化办法有哪些?

前端性能优化原理与实践

JavaScript 性能优化技巧分享



觉得本文对你有帮助?请分享给更多人

关注「前端大全」加星标,提升前端技能

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存