你不知道的JS系列——领略性能测试与调优

木桶效应 & 约束理论

木桶效应: 一只木桶能盛多少水,并不取决于最长的那块木板,而是取决于最短的那块木板。也可称为短板效应。

约束理论: 一个系统最薄弱的地方确定了这个系统有多强大,专注于瓶颈。与直觉相反,如果你把整个系统分解,单独优化每个部分,你会降低整个系统的效率。相反,要优化整个系统。

类比我们人类,人无完人,谁都有缺点,缺点越多成功的几率就越小,我们想要成功就需要先改善缺点,要改善缺点前提就是先要找到缺点。那么对于系统而言,就是通过性能测试定位系统短板,然后进行针对性调优。各种各样的系统,只能说这个系统更适合哪方面,不适合哪方面,你总不能拿 B2B 系统去做 C2C 方面的业务,既然是做 B2B 的系统,那针对这个系统你就该在 B2B 方面去针对性改善。

现实社会残酷无情,人们总是会变成最初自己讨厌的模样

性能测试

如题: 如何测试某个运算的速度(即执行时间)?
答:

var start = Date.now();
doSomething();  // 进行一些操作
var end = Date.now(); 
console.log( "耗时:", (end - start) );
复制代码

或者:

console.time('A');  // A 为计时器名称
doSomething();  // 进行一些操作
console.timeEnd('A');   // 结束计时器A,程序运行所经过的时间会被自动输出到控制台
复制代码

以上做法的错误之处:

不十分精确:举例,若 0ms < 执行时间 < 15ms ,而 IE 早期版本定时器精度只有 15ms ,故此时报告时间会是 0
只能声称这次特定的运行消耗了大概这么长时间,因为你并不明确此时引擎或系统有没有受到什么影响
在获得 start 或 end 时间戳之间也可能有其他一些延误
不明确当前运算测试的环境是否过度优化

提问:你说我不精确,那我用循环让它运行一百一千甚至更多次,取平均值,这不就精确了?

: 依旧不精确,过高或过低的的异常值也可以影响整个平均值,然后再重复应用,误差继续扩散,只会产生更大的欺骗性。而且你还有许多需要考虑的东西:定时器的精度、异常因素、运行环境(桌面浏览器、移动设备...)等,再者你需要大量的测试样本,然后汇集测试结果,诚然这并不简单。

提问:好吧,我不够专业,那该怎么办?
答: 任何有意义且可靠的性能测试都应该基于统计学上合理的实践。对于统计学,你了解并掌握了多少?

讲真: 唉,我只是一个程序员,不懂这些乱七八糟的...

: 好吧,那就直接用轮子吧,关于这些已经有聪明的人写好了,这里提供一个优秀的库: Benchmark.js ,另外你还可以去 jsPerf 官网看看,它可以在线分析代码性能,非常棒。

不要沉迷于微性能

科学研究表明可能大脑可以处理的最快速度是 13ms ,假设这里有两个程序 X 和 Y , X 的运算速度是人类大脑捕获一个独立的事件发生速度的 125 000 倍,而 Y 只有 100 000 倍,你会觉得 X 比 Y 快很多,但它们的差距在最好情况下也只是人类大脑所能感知到的最小间隙的 65 万分之一,所以这些性能差别无所谓,完全无所谓!

相比之下,我们更应该关注优化的大局,而不是担心这些微观性能的细微差别(比如 ++a 和 a++ 谁更快)。我们只需要优化运行在关键路径上的代码,下面引用的话语足以说明:

花费在优化关键路径上的时间不是浪费,不管节省的时间多么少;

而花在非关键路径优化上的时间都不值得,不管节省的时间多么多

尽管程序关键路径上的性能非常重要,但这并不是唯一要考虑的因素。在性能方面大体相似的几个选择中,可读性应该是另外一个重要的考量因素。

举 :chestnut: :

var x = "42"; // 需要数字42 
// 选择1:让隐式类型转换自动发生
var y = x / 2; 
// 选择2:使用parseInt(..) 
var y = parseInt( x, 0 ) / 2; 
// 选择3:使用Number(..) 
var y = Number( x ) / 2; 
// 选择4:使用一元运算符+ 
var y = +x / 2; 
// 选项5:使用一元运算符| 
var y = (x | 0) / 2;
复制代码

这里 parseInt() 与 Number 是函数调用,所以会比较慢,故撇去 1,2,3 ,比较 4 与 5 , 若 5 比 4 快,这点性能也该是微不足道的,此时你亦不该为了这么点微性能去选择 5 而让程序失去了可读性。

调优

什么是尾调用?

尾调用就是一个出现在另一个函数 "结尾" 处的函数调用,即某个函数的最后一步是调用另一个函数。这个调用在结束后就没有其余事情要做了(除了可能要返回结果值)。

举 :chestnut: :

// 正宗尾调用
function f(x){
   return g(x);
}
// 非尾调用,情况一
function f(x){
   let y = g(x);
   return y;
}
// 非尾调用,情况二
function f(x){
   return g(x) + 1;
}
// 非尾调用,情况三
function f(x){
   g(x);
}
复制代码

情况一:调用函数 g 之后,还有赋值操作;

情况二:调用函数 g 之后,还有加操作;

情况二:调用函数 g 之后,未返回,此时默认为 return undefined ;

以上三种情况在函数调用后都做了其余的事情,所以都不是尾调用。

尾调用优化( TCO )

先来了解下 调用栈 ( call stack ) 的概念:

call Stack 就是你代码执行时的地方,定义为解释器追踪函数执行流的一种机制。每调用一个函数,解释器就会把该函数添加进调用栈并开始执行:

stack overflow

JavaScript 是一种单线程编程语言,这意味着它只有一个 Call Stack 。因此,它一次仅能做一件事。

举个网上常见的 :chestnut: :

function multiply(x, y) {
   return x * y;
}
function printSquare(x) {
   var s = multiply(x, x);
   console.log(s);
}
printSquare(5);
复制代码

函数调用会在内存形成一个 "调用记录",又称 "调用帧"( call frame ),保存调用位置和内部变量等信息。所有的调用帧,形成一个 "调用栈"( call stack )。而调用每一个新的函数都需要额外的一块预留内存来管理调用栈,称为栈帧。



这里在函数 printSquare 的内部调用函数 multiply ,那么在 printSquare 的调用帧上方,会形成一个 multiply 的调用帧。等到 multiply 运行结束,将结果返回到 printSquare , multiply
的调用帧才会消失。

尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用帧,因为调用位置、内部变量等信息都不会再用到了,只要直接用内层函数的调用帧,取代外层函数的调用帧就可以了。

function foo(x) { 
    return x; 
} 
function bar(y) { 
    return foo(y + 1);  // 尾调用
}
bar(18);
复制代码

结合上面的例子解释,也就是说,如果支持 TCO 的引擎能够意识到 foo(y+1) 调用位于尾部,这意味着 bar(..) 基本上已经完成了,那么在调用 foo(..) 时, foo 的调用帧就可以直接取代 bar 的调用帧,并且 foo 也不需要创建一个新的栈帧,而是可以重用已有的 bar(..) 的栈帧。所以上面的代码就等同于直接调用 foo(19) 。

注意:只有不再用到外层函数的内部变量,内层函数的调用帧才会取代外层函数的调用帧,否则就无法进行“尾调用优化”。

Tips:内层函数如果需要外层函数的内部变量(特指 基本值 ),此时可以将这个内部变量作为内层函数的参数传入,利用函数参数的按值传递特性,这样内部函数就不会保留对外部函数变量的引用

上述足以体现出尾调用的优势:不仅速度更快,也更节省内存。当然在简单的代码片段中,这类优化算不了什么(我不敢想象将简单代码都写成尾调用的形式,代码可读性会有多差),但是在处理递归时,这就解决了大问题,特别是如果递归可能会导致成百上千个栈帧的时候。有了尾调用优化 ( TCO ),引擎就可以用同一个栈帧执行所有这类调用,就永远不会出现调用栈空间被占满导致的 "堆栈溢出"( stack overflow )的情况。

尾递归实现阶乘的 :chestnut: :

function factorial(n, total = 1) {
    if (n === 1) return total;
    return factorial(n - 1, n * total);
}
factorial(5) // 120
复制代码

注意:

ES6 的尾调用优化只在严格模式下开启,正常模式下无效( class 内部默认就是严格模式)。 ES6 还规定要求引擎实现 TCO 而不是将其留给引擎自由决定。
TCO 只用于有实际的尾调用的情况。如果你写了一个没有尾调用的递归函数,那么性能还是会回到普通栈帧分配的情形,引擎对这样的递归调用栈的限制也仍然有效。
尾递归优化的实现
蹦床函数

function trampoline(f) {
  while (f && f instanceof Function) {
    f = f();
  }
  return f;
}
复制代码

它接受一个函数 f 作为参数。只要 f 执行后返回一个函数,就继续执行(就像蹦床一样,一直蹦一直爽:joy::joy:)。

注意:执行 f 后是返回一个函数,然后再执行该函数,而不是函数里面调用函数,这样就避免了递归执行,从而就消除了调用栈过大的问题。

原来的递归函数需要用 bind 改写:

// sum是一个递归函数,参数x是需要累加的值,参数y控制递归次数。
function sum(x, y) {
  if (y > 0) {
    return sum.bind(null, x + 1, y - 1); 
    // 这里用bind将参数传入并返回函数本身,注意和apply、call不同,未立即进行调用,
  } else {
    return x;
  }
}
// 调用
trampoline(sum(1, 100000))
复制代码

深入体会一下,调用 sum(1, 100000) 返回传入了参数的 sum 函数自己本身并作为参数传入 trampoline 函数内部, trampoline 函数内部判断 sum 存在且是函数就进行调用,再次得到了传入了参数的 sum 函数自己本身,并将结果再赋值给 sum 本身,依次循环。这里边每循环一次, x 就累加一次, y 递减一次,从而达到累计的目的。应该注意到这里面每次循环都是返回一个函数,并没有真正意义上发生函数 sum 的执行,只是 sum 的参数在变化,从而避免了大量调用栈的形成。

加入我们!!~交流,,,!!群,,,..642830685,领取最新软件测试大厂面试资料和Python自动化、接口、框架搭建学习资料、各类工具安装包

©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容