JavaScript DOM 编程艺术·setInterval与setTimeout的动画实现解析

时间:2021-11-16 10:08:39

先贴上moveElement()函数的大纲,为了方便观看,删了部分代码,完整版粘到文章后面。

function moveElement(elementID,final_x,final_y,interval) {
//测试JS兼容性代码
if (elem.movement) {
clearTimeout(elem.movement);
}
//计算并移动elementID位置
var repeat = "moveElement('"+elementID+"',"+final_x+","+final_y+","+interval+")";
elem.movement = setTimeout(repeat,interval);
}

第一反应

由于要实现的动画不是循环的动画(例如从上到下不停移动),

所以当时第一反应是直接用for循环,移动5个像素,然后用sleep之类的函数休眠5ms,然后再移动,直到移动到最终位置,循环结束,动画完成。

如果是这样,那么sleep函数有两种实现方式

  1. 阻塞式,CPU在sleep函数中停留等待一直到5ms过去
  2. 非阻塞式,调用sleep函数后,当前线程被挂起,JS引擎去处理其实事件(例如,此时我又点击了另一个按钮)

遗憾的是,第一种方法不可取,第二种方法不可实现。因为:

  1. 阻塞式中,如果CPU在SLEEP函数中停留等待,将阻塞其它事件的响应。如果整个动画时间为1S,那么在这1S内,如果用户点击了其它按钮,页面将不会对这个行为做出响应。
  2. JS引擎是一个运行在浏览器程序中的单线程引擎,自然没办法实现线程挂起。

事件驱动

但是很幸运(或者说是不幸)的是,JS引擎采用事件驱动,引擎在内部保存一个执行队列,JS引擎依次从队列取出事件,对事件中的JS代码进行解释执行。

例如有一按钮,

<button onclick="alert()"></button>。

当用户点击按钮后,一个onclick事件被插入到执行队列中,事件中的代码即是"alert()",JS引擎对"alert()"进行解释执行,弹出警示窗口。

此时JS引擎中的执行队列可能是:

0. 当前正在处理的事件,代码为"..."

  1. 正在等待处理的事件1,代码为"..."
  2. 正在等待处理的事件2,代码为"..."
  3. 正在等待处理的事件3,代码为"..."
  4. onclick事件,代码为"alert()"

另一条路

虽然JS引擎是单线程的,但是在浏览器中另有一个计时器线程用于计时。那么只要我们能够让计时器在5ms之后,把更新动画的事件代码插入到JS执行队列中去,那么我们就能实现非阻塞式的SLEEP功能:

  1. onclick事件代码:设置计时器计时5ms,代码为"move()"
  2. JS引擎处理其它事件
  3. 5ms时间到,计时器往执行队列中插入一个事件,其代码为"move()"
  4. JS引擎从队列中提出"move()",调用move()函数,更新动画
  5. 回到第1步,直到动画更新完成

setTimeout与setInterval

JS中实现对计时器进行控制的函数有两个,一个是setInterval,一个是setTimeout:

setTimeout(code, interval);
setInterval(code, interval);

两者都接收一段JS代码以及一段时间间隔为参数。区别在于:

  1. setTimeout函数在过了interval毫秒之后,把code代码放入JS执行队列中。
  2. setInterval函数则是每经过interval毫秒之后,周期性地把code代码放入JS执行队列中,直到用clearInterval()函数取消它。

实现动画

我们可以要实现一段动画,例如一张图片从左上角移到右下角,那么setTimeout和setInterval都可以实现。


setInterval实现
一般情况
  1. 首先,很显然用setInterval更直观更容易:

    onclick函数中:用setInterval设置计时器,每过5ms将move()函数放入JS执行队列中
  2. move函数中:移动图片,判断图片是否到达最终位置,如是,用clearInterval取消计时器,动画完成。

    这是一般情况下考虑的动画。
竞争情况

如果网页中有两个事件对图片进行移动,那么就会出现竞争的情况。JavaScript DOM编程艺术中的例子,即是这种情况。下面仿照这本书举个简单的例子:

<!-- 假设图片起始位置为(0,0) -->
<a id="a1" onmouseover="move(-150, 0)">a1</a>
<a id="a2" onmouseover="move(150, 0">a2</a>

如果我的鼠标分别从a1标签,a2标签中扫过。那么就可能会出现a1将图片往右拉,a2将图片往右拉,动画效果就会被破坏,此时JS引擎中的执行队列是这样的:

  1. a1:将图片往左拉5px
  2. ...其它事件...
  3. a2:将图片往右拉5px
  4. ...其它事件...
  5. a1:将图片再往左拉5px
  6. ...其它事件...
  7. ...

    在极端的情况下,还有可能因此陷入死循环。

解决的方法如《JavaScript DOM编程艺术》中给出的,我们需要一个变量作为指示,来保证同一时刻只有一个事件在拉动图片。这个变量必须是任何事件函数都能够访问到的,那么理所当然我们想到的就是给图片JavaScript DOM 编程艺术·setInterval与setTimeout的动画实现解析这个结点添加一个变量movement,使其等于setInterval()的返回值。每一次有事件被触发需要移动图片时,我们就对movement变量进行检查,如果为真,证明以前有其它事件函数,姑且称为A,尝试移动图片,那么我们就用clearInterval(elem.movement)将A函数设置的计时器取消掉,中止其动画过程,自己再另外设置计时器,开始图片新的动画。

现在动画机制代码更改如下:

1. a1,a2 onmouseover: setInterval("move()", 5ms)
// 1. a1标签和a2标签的onmouseover代码:用setInterval函数设置计时器,使其每过5ms就将move()函数放入JS执行队列中去。 2. move():if elem.movement then clearInterval(elem.movement)
// 2. move函数中:判断elem.movement是否为真,如为真,则clearInterval(elem.movement),清除elem.movement绑定的以前的动画过程。至此,elem.movement一定为假。 3. update position of elem
// 3. 计算并更新elem图片新的位置。 4. if not end of movement then setInterval("move()", 5ms) ;
// 判断是否到达最终位置,到达则clearInterval(elem.movement)取消计时器,返回。如果没有到达,由于经过第二步,计时器一定不存在,所以我们需要再调用setInterval()函数设置计时器,使其每过5ms就将move函数放入JS执行队列中去。

这样,a1标签触发的动画还没有完成就触发a2标签的动画的话,那么a2标签的动画(即a2标签引发的move()函数)就会自动取消a1标签的动画,开始其自己的动画。

至时,健壮的动画功能就完成了。但是《JavaScript DOM编程艺术》中使用的是setTimeout,为什么不使用setInterval?且先看看setTimeout是如何实现动画的。


setTimeout实现

####### 一般情况 ######

由前面可以知道,setInterval的优势在于它会周期性地自动将动画函数放入JS引擎执行队列中去,但是setTimeout完全可以通过一个小技巧来实现这个功能,那就是在每次动画函数结束时,再重新设置一次计时器函数:

1. onmouseover:setTimeout("move()", 5ms)
// onmouseover函数中:用setTimeout设置5ms后将move函数放入执行队列
2. move():update position of elem
// move函数:计算并更新动画
3. if not end of movement then setTimeout("move()", 5)
// 判断动画是否结束,如未结束,用setTimeout设置计时器5ms后将move函数再放入执行队列
竞争情况

跟setInterval的情况一样,为了防止两个元素竞争移动同一个图片(或其它元素),我们可以给图片添加一个变量来指示是否有其它元素尝试移动它。同样举之前的例子:


1. a1,a2:onmouseover():setTimeout("move()", 5)
// a1,a2的onmouseover调用的函数代码中:用setTimeout设置5ms后调用move函数 2. move():if elem.movement then clearTimeout(elem.movement)
// move函数中:判断elem.movement是否为真,为真,则用clearTimeout清除之前设置的计时器。至此,elem.movement一定为假。 3. update position of elem
// 计算并更新图片位置 4. if not end of movement then elem.movement = setTimeout("move()", 5)
// 判断动画是否完成,如未完成,用setTimeout设置5ms后调用move函数。

这个就是《JavaScript DOM 编程艺术》中使用的动画机制。跟setInterval的方法非常相似,但是在不同情况下,效果还是有可能不同的。现在我们来比较一下两者,看看我们为什么要倾向于使用setTimeout而不是setInterval。


setTimeout还是setInterval

setInterval的优点:

  1. setInterval相比setTimeout计时更加准确。
  2. 在实现一般动画时,由于能自动将动画函数插入执行队列,实现起来更方便直观,不用重复设置计时器。

    setInterval的缺点:
  3. 假设我们利用setInterval设置每5ms将move函数插入执行队列中,move函数由于计算量比较大,运行时间为6ms,计时器将move函数插入执行队列后,马上又开始计时,那么move函数还未结束,计时器将会把第二个move函数插入执行队列中,导致move函数阻塞了执行队列。此时如果move函数中没有显式取消其自身计时器的话,甚至可能会出现死循环。因此如果插入执行队列的函数计算量大的话(或者周期太小),就不适合选用setInterval。

setTimeout的优点:

  1. setTimeout实现动画不会阻塞执行队列。因为setTimeout本身就是一次性的,在实现动画时,我们在move函数的结尾处需要再设置一次计时器。因此无论setTimeout设置了多少毫秒,假设为5ms,那么在两次move函数之间都一定会间隔开至少5ms。

    setTimeout的缺点:
  2. 在实现一般动画时,需要在函数最后再设置一次计时器。

现在看过了setInterval跟setTimeout的优缺点之后,我们再来看看为什么在竞争情况下我们使用setTimeout而不是setInterval。

经过比较我们可以看到,setInterval相比setTimeout最大的优势是自动化、方便。但是我们看setInterval实现竞争情况下的动画时,move函数伪代码应该是这样的:

1. if elem.movement then clearInterval(elem.movement)
2. update position of elem
3. if not end of movement then elem.movement = setInterval()

由于在第一步中,我们无法确定elem.movement是其它元素设置的,还是它自己在上一步动画中设置,只要elem.movement为真,我们就把计时器清除了,然后在第3步中再设置一次计时器。

也就是说在竞争情况下setInterval使用方便的优势已经丧失了,无论使用setInterval还是setTimeout我们都必须在第三步中再设置一次计时器。而setTimeout此时还具有不阻塞执行队列的优势,毕竟大多数时候动画只是呈现效果,而不是功能或内容,我们不会想要因为一个动画而把整个页面给阻塞了。

最后附上原代码:

function moveElement(elementID,final_x,final_y,interval) {
if (!document.getElementById) return false;
if (!document.getElementById(elementID)) return false;
var elem = document.getElementById(elementID);
if (elem.movement) {
clearTimeout(elem.movement);
}
if (!elem.style.left) {
elem.style.left = "0px";
}
if (!elem.style.top) {
elem.style.top = "0px";
}
var xpos = parseInt(elem.style.left);
var ypos = parseInt(elem.style.top);
if (xpos == final_x && ypos == final_y) {
return true;
}
if (xpos < final_x) {
var dist = Math.ceil((final_x - xpos)/10);
xpos = xpos + dist;
}
if (xpos > final_x) {
var dist = Math.ceil((xpos - final_x)/10);
xpos = xpos - dist;
}
if (ypos < final_y) {
var dist = Math.ceil((final_y - ypos)/10);
ypos = ypos + dist;
}
if (ypos > final_y) {
var dist = Math.ceil((ypos - final_y)/10);
ypos = ypos - dist;
}
elem.style.left = xpos + "px";
elem.style.top = ypos + "px"; var repeat = "moveElement('"+elementID+"',"+final_x+","+final_y+","+interval+")";
elem.movement = setTimeout(repeat,interval);
}