遍历和递归

436 阅读12分钟

遍历和递归是我们前端开发中经常会用到的编程思想,我初学的时候经常搞不懂遍历和递归,现在工作两三年了,知道了什么是遍历,什么是递归,但是在实际应用过程中也经常会入坑(好吧,我承认是我太菜了,emo中),下面我们就系统地理解一下遍历和递归,以及如何避坑。

遍历

什么是遍历

遍历其实就是在数据的集合中进行逐一获取或查看。通俗地理解就是现在有一筐鸡蛋,我们一个个地依次从篮子里取出鸡蛋的过程就叫做遍历。

常见的遍历方式

针对不同的数据类型,遍历方式也是不一样的,这里我们主要介绍数组和对象的遍历。常见的遍历方式有以下几种

1. for循环

特点:

  1. 能被break等终止循环
  2. 会改变原数据
  • for循环用于数组
    const arr = [  {    id: 1,     value: 'one'  },  {    id: 2,     value: 'two'  },  {    id: 3,     value: 'three'  },  {    id: 4,     value: 'four'  },  {    id: 5,     value: 'five'  }];
    for (let i = 0; i < arr.length; i++) {
      console.log(arr[i]);
    }
    
    // 打印结果
    {id: 1
    value: "one"}
    {id: 2
    value: "two"}
    {id: 3
    value: "three"}
    {id: 4
    value: "four"}
    {id: 5
    value: "five"}
    
  • for循环不能用于对象 for循环是用于被循环遍历的内容具有length属性才能使用的,数组有length属性,但是对象是没有length属性的。因此for循环只能用于数组不能用于对象。
2. forEach
  1. 存在兼容性
  2. 不能被break等终止循环,如果想要终止循环,必须放在try模块中
  • forEach用于数组
  const arr = [
    {
      id: 1, 
      value: 'one'
    },
    {
      id: 2, 
      value: 'two'
    },
    {
      id: 3, 
      value: 'three'
    },
    {
      id: 4, 
      value: 'four'
    },
    {
      id: 5, 
      value: 'five'
    }
  ];
  arr.forEach(item => {
    console.log(item);
  })
  // 打印结果
  // {id: 1, value: 'one'}
  // {id: 2, value: 'two'}
  // {id: 3, value: 'three'}
  // {id: 4, value: 'four'}
  // {id: 5, value: 'five'}
  • forEach不能用于对象 forEach和for循环类似,也是需要被循环的变量具有length属性,对象没有length属性,所以forEach不能用于对象
3. for in

特点:

  1. 能被break等终止循环
  2. 适用于对象和数组,但是大多数情况下用于对象的遍历
  3. 有兼容性问题
  • for in 用于数组
const arr = [  {    id: 1,     value: 'one'  },  {    id: 2,     value: 'two'  },  {    id: 3,     value: 'three'  },  {    id: 4,     value: 'four'  },  {    id: 5,     value: 'five'  }];
for (let i in arr) {
  console.log(`i: ${i}; arr[i]: ${arr[i]}`);
}
// 打印结果
// i: 0; arr[i]:{id: 1, value: 'one'}
// i: 1; arr[i]:{id: 2, value: 'two'}
// i: 2; arr[i]:{id: 3, value: 'three'}
// i: 3; arr[i]:{id: 4, value: 'four'}
// i: 4; arr[i]:{id: 5, value: 'five'}
  • for in 用于对象
  const obj = {
    a: 1,
    b: 2,
    c: 3,
    d: 4
  };
  // eslint-disable-next-line no-extend-native
  Object.prototype.fun = function() {}; // 在原型链上添加属性
  Object.defineProperty(obj, 'testPro1', {
    enumerable: true  // 可枚举
  });
  Object.defineProperty(obj, 'testPro2', {
    enumerable: false  // 可枚举
  });

  for (const i in obj) {
    console.log(`i: ${i}; obj: ${obj[i]}`);
  }

  // 打印结果
  // i: a; obj: 1
  // i: b; obj: 2
  // i: c; obj: 3
  // i: d; obj: 4
  // i: testPro1; obj: undefined
  // i: fun; obj: function () {}

从上面代码可以看出,使用for in 遍历对象,可以把原型上的属性和使用Object.property()上定义的可以枚举的属性都可以遍历出来。

4. for of

特点:

  1. 有兼容性问题
  2. 能被break等终止循环
  3. 可以处理数组,但是不能处理对象
  • for of用于数组
const arr = [
  {
    id: 1, 
    value: 'one'
  },
  {
    id: 2, 
    value: 'two'
  },
  {
    id: 3, 
    value: 'three'
  },
  {
    id: 4, 
    value: 'four'
  },
  {
    id: 5, 
    value: 'five'
  }
];
for (const i of arr) {
  console.log(`i: ${i}; arr: ${arr[i]}`);
}
// 打印结果
// i: {id: 1, value: 'one'} arr: undefined
// i: {id: 2, value: 'two'} arr: undefined
// i: {id: 3, value: 'three'} arr: undefined
// i: {id: 4, value: 'four'} arr: undefined
// i: {id: 5, value: 'five'} arr: undefined

从以上代码可以看出,使用for of打印出来的变量i是直接的成员值,不是索引,这点需要注意一下

  • for of不能用于对象 如果将for of使用在遍历对象上,会报错,TypeError: obj is not iterable,这里就需要提到一个别的概念:可迭代的数据结构,对于for of来说只有可迭代的数据结构才能遍历,
可迭代的数据结构
  1. 什么是可迭代的数据结构呢? 迭代的意思就是按照顺序反复多次执行一段程序,通常会有明确的终止条件。那么,能够按照顺序反复多次执行一段程序的数据结构就是可迭代的数据结构啦。
  2. 可迭代的数据结构具有什么特征? 其实在js中,有很多数据结构已经在原生语言结构下就已经具备了可迭代性,我们直接拿来使用就好,实现这种可迭代的方式就是迭代器模式。
迭代器模式

迭代器Iterator模式提供了一种方法顺序访问一个聚合对象中的各个元素,而又无需暴露该对象的内部实现,这样既可以做到不暴露集合的内部结构,又可让外部代码透明地访问集合内部的数据。迭代器模式为遍历不同的集合结构提供了一个统一的接口,从而支持同样的算法在不同的集合结构上进行操作。

  1. 迭代器(Iterator) 因为表示集合的基本数据有四种,数组,对象,set, map, 它们可以任意组合,因此需要一个统一的接口机制来处理所有的不同的数据结构。

迭代器(也称为遍历器)就是这样一种机制。它是一种接口,为各种不同的数据结构提供统一的访问机制。任何数据结构只要部署Iterator接口就可以完成遍历操作。

作用
  • 为各种数据结构提供统一的,简便的访问接口
  • 使数据结构的成员可以按照某种次序排列
  • ES6提供了一种新的遍历命令:for...of循环,Iterator接口主要供for...of消费
遍历过程

1)创建一个指针对象,指向当前数据结构的起始位置,也就是说迭代器对象本质就是一个指针对象。 2)第一次调用指针对象的next方法,指向当前数据结构的第一个成员变量 3)第二次调用指针对象的next方法,指向当前数据结构的第二个成员变量 4)因此类推,每次调用指针对象的next方法,指向当前数据的下一个成员变量,直至指向当前数据结构的结束位置 每次调用next方法,都会返回当前成员变量的信息,一个包含value和done两个属性的对象。其中value表示当前变量的值,done表示当前遍历是否结束。

实现一个迭代器
// 模拟实现迭代器
function makeIterator(arr) {
  let nextIdx = 0;
  return {
    next: function () {
      return nextIdx < arr.length ? { value: arr[nextIdx++], done: false} : { value: arr[nextIdx], done: true}
    }
  }
}

const it = makeIterator(['a', 'b']);
console.log(it.next()); // {value: 'a', done: false}
console.log(it.next()); // {value: 'b', done: false}
console.log(it.next()); // {value: undefined, done: true}
console.log(it.next()); // {value: undefined, done: true}

上面函数的定义也可以使用箭头函数来写

const makeIterator = (arr) => {
  let nextIdx = 0;
  return {
    next: function () {
      return nextIdx < arr.length ? { value: arr[nextIdx++], done: false} : { value: arr[nextIdx], done: true}
    }
  }
}
  1. 如何将不可迭代的数据结构转成可迭代的数据结构 前面已经提到过,Iterator接口的目的,就是为所有数据结构,提供了一种统一的访问机制,当使用for...of循环遍历某种数据结构时,会先去找是否存在Iterator接口,只要有Iterator接口就认为是可遍历的,否则是不可遍历的。 ES6规定,默认的Iterator接口部署在数据结构的Symbol.iterator属性,或者说,一个数据只要有Symbol.iterator属性,就可以认为是可遍历的。Symbol.iterator本身是一个函数,就是当前数据结构默认的迭代器生成函数。执行这个函数,就会返回一个遍历器。至于属性名Symbol.iterator,它是一个表达式,返回Symbol对象的iterator属性,这是一个预定义好的,类型为Symbol的特殊值,所以要放在方括号内。 因此想要给对象设置可迭代性,就需要给他设置Symbol.iterator属性。 原生的具有Iterator接口的数据结构有
  • Array
  • Map
  • Set
  • String
  • TypedArray
  • 函数的arguments对象
  • NodeList对象
// 还是按照我们上面的测试代码
const obj = {
  a: 1,
  b: 2,
  c: 3,
  d: 4,
  length: 4,
  [Symbol.iterator]: Array.prototype[Symbol.iterator]
};
for (const i of obj) {
  console.log(`i: ${i}; obj: ${obj[i]}`);
}

// 打印结果
// i: undefined; obj: undefined
// i: undefined; obj: undefined
// i: undefined; obj: undefined
// i: undefined; obj: undefined

上述代码我们设置成了数组的Symbol.iterator属性,因此需要设置length属性。 我们也可以自定义一个迭代器或者在对象的原型上定义一个迭代器。

其实上述代码除了定义next方法来实现迭代器,我们还可以采用Generator函数实现一个遍历器,这个我们可以在介绍Generator函数的时候再详细介绍,这里就先提到这里。

以上这部分关于迭代器部分参考[es6.ruanyifeng.com/#docs/itera…]

5. map

特点:

  1. 有兼容性问题
  2. 只适用于数组
  3. 需要使用return将数组的内容返回出来,但是不能使用break终止循环
  • map用于数组
  const arr = [
    {
      id: 1, 
      value: 'one'
    },
    {
      id: 2, 
      value: 'two'
    },
    {
      id: 3, 
      value: 'three'
    },
    {
      id: 4, 
      value: 'four'
    },
    {
      id: 5, 
      value: 'five'
    }
  ];
  arr.map(item => {
    console.log('item', item);
    return item;
  })
  // 打印信息
  // item {id: 1, value: 'one'}
  // item {id: 2, value: 'two'}
  // item {id: 3, value: 'three'}
  // item {id: 4, value: 'four'}
  // item {id: 5, value: 'five'}
  • map不能用于对象
6. filter

特点:

  1. filter不能使用break等终止循环
  2. filter()不会对空数组进行检测
  3. fillter()不会改变原始数组
  • filter用于遍历数组
arr.filter(item => {
  console.log('item', item);
  return item;
})
// 打印信息
// item {id: 1, value: 'one'}
// item {id: 2, value: 'two'}
// item {id: 3, value: 'three'}
// item {id: 4, value: 'four'}
// item {id: 5, value: 'five'}
  • filter不能用于遍历对象
7. find

返回数组中符合条件的第一个元素 特点:

  1. find()不能使用break等终止循环
  2. find()不会对空数组进行检测
  3. find()不会改变原始数组
  • find用于遍历数组
const res = arr.find(item => {
  return item.value === 'four';
})
console.log('res', res);  // res {id: 4, value: 'four'}
  • find不能用于遍历对象
8. findIndex

返回第一个符合条件的元素的索引 特点:

  1. findIndex()不能使用break等终止循环
  2. findIndex()不会对空数组进行检测
  3. findIndex()不会改变原始数组
  • findIndex用于遍历数组
const res = arr.findIndex(item => {
  return item.value === 'four';
})
console.log('res', res); // res 3
  • findIndex不能用于遍历对象
9. every

用于遍历查找所有的数组是否都符合某个条件,符合的话返回true 特点:

  1. every()不能使用break等终止循环
  2. every()不会对空数组进行检测
  3. every()不会改变原始数组
  • every用于遍历数组
const res = arr.every(item => {
  return item.value !== 'ten';
})
console.log('res', res); // res true
  • every不能用于遍历对象
10. some

用于遍历查找每一个元素,只要有一项返回true,则返回true 特点:

  1. some()不能使用break等终止循环
  2. some()不会对空数组进行检测
  3. some()不会改变原始数组
  4. 如果数组中有一项满足条件,就不会继续遍历后面的元素了,直接返回true
  • some用于遍历数组
const res = arr.some(item => {
  return item.value === 'four';
})
console.log('res', res);  // res true
  • some不能用于遍历对象
11. reduce

接收一个函数作为累加器,数组中的每个值(从左到右)开始缩减,最终计算为一个值 特点:

  1. 对于空数组是不会执行回调函数的
  2. reduce接受一个函数,这个函数有四个参数:上一次的值,当前值,当前值的索引, 数组
  3. reduce除了接收函数外,它还可以接收第二个参数:计算的初始值
  • reduce用于遍历数组
let res = 0;
res = arr.reduce((pre, cur) => {
  const val = Number(cur.id);
  return val ? pre + val : pre;
}, 0)
console.log('res', res); // res 15
  • reduce不能用于遍历对象
12. reduceRight

reduceRight和reduce的功能是一样的,只是reduceRight是从尾部开始往前计算的,reduce是从头部开始往后计算的,其他的几乎一样

  • reduceRight用于遍历数组
let res = 0;
res = arr.reduceRight((pre, cur) => {
  const val = Number(cur.id);
  return val ? pre + val : pre;
}, 0)
console.log('res', res);  // res 15
  • reduceRight不能用于遍历对象
13. Object.keys()

Object.keys()只能用来遍历对象

const obj = {
  a: 1,
  b: 2,
  c: 3,
  d: 4
};
// eslint-disable-next-line no-extend-native
Object.prototype.fun = function() {}; // 在原型链上添加属性
Object.defineProperty(obj, 'testPro1', {
  enumerable: true  // 可枚举
});
Object.defineProperty(obj, 'testPro2', {
  enumerable: false  // 可枚举
});

console.log(Object.keys(obj));  // ['a', 'b', 'c', 'd', 'testPro1']
14. Object.values()

Object.values()只能用来遍历对象

const obj = {
  a: 1,
  b: 2,
  c: 3,
  d: 4
};
// eslint-disable-next-line no-extend-native
Object.prototype.fun = function() {}; // 在原型链上添加属性
Object.defineProperty(obj, 'testPro1', {
  enumerable: true  // 可枚举
});
Object.defineProperty(obj, 'testPro2', {
  enumerable: false  // 可枚举
});

console.log(Object.values(obj));  // [1, 2, 3, 4, undefined]
15. Object.entries()

Object.entries()只能用来遍历对象

const obj = {
  a: 1,
  b: 2,
  c: 3,
  d: 4
};
// eslint-disable-next-line no-extend-native
Object.prototype.fun = function() {}; // 在原型链上添加属性
Object.defineProperty(obj, 'testPro1', {
  enumerable: true  // 可枚举
});
Object.defineProperty(obj, 'testPro2', {
  enumerable: false  // 可枚举
});

console.log(Object.entries(obj));  // [['a', 1], ['b', 2], ['c', 3], ['d', 4], ['testPro1', undefined]]
16. while
let i = 0;
while (i < 5) {
  console.log(i);
  i++;
}

// 打印结果
// 0
// 1
// 2
// 3
// 4
17. do while
let i = 0;
do {
  console.log(i);
  i++;
} while (i < 5)

// 打印结果
// 0
// 1
// 2
// 3
// 4

小结:

  1. 只有含有while和for(当然forEach除外)的循环方式才能使用break等循环方式终止
  2. 常用的遍历对象的方式是for...in,还有Object.keys(), Object.values(), Object.entries()
  3. 其中遍历对象的时候for...in还可以把原型上的属性和使用Object.property()上定义的可以枚举的属性都遍历出来,但是Object.keys(), Object.values(), Object.entries()除了对象上的属性外,只能遍历出定义在对象上可以枚举的属性,原型上的属性是不能遍历出来的,因此我们在某些场景下如果只需要遍历出对象上的属性,那么就需要做判断

在遍历的时候需要注意什么坑

  1. forEach的遍历是不可终止的,要想使用break, continue, return来终止遍历就必须使用for...这类遍历方式
  2. break, continue, return用来终止遍历的区别
// break
for (let i = 0; i < arr.length; i++) {
  if (i === 3) {
    break;
  }
  console.log(i);
}
// 打印结果
// 0
// 1
// 2

// continue
for (let i = 0; i < arr.length; i++) {
  if (i === 3) {
    continue;
  }
  console.log(i);
}
// 打印结果
// 0
// 1
// 2
// 4

// return
for (let i = 0; i < arr.length; i++) {
  if (i === 3) {
    return;
  }
  console.log(i);
}
// 打印结果
// 0
// 1
// 2

小结:

  • break是退出当前循环,后面的循环都不再执行了
  • continue是退出当次循环,后面的循环会继续执行
  • return是退出当前循环,后面的循环都不再执行了
  • break和return的具体区别 对于单层循环来说,break和return的效果是一样的。如果使用return退出循环,需要将for循环放在函数中, 而使用break时,for循环放不放函数里面都可以。 对于多层循环来说,return是直接跳出函数,而break仅仅是跳出内层循环,外层循环会继续执行
    const a = [1, 2, 3];
    const b = [4, 5, 6, 7, 8];
    for (let i = 0; i < a.length; i++) {
      for (let j = 0; j < b.length; j++) {
        if (b[j] === 5) {
          return;
        } else {
          console.log(`j: ${j}`);
        }
      }
      console.log(`i: ${i}`);
    }
    console.log('最外层');
    // 执行结果:j: 0
    
    const a = [1, 2, 3];
    const b = [4, 5, 6, 7, 8];
    for (let i = 0; i < a.length; i++) {
      for (let j = 0; j < b.length; j++) {
        if (b[j] === 5) {
          break;
        } else {
          console.log(`j: ${j}`);
        }
      }
      console.log(`i: ${i}`);
    }
    console.log('最外层');
    // 执行结果
    // j: 0
    // i: 0
    // j: 0
    // i: 1
    // j: 0
    // i: 2
    // 最外层
    
  1. 以上常见的遍历方式只有for...in不仅能遍历自身,还能遍历原型上的属性或成员,对于对象来说,还能遍历对象上定义的可枚举属性
  2. 遍历对象的顺序 我们在遍历对象的时候经常会遇到一个问题:遍历出来的对象和我们遍历之前的对象顺序不一致,而且在不同的浏览器中表现会不一样,一般情况下遍历的顺序如下: 1)先按照正整数的规则遍历正整数部分 2)然后按照插入顺序遍历剩下的字符串 3)最后按照插入顺序遍历Symbol类型

递归

什么是递归

递归就是在函数里面调用本函数 我们在使用递归函数的时候特别函数的终止条件,否则会陷入死循环

递归的步骤

以下以计算n的阶乘为例

  1. 写好递归函数:根据内容找到参数, 那递归函数就可以写成fn(n){}
  2. 寻找递推关系:一般是根据需求,然后从后往前推,这里我们就找n的阶乘和n-1的阶乘的关系就是n的阶乘 = n-1的阶乘 * n
  3. 将递推关系的结构转换为递归体: 将递推关系转成函数表达式,这里就应该是fn(n) = fn(n-1) * n
  4. 将临界条件加入到递归体中:上面的递归体得出来之后,我们要考虑一下递归的临界条件了,如果没有临界条件,最后就会fn(0),fn(-1),fn(-2), fn(-3)....一直继续下去,这样就会陷入死循环报错: Maximum call stack size exceeded 所以,这里我们需要加上临界条件n=1

递归的应用

递归在平时的开发中还是经常会用到的,我们来看几个常见的例子

1. 求1,2,3,...n的和
init(n) {
  if (n === 1) {
    return 1;
  }
  return this.init(n - 1) + n;
}
console.log(this.init(10));  // 55
2. 求1,3,5,7,...第n项和前n项的和
// 求和
init(n) {
  if (n === 1) {
    return 1;
  }
  return this.init(n - 1) + 2 * n - 1;
}
console.log(this.init(6));  // 36

// 也可以写成下面这种
// 求第n项
foo(n) {
  if (n === 1) {
    return 1;
  }
  return foo(n - 1) + 2;
}
// 求n项和
sum(n) {
  if (n === 1) {
    return 1;
  }
  return sum(n - 1) + foo(n);
}
3. 求2,4,6,8,...第n项和前n项的和
// 前n项和
init(n) {
  if (n === 1) {
    return 2;
  }
  return this.init(n - 1) + 2 * n;
}
console.log(this.init(10));  // 110

// 也可以写成这样子
// 第n项
foo(n) {
  return 2 * n
}
或使用递归表示
foo(n) {
  if (n === 1) {
    return 0;
  }
  return foo(n - 1) + 2;
}
// 求n项和
sum(n) {
  if (n === 1) {
    return 2;
  }
  return sum(n - 1) + foo(n);
}
4. 斐波拉契数列(1,1,2,3,5,8,13,21,34,55,89...求第n项)
init(n) {
  if ( n === 1 || n === 2) {
    return 1;
  }
  // 判断条件也可以写成下面这样
  <!-- if ( n < 2 ) {
    return 0;
  }
  if ( n < 3 ) {
    return 1;
  } -->
  return this.init(n - 1) + this.init(n - 2);  
}
console.log(this.init(20));  // 6765
5. 求1,2,3,...n的阶乘
init(n) {
  if (n === 1) {
    return 1;
  }
  return this.init(n - 1) * n;
}
console.log(this.init(9));  // 362880
6. 求n的m幂次方
init(n, m) {
  if ( n === 1 ) {
    return 1;
  }
  if ( m === 1 ) {
    return n;
  }
  return this.init(n, m - 1) * n;  
}
console.log(this.init(2, 5));   // 32
7. 使用递归实现深拷贝对象
clone(obj) {
  const newObj = {};
  for (const key in obj) {
    if (typeof obj[key] === 'object') {
      newObj[key] = this.clone(obj[key]);
    } else {
      newObj[key] = obj[key];
    }
  }
  return newObj;
}

在使用递归的时候如何避坑

  1. 在使用遍历的时候,如果要返回值,要注意避免会出现在递归函数里面能获取到值,但是一返回值就没有。 案例说明: 一个长度不确定的树形结构数据,需要根据某个元素的id获取它的父元素
// 递归函数的定义
getParent(arr, id) {
  let res = {};
  for (let i = 0; i < arr.length; i++) {
    if (arr[i].id === id) {
      res = arr[i];
      console.log('res', res);  // 有内容的对象
      return res;
    } else {
      if (arr[i].children && arr[i].children.length > 0) {
        this.getParent(arr[i].children, id);
      }
    }
  }
  return res;  // 这里返回的值是{}
}

上面这种方式,执行完递归函数后返回值是空的

getParent(arr, id) {
  let res = {};
  for (let i = 0; i < arr.length; i++) {
    if (arr[i].id === id) {
      res = arr[i];
      console.log('res', res);  // 有内容的对象
      return res;
    } else {
      if (arr[i].children && arr[i].children.length > 0) {
        return this.getParent(arr[i].children, id);
      }
    }
  }
  return res;
},

如果在递归函数前面加上return,最后我们就能取到值了。

上述代码需要说明两个问题:

1) 递归函数最外层的return res还要不要?

其实最外层的return res要不要都无所谓了,因为如果for循环写在函数中,那么如果使用return退出循环,其实是直接退出函数的就不会继续执行后面的代码,这样的话其实写不写return res都无所谓,因为最后都不会执行到。这个在前面将遍历的时候有提到过。

2) 为什么要在递归函数加return才能获取到满足条件的值?

因为递归中的return常用来作为递归终止的条件,但是对于返回数值的情况,要搞明白它是怎么返回的。递归就是自己调用自己,如果有返回值的情况,上一层的函数还没执行完就会调用下一层函数。因此如果递归终止的话,首先返回的就是最底层调用的函数,然后再返回它的上一层数据,依次类推,直到所有的数据全部调用完,返回最终结果。 如果不在递归函数中加上return的话,那么只有最底层执行完的数据返回了结果,其他层的数据都没有返回,因此拿到的就是undefined.

  1. 在写递归函数的时候,一定要注意临界条件的处理,否则就会导致死循环。 这个问题应该很好理解,前面我们也提到了递归的步骤,其中判断临界条件是很重要的,如果没有临界条件,那么递归就会一直进行下去,直到内存炸了,浏览器提示Maximum call stack size exceeded,导致内存泄漏