前端JS代码的性能探究

640?wx_fmt=gif

问题

  团队中做code review有一段时间了,最近一直在思考一个问题,抛开业务逻辑,单纯从代码层面如何评价一段代码的好坏?

  好和坏都是相对的,一段不那么好的代码经过优化之后,如何标准化的给出重构前后的差异呢?

  我们所有的代码都跑在计算机上,计算机的核心是CPU和内存。从这个角度来看,效率高的代码应当占用更少的CPU时间,更少的内存空间。

  因此,问题就演变为优化一段代码,到底优化了多少CPU的使用以及内存空间的使用?

CPU-时间复杂度

时间复杂度

  在数据结构与算法中,常用大O来表示算法的时间复杂度,常见的时间复杂度如下所示:(来源《算法》第四版)

640?wx_fmt=other

  时间复杂度这个东西,是描述一个算法在问题规模不断增大时对应的时间增长曲线。所以,这些增长数量级并不是一个准确的性能评价,可以理解为一个近似值,时间的增长近似于logN、NlogN的曲线。如下图所示:

640?wx_fmt=other

  上面是关于时间复杂度的解释,下面通过具体样例来看看代码的时间复杂度

代码一:

(function count(arr=[1,2,3,4,5,6,7,8,9,10]){
  let num = 0
  for(let i=0;i<arr.length;i++){
    let item = arr[i]
    num = num + item
  }
  return num
})()
复制代码

  这是一段求数组中数字总和的代码,我们粗略估计上述代码在CPU中表达式运算的时间都是一样的,计为avg_time,那么我们来算一下上面的代码需要多少个avg_time.

  首先从第二行开始,表达式赋值计为1个avg_time;代码的3、4、5行分别要运行10次,其中第三行比较特殊,每次运行需要计算arr.length以及i++,所以这里需要(2+1+1)*10 个avg_time;总共就是(2+1+1)*10+1=41个avg_time

  接着,我们来对上面的代码优化一番,如下所示: 代码二

(function count(arr=[1,2,3,4,5,6,7,8,9,10]){
  let num = 0
  let len = arr.length
  while(len--){
    num = num + arr[len]
  }
  return num
})()
复制代码

  不难算出,优化后的代码只耗费了1+1+(1+1)*10=22个avg_time,代码二相对于代码一,节约了41-22=19个avg_time,代码性能提升19/41=46.3%!

如何写出低时间复杂度的代码?

1.灵活使用break、continue、return

  这三个关键字一般用在减少循环次数,达到目的,立即退出。如下所示:

    (function check(arr=[1,2,3],target=2){
      let len = arr.length
      while(len--){
        if(arr[len]===target){
          
          return len
        }
      }
      return -1
    })()
复制代码

2.空间换时间

常见的做法是利用缓存,把上次的计算结果存起来,避免重复计算。

3.更优的数据结构与算法

根据不同的情况选择合适的数据结构与算法,例如,如果需要频繁的从一组数据中通过关键key查询出数据,如果要从json对象和数组中选择,那么可以优先考虑使用json对象来避免数组的遍历查询。

内存-空间复杂度

  评价一段代码,除了看它执行需要多少时间,还需要看看需要多少空间,谈到代码的空间占用,必须就得知道JS的内存管理

  JS的内存管理分为三部分:

  • 内存分配。

  • 使用分配的内存。

  • 内存回收。

这里,放一张JS Runtime的图

640?wx_fmt=other

静态内存分配

  是指stack中内存的分配,基础数据类型的数据就放在stack中。另外,stack是有固定大小的,超过stack的长度,就会报错,所以必须得节约着用。

爆栈

function foo(){
  foo()
}
foo()

VM201:1 Uncaught RangeError: Maximum call stack size exceeded
    at foo (<anonymous>:1:13)
    at foo (<anonymous>:2:3)
    at foo (<anonymous>:2:3)
    at foo (<anonymous>:2:3)
    at foo (<anonymous>:2:3)
    at foo (<anonymous>:2:3)
    at foo (<anonymous>:2:3)
    at foo (<anonymous>:2:3)
    at foo (<anonymous>:2:3)
    at foo (<anonymous>:2:3)

  我们是怎么达到爆栈目的的呢?因为所有的函数调用,在内存中都存在一个函数调用栈,我们不断无结束条件的递归调用,最终撑破了stack。

如图所示:

640?wx_fmt=other
函数调用栈

可能你会问怎么证明函数调用栈的存在呢?请看如下代码:

function second() {
    throw new Error('function call stack');
}
function first() {
    second();
}
function start() {
    first();
}
start();

VM266:2 Uncaught Error: function call stack
    at second (<anonymous>:2:11)
    at first (<anonymous>:5:5)
    at start (<anonymous>:8:5)
    at <anonymous>:10:1

  从上面的运行结果可以看出函数调用栈的顺序,start先入栈,接着first,最后second;打印顺序为首选打印second,最后打印start;满足栈的先进后出的数据结构特性。

内存占用

  了解上面知识点的核心目的还是在于指导我们写出更优的代码,我们知道基本数据类型都放在栈中,对象都放在堆中。另外,通过《JavaScript权威指南》第六版第三章可以知道,js中的数字都是双精度类型,占64位8个字节的空间,字符占16位2个字节的空间。

  有了这个知识,我们就可以估算出我们的代码大致占用了多少内存空间。

  这些毕竟都是理论知识,不禁要怀疑一下,的确是这样的吗?下面我们利用爆栈的原理,通过代码实际瞧瞧

let count = 0
try{
  function foo() {
    count++
    foo()
  }
  foo()
}finally{
  console.log(count)
}


我们知道一个数字占8个字节,栈的大小固定;稍微变更一下代码

let count = 0
try{
  function foo() {
    let local = 58 
    count++
    foo()
  }
  foo()
}finally{
  console.log(count)
}


那么我们可以利用如下方法算一下栈的总大小

N = 栈中单个元素的大小
15662 * N = 13922 * (N + 8) // 两次函数调用,栈的总大小相等
(15662 - 13922) * N = 13922 * 8
1740 * N = 111376
N = 111376 / 1740 = 64 bytes
Total stack size = 15662 * 64 = 1002368 = 0.956 MB

注:不通环境可能结果不太一样

接下来,我们来确定一下数字类型是否占8个字节空间

let count = 0
try{
  function foo() {
    
    let local = 58
    let local2 = 85
    count++
    foo()
  }
  foo()
}finally{
  console.log(count)
}


计算一下Number的内存占用大小

// 总的栈内存空间/栈中元素数量 = 单个栈元素大小
1002368/12530 = 80
// 对比不带任何额外变量的代码,单个栈元素大小是64,这里新增两个16,加起来正好为80
80 = 64+8+8

经实际验证,在Chrome、Safari、Node环境下,不论变量的值是什么类型,在stack中都占8个字节。对于字符串貌似跟预期不太一样,不论多长的字符串实践表明在stack中都占8个字节,怀疑浏览器默认把字符串转换为了对象,最终占用heap空间

动态内存分配

  是指heap中内存的分配,所有对象都放在heap中,stack中只放对象的引用。

这里有一篇数组占用多少内存空间的文章:How much memory do JavaScript arrays take up in Chrome?

如何写出低内存占用的代码?

  低内存占用,从静态内存分配方面可以考虑,尽量少的使用基础类型变量;从动态内存分配的角度,让代码更简洁、不要毫无节制的new一个对象、少在对象放东西;

下面是一些小技巧:1.三目运算符

    
    if(a===1){
      b = 'aa'
    }else{
      b = 'bb'
    }
    
    b = a===1 ? 'aa' : 'bb'

2.直接返回结果

   if(a===1){
     return true
   }else{
     return false
   }
   
   return a===1

一时半会儿想不到好的样例,上面的样例至少节约了代码的空间占用!......欢迎评论补充......

内存回收

  我的理解是,当函数调用栈为空时,占用的占内存随之清空;只有堆内存中的数据才需要通过垃圾回收机制来回收。

常见的垃圾回收算法如下:

  • 引用计数无法清除循坏依赖的对象

  • 标记清除:

  • 综合算法:

这里有一篇垃圾回收的文章:A tour of V8: Garbage Collection 已经被翻译为了中文,点进去就知道了。

如何避免内存溢出?

  从上面的垃圾回收机制不难看出,当某些情况内存无法被回收且不断增加时,内存溢出就会产生。下面是几种常见的会有内存溢出风险的代码。

1.控制全局变量

function foo(arg) {
    a = "some text";
    this.b = "some text";
}

foo()

2.setInterval注意内存占用

3.注意闭包

 热 文 推 荐 

☞ JavaScript代码简洁之道

☞ 使用 webpack 各种插件提升你的开发效率

☞ 中高级前端大厂面试秘籍,寒冬中为您保驾护航,直通大厂(上)

☞ 我是如何在阿里巴巴面试中壮烈牺牲的?(内含面试题)

☞ vue-cli3 项目从搭建优化到docker部署

640?wx_fmt=png

640?wx_fmt=png

喜欢就点击“好看”吧!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值