读书笔记-高性能js-算法和流程控制-再读

算法和流程控制

目录

  1. 循环
  2. 条件语句
  3. 递归

代码的整体结构是影响运行速度的主要因素之一。
代码数量少并不意味着运行速度就快,代码数量多也并不意味着运行速度就一定慢。

循环

循环处理是最常见的编程模式之一,也是提升性能必须关注的要点之一。
理解js中循环对性能的影响至关重要,因为死循环或长时间运行的循环会严重影响用户体验。

问题
  1. 循环有几种
  2. 哪种性能好
  3. 怎么去优化

循环的类型

for循环
for(let i = 0; i < 10; i++) {
  // 循环主体
}

for循环的组成:初始化,前测条件,后执行体,循环体。
在for循环初始化中var语句会创建一个函数级别的变量,而不是循环级。
es6推出的let是块作用域。

while循环
let i = 0
while( i < 10) {
  // 循环主体
  i++
}
do-while循环
let i = 0
do{
  // 循环主体
} while(i++ < 10)
for-in循环
for(let prop in object) { // 循环返回的属性包括了对象实例属性和原型链中继承的属性
  // 循环主体
}

例子:

function Animal(){
  this.name = 'animal'
  this.eat = function() {
    console.log('food')
  }
}
Animal.prototype.say = function() {
  return 'a'
}
let cat = new Animal()
for(let prop in cat) {
  console.log(prop)
}
小结
  1. for、while 前测循环
  2. do while 后测循环,至少执行一次。
  3. for in 枚举

性能

引入

for in 最慢,每次迭代都要搜索实例和原型属性。
优化点1:过滤掉不属于自己的属性

function Animal(){
  this.name = 'animal'
  this.eat = function() {
    console.log('food')
  }
}
Animal.prototype.say = function() {
  return 'a'
}
let cat = new Animal()
for(let prop in cat) {
  if(cat.hasOwnProperty(prop)) {
    console.log(prop)
  }
}

优化点2:遍历已知的属性的列表,提取已知属性,使用其他循环代替。

function Animal(){
  this.name = 'animal'
  this.eat = function() {
    console.log('food')
  }
}
Animal.prototype.say = function() {
  return 'a'
}
let cat = new Animal()
let props = ['eat', 'name']
let i = 0
while(i < props.length) {
  console.log(cat[props[i++]])
}

相对于查找该对象的每个属性,该代码只关注给定的属性,减少了开销,也节省了时间。

tip:不要使用for in 遍历数组成员。为什么?

小结:除了for-in循环外,其他循环类型的性能都差不多,深究哪种循环最快没有什么意义。

循环的选择基于需求而不是性能。

如果循环类型与性能无关,那么如下选择:
1.每次迭代处理的事务
2.迭代的次数
通过减少这两者之一或全部的时间开销,提升整体的循环性能。

循环分析

根据条件一,很明显,如果一次循环迭代要花很多的时间去执行,那么多次循环意味着需要更多的时间。
for的一般写法:

for(let i = 0; i < arr.length; i++) {
  process(arr[i])
}

分析:
在上面的循环中,每次运行循环体都会产生如下操作:

  1. 在控制条件中查找一次属性(arr.length)
  2. 在控制条件中执行一次数值比较( i < arr.length)
  3. 一次比较操作查看控制条件的计算结果是否为true(i < arr.length === true)
  4. 一次自增操作(i++)
  5. 一次数组查找(arr[i])
  6. 一次函数调用(process(arr[i]))
    在这些简单的循环中,即使代码不多,每次迭代也要进行许多操作。
    代码运行速度很大程度取决于函数(process())对每个数组成员项的操作,
    即使如此,减少每次迭代中的操作总数能大幅提高循环的总体性能。
优化

优化循环的第一步是减少对象成员及数组项的查找次数。在大多数浏览器中,这些操作比使用局部变量和字面量需要花费更大时间。
举个例子:当我们操作一个元素或者赋值的时候,有机会出现下面的写法。

例子:赋值1

let res = await this.$get(url, params)
if (res && res.code === '00000') {
  res.data = res.data.reverse()
  res.data.map(item => {
    item.selected = true
    return item
  })
  this.data = res.data
}

例子:赋值2

handleRefuse() {
  this.$refs.dialog.dialogTitle = '操作驳回xx'
  this.$refs.dialog.auditResult = '1'
  this.$refs.dialog.dialogShow = false
  this.$refs.dialog.afferentArr = this.selectedData
}

每一行代码都要先去查找this->$refs->dialog->对应的属性,这里执行了四次查找。

赋值优化1:

let res = await this.$get(url, params)
if (res && res.code === '00000') {
  let list = res.data
  list.map(item => {
    item.selected = true
    return item
  }).reverse()
  this.data = list
}

赋值优化2:

handleRefuse() {
  let obj = {
    dialogTitle : '操作驳回xx',
    auditResult : '1',
    dialogShow  : false,
    afferentArr : this.selectedData
  }
  Object.assign(this.$refs.dialog, obj)
}

分析:前面的例子中,每次循环都需要查找item.length,然而该值在运行过程中却并没有什么改变,因此产生了不必要的性能损失。
我们可以将该值存在一个局部变量中,然后在控制条件中使用这个变量。

1.限制循环中耗时操作的数量
1.1减少每次arr.length的查找(根据条件一,从初始值入手优化)
// 最小化属性查找
for(let i = 0, l = arr.length; i < l; i++) {
  ...
}

因为这是优化每次读取的数组的长度,所以该优化的效果由数组的长度决定。

1.2颠倒数组(根据条件一,从判断条件入手优化)

通常数组项的顺序和执行的任务无关,因此从最后一项开始向前处理是其中一个可选方案。
这是编程语言中一种通用的性能优化方法。它减少了属性查找。

// 倒序
for(let i = l.arr.length; i > 0; i--) {
  ...
}
// 再优化,合并>0的判断。条件判断会自动转为true
for(let i = l.arr.length; i--;) {
  ...
}

分析:使用了倒序循环,并把减法操作放在控制条件中。
现在每个控制条件只是简单的与0比较,即控制条件和true值比较时,任何非0都会自动转换为true,而0等同于false。
控制条件从两次比较(迭代数少于总数吗?它是否为true?i < l; i < l === true)减少到一次比较(它是true吗?i-- === true
对比原版本,我们这里减少了两次操作。

当复杂度为O(n)时,减少每次迭代的工作量最有效。
当复杂度大于O(n)时,着重减少迭代次数。

算法复杂度比较

O(1)<O(logn)<O(n)<O(nlogn)<O(n²)<O(n³)<O(2ⁿ)<O(n!)

2.减少迭代次数

即使循环体中执行最快的代码,累计迭代上千次也会慢下来。
此外,循环体运行时会带来一次小的性能开销,这增加了总体运行时间。
减少迭代次数能获得更加显著的性能提升。

最广为认知度一种限制循环次数的模式被称为:达夫设备模式-“Duff Device”
[例子演示][https://andrew.hedges.name/experiments/javascript_optimization/]

let arr = [1,2,3,4,5,6,7,8,9,10]
let iterations = Math.floor(arr.length / 8)
let startAt = arr.length % 8
let i = 0
do {
  switch(startAt) { // 12 % 8 = 4
    case 0: process(arr[i++]);
    case 7: process(arr[i++]);
    case 6: process(arr[i++]);
    case 5: process(arr[i++]);
    case 4: process(arr[i++]); // 跳到这里开始执行,process执行四次。
    case 3: process(arr[i++]);
    case 2: process(arr[i++]);
    case 1: process(arr[i++]);
  }
  startAt = 0; // 置零后从头开始循环,一会八次。
} while(--iterations)
function process(v) {
  for(let i = 100; i--;) {
    arr.push(v)
  }
}

分析:每次循环中最多可调用8次process(),循环的迭代次数为总数除以8。
startAt存放余数,表示第一次循环中应调用多少次process()
假设原本是12次循环,那么第一次循环会调用4次,第二次调用8次,以此类推。

再次优化,对余数分离处理

let arr = [1,2,3,4,5,6,7,8,9,10]
let startAt = arr.length % 8
if(startAt) {
  while(startAt) {
    process(arr[startAt--])
  }
}
let i = Math.floor(arr.length / 8)
while(i) {
  process(arr[i--])
  process(arr[i--])
  process(arr[i--])
  process(arr[i--])
  process(arr[i--])
  process(arr[i--])
  process(arr[i--])
  process(arr[i--])
}
function process(v) {
  for(let i = 100; i--;) {
    arr.push(v)
  }
}

分析:一次循环改为两次,但由于移除了switch语句,速度更快。

小结:

  1. 用多次循环代替原来的循环次数。
  2. 适用于迭代次数超过1000的情况
  3. 但随之浏览器对循环迭代的优化,该算法的优势没有那么明显,然其经典的思想可以借鉴。
3.函数迭代

forEach 基于函数迭代,比 循环的迭代 慢很多(接近8倍),主要原因是对每个数组成员调用额外的外部方法带来了开销。

// 随机内容
var chars = '0123456789abcdef'
function getRandomString() {
  var len = Math.ceil(Math.random() * 7) + 3 // 4-10 随机个数
  var result = ''
  while (len--) {
    result += chars.charAt(Math.floor(Math.random() * chars.length))
  }
  return result
}

var len = 1000 // 数组子项数量
var count = 100 // 循环次数

var ary = []
while (len--) {
  ary.push('' + getRandomString() + '')
}

function process(item) {
  var isInt = /(^[0-9]$)|(^[1-9][0-9]+$)/
  if (isInt.test(item)) {
    item += 1
  }
}

function for_normal_forEach() {
  ary.forEach(function (value, index) {
    // process(ary[index]); // 函数调用
    ary[index]++ // 过程代码
  })
}

function for_normal() {
  for (var i = 0, len = ary.length; i < len; i++) {
    // process(ary[i]) // 函数调用
    ary[i]++ // 过程代码
  }
}

console.log('数组长度:' + ary.length)
console.log('Profiling will begin in 2 seconds...')
setTimeout(function () {
  console.log('普通for循环测试:')
  var currTime = new Date()
  console.profile()
  for (var i = 0; i < count; i++) {
    for_normal()
  }
  console.profileEnd()
  console.log('用时:' + (new Date() - currTime) + 'ms')

  console.log('forEach循环测试:')
  currTime = new Date()
  console.profile()
  for (var i = 0; i < count; i++) {
    for_normal_forEach()
  }
  console.profileEnd()
  console.log('用时:' + (new Date() - currTime) + 'ms')
}, 2000)

小结:
循环函数时, 随着函数迭代尾调用优化,该方法的速度比普通的for循环还快。
循环过程式代码时,该方法比普通的for循环还慢。

条件语句

由于各个浏览器对流程控制进行了不同的优化,所以使用哪种技术没有更好的定论。

使用if-else还是switch

一般的例子:

  if(color === 'red') {
    // 代码处理
  } else {
    // 代码处理
  }
  switch(color) {
    case 'red':
      // 代码处理
      break;
    default:
      // 代码处理
  }

数量多的时候的例子:

  if(color === 'red') {
    // 代码处理
  } else if(color === 'green'){
    // 代码处理
  } else if(color === 'blue'){
    // 代码处理
  } else if(color === 'yellow'){
    // 代码处理
  }
  switch(color) {
    case 'red':
      // 代码处理
      break;
    case 'green':
      // 代码处理
      break;
    case 'blue':
      // 代码处理
      break;
    case 'yellow':
      // 代码处理
      break;
    default:
      // 代码处理
  }

小结:switch-case可读性好于if-else
一般情况下,switch速度更快,但只有数量大时才明显。
由于当条件增加时,if-else性能负担增加的程度比switch要多。
所以,最流行的方法是基于测试条件的数量来判断:

  1. 条件少时,if-else易读
  2. 条件多时,switch易读(主要是看代码的易读性而定,而后才是性能。)

通常来说,当if-else适用于判断两个离散值或者几个不同的值域。
当判断多于两个离散值时,switch更佳。

tip: switch比较值时,使用全等判断,不会发生类型转换的损耗。

优化if-else

目标:最小化到达正确分支所需判断的条件数量。

例子1:

if ( value < 5 ) {
  // coding
} else if (value > 5 && value < 10) {
  // coding
}

分析:该代码只有 value 小于 5时是最优,其他则要经过两次判断,最终增加了消耗的平均时间。
一般而言,if-else 语句应该按照从大概率到小概率的顺序来排列,确保速度最快。

例子2:

  if (value === 0) {
    return value0
  } else if(value === 1) {
    return value1
  } else if(value === 2) {
    return value2
  } else if(value === 3) {
    return value3
  } else if(value === 4) {
    return value4
  } else if(value === 5) {
    return value5
  } else if(value === 6) {
    return value6
  } else if(value === 7) {
    return value7
  } else if(value === 8) {
    return value8
  } else if(value === 9) {
    return value9
  } else {
    return value10
  }

优化:二分法,改为嵌套

  if(value < 6 ) {
    if(value < 3) {
      if (value === 0) {
        return value0
      } else if(value === 1) {
        return value1
      } else {
        return value2
      }
    } else {
      if (value === 3) {
        return value3
      } else if(value === 4) {
        return value4
      } else {
        return value5
      }
    }
  } else {
    if(value < 8) {
      if (value === 6) {
        return value6
      } else {
        return value7
      }
    } else {
      if (value === 8) {
        return value8
      if (value === 9) {
        return value0
      } else {
        return value10
      }
    }
  }

二分法将值域分成一系列的区间,然后逐步缩小范围。
当值均匀分布在0-10区间的时候,二分法大约是原来if-else嵌套语句执行的平均时间的一半。
二分法适合多个值域(非离散值)需要测试的时候。

小结:

  1. 最可能的情况放在首位。
  2. 确保最大概念到最小概念的顺序排列
  3. 使用if-else嵌套语句比单独使用更优

查找表

当有大量离散值时,使用查找表。
例子:

  switch(value) {
    case 0:
      return result0;
      break;
    case 1:
      return result1;
      break;
    case 2:
      return result2;
      break;
    case 3:
      return result3;
      break;
    case 4:
      return result4;
      break;
    case 5:
      return result5;
      break;
    case 6:
      return result6;
      break;
    case 7:
      return result7;
      break;
    case 8:
      return result8;
      break;
    case 9:
      return result9;
      break;
    default:
      return result10;
  }
  // 使用数组表示的情况,这得是数据有按照顺序,不重复并有规律。
  let result = arr[result0,result1,result2,result3,result4,result5,result6,result7,result8,result9,result10]
  return result[value]

展开来讲,我们具体点可以这么写:

const actions = new Map([
  ['admin_1', ()=>{/*do sth*/}],
  ['admin_2', ()=>{/*do sth*/}],
  ['admin_3', ()=>{/*do sth*/}],
  ['admin_4', ()=>{/*do sth*/}],
  ['admin_5', ()=>{/*do sth*/}],
  ['user_1', ()=>{/*do sth*/}],
  ['user_2', ()=>{/*do sth*/}],
  ['user_3', ()=>{/*do sth*/}],
  ['user_4', ()=>{/*do sth*/}],
  ['user_5', ()=>{/*do sth*/}],
  ['default', ()=>{/*do sth*/}],
])
const handle  = (identity,status)=>{
  let action = actions.get(`${identity}_${status}`) || actions.get('default')
  action.call(this)
}

查找表可以使用数组或者对象来构建。
查找表的优点:不用书写任务条件语句,即便候选值增加时,也几乎不会产生额外的消耗。
查找表速度更快,可读性更好
但项目中一般我们仅仅用来存储常量。

查找表和switch-case的区别:
查找表适合于键值映射的情况
而switch更适合一个键对应一个独特动作或者一系列行为的场合。

递归

引入

例子,阶乘算法:

  function factorial(n) {
    if(n === 0) {
      return 1;
    } else {
      return n * factorial(n - 1)
    }
  }

递归是为了简单化算法
但递归存在以下潜在问题:

  1. 终止条件不明确或缺少终止条件导致函数长时间执行
  2. 可能遇到浏览器的“调用栈大小限制”

各浏览器有各自的限制,但不应该发布有问题的递归。

递归模式

出问题的两种类型

  1. 递归调用函数本身(如上例子)
  2. 两个函数相互调用
function first() {
  second()
}
function second () {
  first()
}
first()

最常见的是终止条件的问题,所以验证终止条件是解决问题的关键。

迭代

为了能在浏览器中安全的工作,一般将递归改为迭代,或者两者结合使用。

例子:递归排序算法

// 归并排序算法
// 是第一个可以被实际使用的排序算法
// 其复杂度为O(nlogn)。
// 归并排序是一种分治算法。其思想是将原始数组切分成较小的数组,直到每个小数组只有一
// 个位置,接着将小数组归并成较大的数组,直到最后只有一个排序完毕的大数组。
// 由于是分治法,归并排序也是递归的:
let mergeSort = function (arr) {
  let merge = function (left, right) {
    let res = []
    let il = 0
    let ir = 0
    // 切分到最小分组,左右分别只有一个值,按照从小到大比较后排序。
    // 返回迭代上一层,继续比较,直到回到顶层。
    while (il < left.length && ir < right.length) {
      if (left[il] < right[ir]) {
        res.push(left[il++])
      } else {
        res.push(right[ir++])
      }
    }
    // 收入剩余的
    while (il < left.length) {
      res.push(left[il++])
    }
    while (ir < right.length) {
      res.push(right[ir++])
    }
    return res
  }
  let mergeSortRec = function (arr) {
    let l = arr.length
    // 合并不断递归进入时,直到只有一层,返回到merge去比较
    // 合并返回时,merge合并后只有一层, 返回上一层作为的上一层的merge参数进入比较合并。
    // 最后递归回来,直到合并成一个数组
    if (l === 1) {
      return arr
    }
    // 切分为两组
    let mid = Math.floor(l / 2)
    let left = arr.slice(0, mid)
    let right = arr.slice(mid)
    return merge(mergeSortRec(left), mergeSortRec(right))
  }
  return mergeSortRec(arr)
}

分析:mergeSort函数会导致很频繁的自身调用。一个长度为n的数组最终会调用mergeSortRec()2*n-1次。
如果数组长度超过1500,则会在火狐浏览器发生栈溢出。

优化:迭代实现递归排序算法:

  function mergeSortRec(items) {
    if (items.length === 1) {
      return items
    }
    let work = []
    for (let i = 0, len = items.length; i < len; i++) {
      work.push([item[i]])
    }
    work.push([]) // 如果数组长度为奇数
    for (let lim = len; lim > 1; lim = (lim + 1)/2) {
      for (let j = 0, k = 0; k < lim; j++, k += 2) {
        work[j] = merge(work[k], work[k+1])
      }
      work[j] = []
    }
    return work[0]
  }

这个版本稍微慢一些,但不会受到溢出栈的限制。

小结:
迭代算法一般包含几个不同的循环,分别对应计算过程的不同方面,
虽然会引入一些问题,但优化后的循环替代长时间运行的递归函数是可以提升性能的。
因为运行一个循环比反复调用一个函数的开销要少很多。

任何递归能实现的算法都可以使用迭代实现。
使用优化后的循环比递归函数更具性能,开销更少。
将递归算法改为迭代实现,避免调用栈限制。

Memorization

减少工作量就是最好的性能优化技术。
Memorization正是一种避免重复工作的方法,它缓存前一个计算结果供后续计算使用。
当代码中多次调用递归函数时,大量重复的工作不可避免。

例子:

let fact6 = factorial(6)
let fact5 = factorial(5)
let fact4 = factorial(4)

分析:这段代码有3次阶乘计算,导致该函数一共被调用18次。
而且,所有必要的计算在第一行代码里就处理掉了:6的阶乘等于6乘以5的阶乘,因此5的阶乘被计算了两次。
4的阶乘被计算了3次。我们应该保存并重用他们的计算结果,而不是每次都重新计算整个函数。

优化:

function memFactorial(n) {
  if(!memfactorial.cache) {
    memfactorial.cache = {
      '0': 1,
      '1': 1
    }
  }
  if(!memfactorial.cache.hasOwnProperty(n)) {
    memfacotrial.cache[n] = n * memfacotrial(n - 1)
  }
  return memfacotrial.cache[n]
}

这个优化的版本创建了一个缓存对象,这个对象存储在对象自身内部,并预设两个最简单的阶乘。
在计算一个阶乘前,会先检测这个缓存对象看看是否已经存在相应的计算结果。没有对应的缓存值则意味着这是第一次计算。
计算完成后,结果被存储在缓存中为以后使用。

let fact6 = memFactorial(6)
let fact5 = memFactorial(5)
let fact4 = memFactorial(4)

该代码返回了3个不同的阶乘,但只调用了该函数8次。因为所有必要的计算都在第一行代码完成并缓存了,
接下来的两行代码不会发生递归运算,而是直接返回缓存中的值。

封装一个具备基础功能的通用的缓存函数

  function mem(func, cache) {
    cache = cache || {}
    let shell = function(arg) {
      if (!cache.hasOwnProperty(arg)) {
        cache[arg] = func(arg)
      }
      return cache[arg]
    }
    return shell
  }

mem接收两个参数,一个是需要增加缓存功能的函数,一个是可选的缓存对象。
创建了一个封装了原始函数的外壳函数,只有当一个结果值之前从未被计算过时才会产生新的计算。

// 缓存该函数
let memFactorial = mem(factorial, {'0': 1, '1': 1})
// 调用
let fact6 = memFactorial(6)
let fact5 = memFactorial(5)
let fact4 = memFactorial(4)

因为该mem函数会缓存特定参数的函数调用结果,该方法比上一个版本优化效果稍差。
当代码以相同的参数多次调用外壳函数时才能节省时间。
因此,如果mem函数存在显著性能问题时,最好是针对性的手工实现,而不是直接使用mem方案。

续上面登录用户权限的问题,项目中我们还可能遇到这样的情况:
在admin情况下,status1-4的处理逻辑都一样:

const actions = new Map([
  ['admin_1', ()=>{/*function A*/}],
  ['admin_2', ()=>{/*function A*/}],
  ['admin_3', ()=>{/*function A*/}],
  ['admin_4', ()=>{/*function A*/}],
  ['admin_5', ()=>{/*function B*/}],
])

那么,我们可以缓存FnA和FnB

const actions = ()=>{
  const functionA = ()=>{/*do sth*/}
  const functionB = ()=>{/*do sth*/}
  return new Map([
   ['admin_1', functionA],
   ['admin_2', functionA],
   ['admin_3', functionA],
   ['admin_4', functionA],
   ['admin_5', functionB],
    //...
  ])
}

如果status超过10条或者更多,代码同样很臃肿,那么我们可以在以上代码加上正则匹配:

const actions = ()=>{
  const functionA = ()=>{/*do sth*/}
  const functionB = ()=>{/*do sth*/}
  return new Map([
    [/^admin_[1-4]$/,functionA],
    [/^admin_5$/,functionB],
    //...
  ])
}
const handle = (identity,status)=>{
  let action = [...actions()].filter(([key,value])=>(key.test(`${identity}_${status}`)))
  action.forEach(([key,value])=>value.call(this))
}

更多的应用比如react的mem函数,vue的computed,key,keep-alive等。


总结:

  1. for,while,do-while性能都差不多。
  2. 避免使用for-in循环
  3. forEach函数不适合用来循环过程式代码,更适合循环函数
  4. 改善循环性能的最佳方式是减少迭代运算量和迭代次数
  5. 通常来说,switch比if-else快
  6. 条件较多时,查找表更快
  7. 递归算法有的问题,现在已经通过es6的尾调用改善了。
  8. 函数遇到栈溢出错误可改写为迭代
  9. 函数可以使用memorization来避免重复计算以提升性能
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值