Wenzi

在 JavaScript 中循环和定时输出一系列的内容

蚊子前端博客
发布于 2022/08/03 17:20
基于js的机制,如何定时循环输出一系列的内容?

我们在上一篇文章中聊了多种 Promise 的并发控制,于是就衍生出了另一个问题:定时输出一系列的内容,这里可能是数组中的数据,也可能是其他的。我们来看看有哪些实现方法。

既然是定时输出,必然涉及到 setTimeout 或者 setInterval 的运用。

一个很经典的错误案例:

// 使用var声明变量
for (var i = 0; i < 10; i++) {
  setTimeout(() => {
    console.log(i);
  }, 500 * i);
}

var声明的变量存在变量提升的问题,而且 for 循环是同步任务,当执行 setTimeout 时,for 循环已执行完毕,因此输出的全是 10。那有什么解决方案吗?

1. 闭包 #

在指定 setTimeout 时,外层包一个闭包,当 setTimeout 向外层寻找时,找到该闭包就停止了,而每个闭包中的环境是独立的。

for (var i = 0; i < 10; i++) {
  ((j) => {
    setTimeout(() => {
      console.log(j);
    }, 500 * j);
  })(i);
}

2. 使用 let 来声明变量 #

主要考虑 let 的块级作用域和 eventloop 事件循环机制。如:

// 使用let声明变量
for (let i = 0; i < 10; i++) {
  setTimeout(() => {
    console.log(i);
  }, 500 * i);
}

let声明的变量是块级作用域,setTimeout 向外层寻找到的变量 i 就是当时循环时的那个 i 的变量。

3. setTimeout 本身就可以传参 #

可能我们用 setTimeout 后续的参数比较少,其实 setTimeout() 函数,从第 3 个参数开始,都是传入到回调里的数据。如:

setTimeout(
  (username, age) => {
    console.log(`my name is ${username}, my age is ${age}`); // my name is jack, my age is 28
  },
  500, // 延迟时间
  'jack', // 从这里开始,都是参数,并且可以无限个
  28,
);

因此,我们可以把 for 循环改成:

for (var i = 0; i < 10; i++) {
  setTimeout(
    (j) => {
      console.log(j);
    },
    500 * i,
    i,
  );
}

4. bind #

我们可以 setTimeout 中的回调函数,通过 bind()方法再生成一个:

for (var i = 0; i < 10; i++) {
  setTimeout(
    ((j) => {
      console.log(j);
    }).bind(null, i),
    500 * i,
  );
}

里面拆开一下:

const fn = (j) => {
  console.log(j);
};
const callback = fn.bind(null, i);
setTimeout(callback, 500);

bind()本身就是用闭包来实现的。

5. Promise #

我们可以把 setTimeout 封装成一个 Promise,然后再在 for 循环里使用。

const sleep = (delay) => {
  return new Promise((resolve) => setTimeout(resolve, delay));
};

循环所在的函数改为async-await的结构:

const start = async () => {
  for (var i = 0; i < 10; i++) {
    // for-of也可以
    await sleep(500);
    console.log(i);
  }
};
start();

我们用了普通的 for 循环,其实for-of也是可以的。但forEach(), map()等方法就不可以了,如:

const start = async () => {
  // for (var i = 0; i < 10; i++) {
  //   // for-of也可以
  //   await sleep(500);
  //   console.log(i);
  // }
  const arr = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
  // 错误的方式,请不要使用
  arr.forEach(async (item) => {
    await sleep(500);
    console.log(item);
  });
};
start();

虽然输出了 0-9,但是是同时输出的,两个输出之间没有间隔。这是因为 forEach()中,每次循环的 callback 都是独立执行的,async 只控制其自己内部的 await,并不能控制其他的循环。

可以看下 V8 源码中的实现,Array.prototype.forEach()实际上调用的 V8 中的ArrayForEach()

ArrayForEach()的源码

从远吗中也能看到,这是对数组的每一项都调用了 callback。

上面的写法,我们拆分一下就好理解了:

const callback = async (item) => {
  await sleep(500);
  console.log(item);
};
arr.forEach(callback);

若我们自己来实现 forEach() 方法时:

Array.prototype.forEach = function (callback, thisArg) {
  const context = thisArg ?? null;
  const arr = this;

  for (let i = 0; i < arr.length; i++) {
    callback.call(context, arr[i], i, arr);
  }
};

顺带地,我们也就知道了为什么breakreturn等终止循环的语句在 forEach()中没有效果了。因为这些操作语句的作用范围仅是限制在回调函数 callback 的内部,并不会影响到外层的循环。

既然 Promise 和循环可以结合,那么 Promise 和递归也可以结合。这里我们就不拆解了。

6. 总结 #

我们用了多种方法来实现这样的功能,不同的方法接触到的知识点也不一样。

标签:setTimeout
阅读(1288)
Simple Empty
No data