合作异步JavaScript: 超时和间隔

翻译正在进行中。

在这里,我们将讨论传统的JavaScript方法,这些方法可以在一段时间或一段规则间隔(例如,每秒固定的次数)之后,以异步方式运行代码,并讨论它们的用处,以及它们的固有问题。

预备条件: 基本的计算机知识,对JavaScript基本原理有较好的理解。
目标: 了解异步循环和间隔及其用途。

介绍

很长一段时间以来,web平台为JavaScript程序员提供了许多函数,这些函数允许您在一段时间间隔过后异步执行代码,或者重复异步执行代码块,直到您告诉它停止为止。这些都是:

setTimeout()
在指定的时间后执行一段代码.
setInterval()
以固定的时间间隔,重复运行一段代码.
requestAnimationFrame()
setInterval()的现代版本;在浏览器下一次重新绘制显示之前执行指定的代码块,从而允许动画在适当的帧率下运行,而不管它在什么环境中运行.

这些函数设置的异步代码实际上在主线程上运行,但是您可以在迭代之间运行其他代码,运行效率或高或低,这取决于这些操作的处理器密集程度。无论如何,这些函数用于在web站点或应用程序上运行常量动画和其他后台处理。在下面的部分中,我们将向您展示如何使用它们。

setTimeout()

在指定的时间后执行一段代码. 它需要如下参数:

  • 要运行的函数,或者函数引用。
  • 表示在执行代码之前等待的时间间隔(以毫秒为单位,所以1000等于1秒)的数字。如果指定值为0(或完全省略该值),函数将立即运行。稍后详述这样做的原因.
  • 更多的参数:在指定函数运行时,希望传递给函数的值.

Note: 因为超时回调是协同执行的,所以不能保证在指定的确切时间之后调用它们。相反,它们将在至少经过那么多时间之后被调用。超时处理程序在主线程到达执行点之前无法运行,在执行点上,它将浏览这些处理程序,选择需要运行的那个来运行。

在下面的示例中,浏览器将在执行匿名函数之前等待两秒钟,然后显示alert消息 (see it running live, and see the source code):

let myGreeting = setTimeout(function() {
  alert('Hello, Mr. Universe!');
}, 2000)

我们指定的函数不必是匿名的。我们可以给函数一个名称,甚至可以在其他地方定义它,并将函数引用传递给setTimeout()。以下两个版本的代码片段相当于第一个版本:

// With a named function
let myGreeting = setTimeout(function sayHi() {
  alert('Hello, Mr. Universe!');
}, 2000)

// With a function defined separately
function sayHi() {
  alert('Hello Mr. Universe!');
}

let myGreeting = setTimeout(sayHi, 2000);

例如,如果我们有一个函数既需要从超时调用,也需要响应某个事件,那么这将非常有用。此外它也可以帮助保持代码整洁,特别是当超时回调超过几行代码时。

setTimeout() 返回一个标志符变量用来引用这个间隔,可以稍后用来取消这个超时任务,下面就会学到 Clearing timeouts

传递参数给setTimeout() 

我们希望传递给setTimeout()中运行的函数的任何参数,都必须作为列表末尾的附加参数传递给它。例如,我们可以重构之前的函数,这样无论传递给它的人的名字是什么,它都会向它打招呼:

function sayHi(who) {
  alert('Hello ' + who + '!');
}

人名可以通过第三个参数传进 setTimeout()

let myGreeting = setTimeout(sayHi, 2000, 'Mr. Universe');

清除超时

最后,如果创建了超时,您可以通过调用clearTimeout(),将setTimeout()调用的标识符作为参数传递给它,从而在超时运行之前取消。要取消上面的超时,你需要这样做:

clearTimeout(myGreeting);

注意: 请参阅 greeter-app.html 以获得稍微复杂一点的演示,该演示允许您在表单中设置要打招呼的人的姓名,并使用单独的按钮取消问候语(see the source code also)。

setInterval()

当我们需要在一段时间之后运行一次代码时,setTimeout()可以很好地工作。但是当我们需要反复运行代码时会发生什么,例如在动画的情况下?

这就是setInterval()的作用所在。这与setTimeout()的工作方式非常相似,只是作为第一个参数传递给它的函数,重复执行的时间不少于第二个参数给出的毫秒数,而不是一次执行。您还可以将正在执行的函数所需的任何参数作为setInterval()调用的后续参数传递。

让我们看一个例子。下面的函数创建一个新的Date()对象,使用toLocaleTimeString()从中提取一个时间字符串,然后在UI中显示它。然后,我们使用setInterval()每秒运行该函数一次,创建一个每秒更新一次的数字时钟的效果。

(see this live, and also see the source):

function displayTime() {
   let date = new Date();
   let time = date.toLocaleTimeString();
   document.getElementById('demo').textContent = time;
}

const createClock = setInterval(displayTime, 1000);

setTimeout()一样, setInterval() 返回一个确定的值,稍后你可以用它来取消间隔任务。

清除intervals

setInterval()永远保持运行任务,除非我们做点什么——我们可能会想阻止这样的任务,否则当浏览器无法完成任何进一步的任务时我们可能得到错误, 或者动画处理已经完成了。我们可以用与停止超时相同的方法来实现这一点——通过将setInterval()调用返回的标识符传递给clearInterval()函数:

const myInterval = setInterval(myFunction, 2000);

clearInterval(myInterval);

主动学习:创建秒表!

话虽如此,我们还是要给你一个挑战。以我们的setInterval-clock.html为例,修改它以创建您自己的简单秒表。

你要像前面一样显示时间,但是在这里,你需要:

  • "Start" 按钮开始计时.
  • "Stop" 按钮停止计时
  • "Reset" 按钮清零.
  • 时间显示经过的秒数.

提示:

  • 您可以随心所欲地构造按钮标记;只需确保使用HTML语法,确保JavaScript获取按钮引用。
  • 创建一个变量,从0开始,每秒钟增加1.
  • 与我们版本中所做的一样,在不使用Date()对象的情况下创建这个示例更容易一些,但是不那么精确——您不能保证回调会在恰好1000ms之后触发。更准确的方法是运行startTime = Date.now()来获取用户单击start按钮的确切时间戳,然后执行Date.now() - startTime来获取单击start按钮后的毫秒数。
  • 您还希望将小时、分钟和秒作为单独的值计算,然后在每次循环迭代之后将它们一起显示在一个字符串中。从第二个计数器,你可以计算出他们.
  • 如何计时时分秒? 想一下:
    • 一小时3600秒.
    • 分钟应该是:时间减去小时后剩下的除以60.
    • 分钟都减掉后,剩下的就是秒钟了.
  • 如果这个数字小于10,最好在显示的值前面加一个0,这样看起来更加像那么回事。
  • 暂停的话,要清掉间隔函数。重置的话,显示要清零,要更新显示

Note: 如果您在操作过程有困难,请参考 see it runing live (see the source code also).

关于 setTimeout() 和 setInterval() 需要注意的几点

当使用 setTimeout()setInterval()的时候,有几点需要额外注意。 现在让我们回顾一下:

递归的timeouts

还有另一种方法可以使用setTimeout():我们可以递归调用它来重复运行相同的代码,而不是使用setInterval()

下面的示例使用递归setTimeout()每100毫秒运行传递来的函数:

let i = 1;

setTimeout(function run() {
  console.log(i);
  i++;
  setTimeout(run, 100);
}, 100);

将上面的示例与下面的示例进行比较 ––这使用setInterval()来实现相同的效果:

let i = 1;

setInterval(function run() {
  console.log(i);
  i++
}, 100);

递归setTimeout()和setInterval()有何不同?

上述代码的两个版本之间的差异是微妙的。

  • 递归 setTimeout() 保证执行之间的延迟相同,例如在上述情况下为100ms。 代码将运行,然后在它再次运行之前等待100ms,因此无论代码运行多长时间,间隔都是相同的。
  • 使用 setInterval() 的示例有些不同。 我们选择的间隔包括执行我们想要运行的代码所花费的时间。假设代码需要40毫秒才能运行 - 然后间隔最终只有60毫秒。
  • 当递归使用 setTimeout() 时,每次迭代都可以在运行下一次迭代之前计算不同的延迟。 换句话说,第二个参数的值可以指定在再次运行代码之前等待的不同时间(以毫秒为单位)。

当你的代码有可能比你分配的时间间隔,花费更长时间运行时,最好使用递归的 setTimeout() - 这将使执行之间的时间间隔保持不变,无论代码执行多长时间,你不会得到错误。

立即超时

使用0用作setTimeout()的回调函数会立刻执行,但是在主线程代码运行之后执行。

举个例子,下面的代码(see it live) 输出一个包含警报的"Hello",然后在您点击第一个警报的OK之后立即弹出“world”。

setTimeout(function() {
  alert('World');
}, 0);

alert('Hello');

如果您希望设置一个代码块以便在所有主线程完成运行后立即运行,这将很有用。将其放在异步事件循环中,这样它将随后直接运行。

使用 clearTimeout() or clearInterval()清除

clearTimeout()clearInterval() 都使用相同的条目列表进行清除。有趣的是,这意味着你可以使用任一一种方法来清除 setTimeout() 和 setInterval()。

但为了保持一致性,你应该使用 clearTimeout() 来清除 setTimeout() 条目,使用 clearInterval() 来清除 setInterval() 条目。 这样有助于避免混乱。

requestAnimationFrame()

requestAnimationFrame() 是一个专门的循环函数,旨在浏览器中高效运行动画。它基本上是现代版本的setInterval() —— 它在浏览器重新加载显示内容之前执行指定的代码块,从而允许动画以适当的帧速率运行,不管其运行的环境如何。

它是针对setInterval() 遇到的问题创建的,比如 setInterval()并不是针对设备优化的帧率运行,有时会丢帧。还有即使该选项卡不是活动的选项卡或动画滚出页面等问题 。

(在CreativeJS上了解有关此内容的更多信息).

注意: 你可以在课程中其他地方找到requestAnimationFrame() 的使用范例—参见 Drawing graphics, 和 Object building practice

该方法将重新加载页面之前要调用的回调函数作为参数。这是您将看到的常见表达:

function draw() {
   // Drawing code goes here
   requestAnimationFrame(draw);
}

draw();

这个想法是要定义一个函数,在其中更新动画 (例如,移动精灵,更新乐谱,刷新数据等),然后调用它来开始这个过程。在函数的末尾,以 requestAnimationFrame() 传递的函数作为参数进行调用,这指示浏览器在下一次显示重新绘制时再次调用该函数。然后这个操作连续运行, 因为requestAnimationFrame() 是递归调用的。

注意: 如果要执行某种简单的常规DOM动画, CSS 动画 可能更快,因为它们是由浏览器的内部代码计算而不是JavaScript直接计算的。但是,如果您正在做一些更复杂的事情,并且涉及到在DOM中不能直接访问的对象(such as 2D Canvas API or WebGL objects), requestAnimationFrame() 在大多数情况下是更好的选择。

你的动画跑得有多快?

动画的平滑度直接取决于动画的帧速率,并以每秒帧数(fps)为单位进行测量。这个数字越高,动画看起来就越平滑。

由于大多数屏幕的刷新率为60Hz,因此在使用web浏览器时,可以达到的最快帧速率是每秒60帧(FPS)。然而,更多的帧意味着更多的处理,这通常会导致卡顿和跳跃-也称为丢帧或跳帧。

如果您有一个刷新率为60Hz的显示器,并且希望达到60fps,则大约有16.7毫秒(1000/60)来执行动画代码来渲染每个帧。这提醒我们,我们需要注意每次通过动画循环时要运行的代码量。

requestAnimationFrame() 总是试图尽可能接近60帧/秒的值,当然有时这是不可能的如果你有一个非常复杂的动画,你是在一个缓慢的计算机上运行它,你的帧速率将更少。requestAnimationFrame() 会尽其所能利用现有资源提升帧速率。

 requestAnimationFrame() 与 setInterval() 和 setTimeout()有什么不同?

让我们进一步讨论一下 requestAnimationFrame() 方法与前面介绍的其他方法的区别. 下面让我们看一下代码:

function draw() {
   // Drawing code goes here
   requestAnimationFrame(draw);
}

draw();

现在让我们看看如何使用setInterval():

function draw() {
   // Drawing code goes here
}

setInterval(draw, 17);

如前所述,我们没有为requestAnimationFrame();指定时间间隔;它只是在当前条件下尽可能快速平稳地运行它。如果动画由于某些原因而处于屏幕外浏览器也不会浪费时间运行它。

 另一方面setInterval()需要指定间隔。我们通过公式1000毫秒/60Hz得出17的最终值,然后将其四舍五入。四舍五入是一个好主意,浏览器可能会尝试运行动画的速度超过60fps,它不会对动画的平滑度有任何影响。如前所述,60Hz是标准刷新率。

包括时间戳

传递给 requestAnimationFrame() 函数的实际回调也可以被赋予一个参数(一个时间戳值),表示自 requestAnimationFrame() 开始运行以来的时间。这是很有用的,因为它允许您在特定的时间以恒定的速度运行,而不管您的设备有多快或多慢。您将使用的一般模式如下所示:

let startTime = null;

function draw(timestamp) {
    if(!startTime) {
      startTime = timestamp;
    }

   currentTime = timestamp - startTime;

   // Do something based on current time

   requestAnimationFrame(draw);
}

draw();

浏览器支持

 与setInterval()setTimeout() 相比最近的浏览器支持requestAnimationFrame()

requestAnimationFrame().在Internet Explorer 10及更高版本中可用。因此,除非您的代码需要支持旧版本的IE,否则没有什么理由不使用requestAnimationFrame().

一个简单的例子

学习上述理论已经足够了,下面让我们仔细研究并构建自己的requestAnimationFrame() 示例。我们将创建一个简单的“微调器动画”(spinner animation),即当应用程序忙于连接到服务器时可能会显示的那种动画。

注意: 一般来说,像以下例子中如此简单的动画应用CSS动画来实现,这里使用requestAnimationFrame()只是为了帮助解释其用法。requestAnimationFrame()正常应用于如逐帧更新游戏画面这样的复杂动画。

  1. 首先, 下载我们的网页模板

  2. 放置一个空的 <div> 元素进入 <body>, 然后在其中加入一个 ↻ 字符.这是一个将循环的字符将在我们的例子中作为我们的微调器(spinner)。

  3. 用任何你喜欢的方法应用下述的CSS到HTML模板中。这些在页面上设置了一个红色背景,将<body>的高度设置为100%<html>的高度,并将<div>水平和竖直居中。

    html {
      background-color: white;
      height: 100%;
    }
    
    body {
      height: inherit;
      background-color: red;
      margin: 0;
      display: flex;
      justify-content: center;
      align-items: center;
    }
    
    div {
      display: inline-block;
      font-size: 10rem;
    }
  4. 插入一个 <script>元素在 </body> 标签之上。

  5. 插入下述的JavaScript在你的 <script> 元素中。这里我们存储了一个<div>的引用在一个常量中,设置rotateCount变量为 0, 设置一个未初始化的变量之后将会用作容纳一个requestAnimationFrame() 的调用, 然后设置一个 startTime 变量为 null,它之后将会用作存储 requestAnimationFrame() 的起始时间.。

    const spinner = document.querySelector('div');
    let rotateCount = 0;
    let rAF;
    let startTime = null;
  6. 在之前的代码下面, 插入一个 draw() 函数将被用作容纳我们的动画代码,并且包含了 时间戳 参数。

    function draw(timestamp) {
    
    }
  7. draw()中, 加入下述的几行。 这里我们定义了起始时间如果这个变量还没有被定义的话(这只将发生在循环中的第一步), 然后旋转这个微调器(spinning)字符and rotate the spinner character by an increasing amount with each iteration (the current timestamp, take the starting timestamp, divided by three so it doesn't go too fast):

      if (!startTime) {
       startTime = timestamp;
      }
    
      let rotateCount = (timestamp - startTime) / 3;
      spinner.style.transform = 'rotate(' + rotateCount + 'deg)';
  8. Below the previous line inside draw(), add the following block — this checks to see if the value of rotateCount is above 359 (e.g. 360, a full circle). If so, removes 360 from the value so the circle animation can continue uninterrupted, at a sensible, low value. Note that this isn't strictly necessary, but it is easier to work with values of 0-359 degrees than values like "128000 degrees".

    if (rotateCount > 359) {
      rotateCount -= 360;
    }
  9. At the very bottom inside the draw() function, insert the following line. This is the key to the whole operation — we are setting the variable we defined earlier to an active requestAnimation() call that takes the draw() function as its parameter. This starts the animation off, constantly running the draw() function at a rate of as close to 60 FPS as possible.

    rAF = requestAnimationFrame(draw);

Note: You can find this example live on GitHub (see the source code also).

Clearing a requestAnimationFrame() call

Clearing a requestAnimationFrame() call can be done by calling the corresponding cancelAnimationFrame() method (note, "cancel" not "clear" as with the "set..." methods), passing it the value returned by the requestAnimationFrame() call to cancel, which we stored in a variable called rAF:

cancelAnimationFrame(rAF);

Active learning: Starting and stopping our spinner

In this exercise, we'd like you to test out the cancelAnimationFrame() method by taking our previous example and updating it, adding an event listener to start and stop the spinner when the mouse is clicked anywhere on the page.

Some hints:

  • A click event handler can be added to most elements, including the document <body>. It makes more sense to put it on the <body> element if you want to maximize the clickable area — the event bubbles up to its child elements.
  • You'll want to add a tracking variable to check whether the spinner is spinning or not, clearing the animation frame if it is, and calling it again if it isn't.

Note: Try this yourself first; if you get really stuck, check out of our live example and source code.

Throttling a requestAnimationFrame() animation

One limitation of requestAnimationFrame() is that you can't choose your frame rate. This isn't a problem most of the time, as generally you want your animation to run as smoothly as possible, but what about when you want to create an old school, 8-bit-style animation?

This was a problem for example in the Monkey Island-inspired walking animation from our Drawing Graphics article:

In this example we have to animate both the position of the character on the screen, and the sprite being shown. There are only 6 frames in the sprite's animation; if we showed a different sprite frame for every frame displayed on the screen by requestAnimationFrame(), Guybrush would move his limbs too fast and the animation would look ridiculous. We therefore throttled the rate at which the sprite cycles its frames using the following code:

if (posX % 13 === 0) {
  if (sprite === 5) {
    sprite = 0;
  } else {
    sprite++;
  }
}

So we are only cycling a sprite once every 13 animation frames. OK, so it's actually about every 6.5 frames, as we update posX (character's position on the screen) by two each frame:

if(posX > width/2) {
  newStartPos = -((width/2) + 102);
  posX = Math.ceil(newStartPos / 13) * 13;
  console.log(posX);
} else {
  posX += 2;
}

This is the code that works out how to update the position in each animation frame.

The method you use to throttle your animation will depend on your particular code. For example, in our spinner example we could make it appear to move slower by only increasing our rotateCount by one on each frame instead of two.

Active learning: a reaction game

For our final section of this article, we'll create a 2-player reaction game. Here we have two players, one of whom controls the game using the A key, and the other with the L key.

When the Start button is pressed, a spinner like the one we saw earlier is displayed for a random amount of time between 5 and 10 seconds. After that time, a message will appear saying "PLAYERS GO!!" — once this happens, the first player to press their control button will win the game.

Let's work through this.

  1. First of all, download the starter file for the app — this contains the finished HTML structure and CSS styling, giving us a game board that shows the two players' information (as seen above), but with the spinner and results paragraph displayed on top of one another. We just have to write the JavaScript code.

  2. Inside the empty <script> element on your page, start by adding the following lines of code that define some constants and variables we'll need in the rest of the code:

    const spinner = document.querySelector('.spinner p');
    const spinnerContainer = document.querySelector('.spinner');
    let rotateCount = 0;
    let startTime = null;
    let rAF;
    const btn = document.querySelector('button');
    const result = document.querySelector('.result');

    In order, these are:

    1. A reference to our spinner, so we can animate it.
    2. A reference to the <div> element that contains the spinner, used for showing and hiding it.
    3. A rotate count — how much we want to show the spinner rotated on each frame of the animation.
    4. A null start time — will be populated with a start time when the spinner starts spinning.
    5. An uninitialized variable to later store the requestAnimationFrame() call that animates the spinner.
    6. A reference to the Start button.
    7. A reference to the results paragraph.
  3. Next, below the previous lines of code, add the following function. This simply takes two numerical inputs and returns a random number between the two. We'll need this to generate a random timeout interval later on.

    function random(min,max) {
      var num = Math.floor(Math.random()*(max-min)) + min;
      return num;
    }
  4. Next add in the draw() function, which animates the spinner. This is exactly the same as the version seen in the simple spinner example we looked at earlier:

      function draw(timestamp) {
        if(!startTime) {
         startTime = timestamp;
        }
    
        let rotateCount = (timestamp - startTime) / 3;
        spinner.style.transform = 'rotate(' + rotateCount + 'deg)';
    
        if(rotateCount > 359) {
          rotateCount -= 360;
        }
    
        rAF = requestAnimationFrame(draw);
      }
  5. Now it is time to set up the initial state of the app when the page first loads. Add the following two lines, which simply hide the results paragraph and spinner container using display: none;.

    result.style.display = 'none';
    spinnerContainer.style.display = 'none';
  6. We'll also define a reset() function, which sets the app back to the original state required to start the game again after it has been played. Add the following at the bottom of your code:

    function reset() {
      btn.style.display = 'block';
      result.textContent = '';
      result.style.display = 'none';
    }
  7. OK, enough preparation.  Let's make the game playable! Add the following block to your code. The start() function calls draw() to start the spinner spinning and display it in the UI, hides the Start button so we can't mess up the game by starting it multiple times concurrently, and runs a setTimeout() call that runs a setEndgame() function after a random interval between 5 and 10 seconds has passed. We also add an event listener to our button to run the start() function when it is clicked.

    btn.addEventListener('click', start);
    
    function start() {
      draw();
      spinnerContainer.style.display = 'block';
      btn.style.display = 'none';
      setTimeout(setEndgame, random(5000,10000));
    }

    Note: You'll see that in this example we are calling setTimeout() without storing the return value (so not let myTimeout = setTimeout(functionName, interval)). This works and is fine, as long as you don't need to clear your interval/timeout at any point. If you do, you'll need to save the returned identifier.

    The net result of the previous code is that when the Start button is pressed, the spinner is shown and the players are made to wait a random amount of time before they are then asked to press their button. This last part is handled by the setEndgame() function, which we should define next.

  8. So add the following function to your code next:

    function setEndgame() {
      cancelAnimationFrame(rAF);
      spinnerContainer.style.display = 'none';
      result.style.display = 'block';
      result.textContent = 'PLAYERS GO!!';
    
      document.addEventListener('keydown', keyHandler);
    
      function keyHandler(e) {
        console.log(e.key);
        if(e.key === 'a') {
          result.textContent = 'Player 1 won!!';
        } else if(e.key === 'l') {
          result.textContent = 'Player 2 won!!';
        }
    
        document.removeEventListener('keydown', keyHandler);
        setTimeout(reset, 5000);
      };
    }

    Stepping through this:

    1. First we cancel the spinner animation with cancelAnimationFrame() (it is always good to clean up unneeded processes), and hide the spinner container.
    2. Next we display the results paragraph and set its text content to "PLAYERS GO!!" to signal to the players that they can now press their button to win.
    3. We then attach a keydown event listener to our document — when any button is pressed down, the keyHandler() function is run.
    4. Inside keyHandler(), we include the event object as a parameter (represented by e) — its key property contains the key that was just pressed, and we can use this to respond to specific key presses with specific actions.
    5. We first log e.key to the console, which is a useful way of finding out the key value of different keys you are pressing.
    6. When e.key is "a", we display a message to say that Player 1 won, and when e.key is "l", we display a message to say Player 2 won. Note that this will only work with lowercase a and l — if an uppercase A or L is submitted (the key plus Shift), it is counted as a different key.
    7. Regardless of which one of the player control keys was pressed, we remove the keydown event listener using removeEventListener() so that once the winning press has happened, no more keyboard input is possible to mess up the final game result. We also use setTimeout() to call reset() after 5 seconds — as we explained earlier, this function resets the game back to its original state so that a new game can be started.

That's it, you're all done.

Note: If you get stuck, check out our version of the reaction game (see the source code also).

Conclusion

So that's it — all the essentials of async loops and intervals covered in one article. You'll find these methods useful in a lot of situations, but take care not to overuse them — since these still run on the main thread, heavy and intensive callbacks (especially those that manipulate the DOM) can really slow down a page if you're not careful.

In this module