1.setTimeout和setInterval
在講setTimeout和setInterval之前,先講一下異步執行的運行機制。(同步執行也是如此,因為它可以被視為沒有異步任務的異步執行。)
(1)所有同步任務都在主線程上執行,形成一個執行棧(execution context stack)。
(2)主線程之外,還存在一個"任務隊列"(task queue)。只要異步任務有了運行結果,就在"任務隊列"之中放置一個事件。
(3)一旦"執行棧"中的所有同步任務執行完畢,系統就會讀取"任務隊列",看看里面有哪些事件。那些對應的異步任務,於是結束等待狀態,進入執行棧,開始執行。
(4)主線程不斷重復上面的第三步。
setTimeout()
setTimeout()方法用來指定某個函數或字符串在指定的毫秒數之后執行。它返回一個整數,表示定時器的編號,這個值可以傳遞給clearTimeout()用於取消這個函數的執行
以下代碼中,控制台先輸出0,大概過1000ms即1s后,輸出定時器setTimeout()方法的返回值1
var Timer = setTimeout(function(){ console.log(Timer); },1000); console.log(0);
如果省略setTimeout的第二個參數,則該參數默認為0
clearTimeout()
setTimeout函數返回一個表示計數器編號的整數值,將該整數傳入clearTimeout函數,取消對應的定時器
//過100ms后,控制台輸出setTimeout()方法的返回值1 var Timer = setTimeout(function(){ console.log(Timer); },100);
於是可以利用這個值來取消對應的定時器
var Timer = setTimeout(function(){ console.log(Timer); },100); clearTimeout(Timer);
或者直接使用返回值作為參數
var Timer = setTimeout(function(){ console.log(Timer); },100); clearTimeout(1);
一般來說,setTimeout返回的整數值是連續的,也就是說,第二個setTimeout方法返回的整數值比第一個的整數值大1
//控制台輸出1、2、3 var Timer1 = setTimeout(function(){ console.log(Timer1); },100); var Timer2 = setTimeout(function(){ console.log(Timer2); },100); var Timer3 = setTimeout(function(){ console.log(Timer3); },100);
setInterval()
setInterval的用法與setTimeout完全一致,區別僅僅在於setInterval指定某個任務每隔一段時間就執行一次,也就是無限次的定時執行
<button id="btn">0</button> <script> var timer = setInterval(function(){ btn.innerHTML = Number(btn.innerHTML) + 1; },1000); btn.onclick = function(){ clearInterval(timer); btn.innerHTML = 0; } </script>
[注意]HTML5標准規定,setTimeout的最短時間間隔是4毫秒;setInterval的最短間隔時間是10毫秒,也就是說,小於10毫秒的時間間隔會被調整到10毫秒
大多數電腦顯示器的刷新頻率是60HZ,大概相當於每秒鍾重繪60次。因此,最平滑的動畫效的最佳循環間隔是1000ms/60,約等於16.6ms
運行機制
下面例子中,控制台會先輸出0,再輸出1。
setTimeout(function(){ console.log(1); }); console.log(0);
實際上,把setTimeout的第二個參數設置為0s,並不是立即執行函數的意思,只是把函數放入異步隊列。瀏覽器先執行完同步隊列里的任務,才會去執行異步隊列中的任務
在下面這個例子中,給一個按鈕btn設置了一個事件處理程序。事件處理程序設置了一個250ms后調用的定時器。點擊該按鈕后,首先將onclick事件處理程序加入隊列。該程序執行后才設置定時器,再有250ms后,指定的代碼才被添加到隊列中等待執行
btn.onclick = function(){ setTimeout(function(){ console.log(1); },250); }
如果上面代碼中的onclick事件處理程序執行了300ms,那么定時器的代碼至少要在定時器設置之后的300ms后才會被執行。隊列中所有的代碼都要等到javascript進程空閑之后才能執行,而不管它們是如何添加到隊列中的

如圖所示,盡管在255ms處添加了定時器代碼,但這時候還不能執行,因為onclick事件處理程序仍在運行。定時器代碼最早能執行的時機是在300ms處,即onclick事件處理程序結束之后
setInterval()的問題
setInterval定時器是等到回調執行完,才開始計時進行下次循環呢?還是只要一次計時完畢,插入回調之后不管回調執不執行就開始計時呢
答案是后者,定時器代碼可能在代碼再次被添加到隊列之前還沒有完成執行,結果導致定時器代碼連續運行好幾次,而之間沒有任何停頓。而javascript引擎對這個問題的解決是:當使用setInterval()時,僅當沒有該定時器的任何其他代碼實例時,才將定時器代碼添加到隊列中。這確保了定時器代碼加入到隊列中的最小時間間隔為指定間隔
但是,這樣會導致兩個問題:1、某些間隔被跳過;2、多個定時器的代碼執行之間的間隔可能比預期的小
假設,某個onclick事件處理程序使用setInterval()設置了200ms間隔的定時器。如果事件處理程序花了300ms多點時間完成,同時定時器代碼花了400ms的時間,就會同時出現跳過某間隔的情況

例子中的第一個定時器是在205ms處添加到隊列中的,但是直到過了300ms處才能執行。當執行這個定時器代碼時,在405ms處又給隊列添加了另一個副本。在下一個間隔,即605ms處,第一個定時器代碼仍在運行,同時在隊列中已經有了一個定時器代碼的實例。結果是,在這個時間點上的定時器代碼不會被添加到隊列中。
迭代setTimeout
為了避免setInterval()定時器的問題,可以使用鏈式setTimeout()調用
setTimeout(function fn(){ setTimeout(fn,interval); },interval);
這個模式鏈式調用了setTimeout(),每次函數執行的時候都會創建一個新的定時器。第二個setTimeout()調用當前執行的函數,並為其設置另外一個定時器。這樣做的好處是,在前一個定時器代碼執行完之前,不會向隊列插入新的定時器代碼,確保不會有任何缺失的間隔。而且,它可以保證在下一次定時器代碼執行之前,至少要等待指定的間隔,避免了連續的運行。
setTimeout(setInterval)來實現動畫
動畫原理
我們在瀏覽器中看到的圖像是在以每秒60次的頻率刷新的,由於刷新頻率很高,因此你感覺不到它在刷新。而動畫本質就是要讓人眼看到圖像被刷新而引起變化的視覺效果,這個變化要以連貫的、平滑的方式進行過渡。 那怎么樣才能做到這種效果呢?
刷新頻率為60Hz的屏幕每16.7ms刷新一次,我們在屏幕每次刷新前,將圖像的位置向左移動一個像素,即1px。這樣一來,屏幕每次刷出來的圖像位置都比前一個要差1px,因此你會看到圖像在移動;由於我們人眼的視覺停留效應,當前位置的圖像停留在大腦的印象還沒消失,緊接着圖像又被移到了下一個位置,因此你才會看到圖像在流暢的移動,這就是視覺效果上形成的動畫。
setTimeout(setInterval)的缺點
理解了上面的概念以后,我們不難發現,setTimeout 其實就是通過設置一個間隔時間來不斷的改變圖像的位置,從而達到動畫效果的。但我們會發現,利用seTimeout實現的動畫在某些低端機上會出現卡頓、抖動的現象。 這種現象的產生有兩個原因:
- setTimeout的執行時間並不是確定的。在Javascript中, setTimeout 任務被放進了異步隊列中,只有當主線程上的任務執行完以后,才會去檢查該隊列里的任務是否需要開始執行,因此 setTimeout 的實際執行時間一般要比其設定的時間晚一些。
- 刷新頻率受屏幕分辨率和屏幕尺寸的影響,因此不同設備的屏幕刷新頻率可能會不同,而 setTimeout只能設置一個固定的時間間隔,這個時間不一定和屏幕的刷新時間相同。
以上兩種情況都會導致setTimeout的執行步調和屏幕的刷新步調不一致,從而引起丟幀現象。 那為什么步調不一致就會引起丟幀呢?
首先要明白,setTimeout的執行只是在內存中對圖像屬性進行改變,這個變化必須要等到屏幕下次刷新時才會被更新到屏幕上。如果兩者的步調不一致,就可能會導致中間某一幀的操作被跨越過去,而直接更新下一幀的圖像。假設屏幕每隔16.7ms刷新一次,而setTimeout每隔10ms設置圖像向左移動1px, 就會出現如下繪制過程:
- 第0ms: 屏幕未刷新,等待中,setTimeout也未執行,等待中;
- 第10ms: 屏幕未刷新,等待中,setTimeout開始執行並設置圖像屬性left=1px;
- 第16.7ms: 屏幕開始刷新,屏幕上的圖像向左移動了1px, setTimeout 未執行,繼續等待中;
- 第20ms: 屏幕未刷新,等待中,setTimeout開始執行並設置left=2px;
- 第30ms: 屏幕未刷新,等待中,setTimeout開始執行並設置left=3px;
- 第33.4ms:屏幕開始刷新,屏幕上的圖像向左移動了3px, setTimeout未執行,繼續等待中;
- …
從上面的繪制過程中可以看出,屏幕沒有更新left=2px的那一幀畫面,圖像直接從1px的位置跳到了3px的的位置,這就是丟幀現象,這種現象就會引起動畫卡頓。
所以,使用window.setInterval()或者window.setTimeout()制作的動畫,有如下缺點:
1.他們都是通用的方法,並不是專為繪制動畫而用
2.即使向其專遞以毫秒為單位的參數值,它們也達不到毫秒級的准確性
3.沒有對調用動畫循環的機制優化
4.不考慮繪制動畫的最佳時機,而只會一味地以某個大致的時間間隔調用動畫循環。
4、requestAnimationFrame
什么是requestAnimationFrame?
MDN API文檔解釋如下:https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestAnimationFrame
簡單來說,requestAnimationFrame方法用於通知瀏覽器重采樣動畫。
當requestAnimationFrame(callback)被調用時不會執行callback,而是會將元組< handle,callback>插入到動畫幀請求回調函數列表末尾(其中元組的callback就是傳入requestAnimationFrame的回調函數),並且返回handle值,該值為瀏覽器定義的、大於0的整數,唯一標識了該回調函數在列表中位置。
每個回調函數都有一個布爾標識cancelled,該標識初始值為false,並且對外不可見。
瀏覽器在執行“采樣所有動畫”的任務時會遍歷動畫幀請求回調函數列表,判斷每個元組的callback的cancelled,如果為false,則執行callback。
cancelAnimationFrame 方法用於取消先前安排的一個動畫幀更新的請求。
當調用cancelAnimationFrame(handle)時,瀏覽器會設置該handle指向的回調函數的cancelled為true。
無論該回調函數是否在動畫幀請求回調函數列表中,它的cancelled都會被設置為true。
如果該handle沒有指向任何回調函數,則調用cancelAnimationFrame 不會發生任何事情。
當頁面可見並且動畫幀請求回調函數列表不為空時,瀏覽器會定期地加入一個“采樣所有動畫”的任務到UI線程的隊列中。
這個API的調用很簡單,如下所示:
var progress = 0; //回調函數 function render() { progress += 1; //修改圖像的位置 if (progress < 100) { //在動畫沒有結束前,遞歸渲染 window.requestAnimationFrame(render); } } //第一幀渲染 window.requestAnimationFrame(render);
function a(time) { console.log("animation"); id = window.requestAnimationFrame(a); } a(); window.cancelAnimationFrame(id);
與setTimeout相比,requestAnimationFrame最大的優勢是由系統來決定回調函數的執行時機。具體一點講,如果屏幕刷新率是60Hz,那么回調函數就每16.7ms被執行一次,如果刷新率是75Hz,那么這個時間間隔就變成了1000/75=13.3ms,換句話說就是,requestAnimationFrame的步伐跟着系統的刷新步伐走。它能保證回調函數在屏幕每一次的刷新間隔中只被執行一次,這樣就不會引起丟幀現象,也不會導致動畫出現卡頓的問題。
除此之外,requestAnimationFrame還有以下兩個優勢:
- CPU節能:使用setTimeout實現的動畫,當頁面被隱藏或最小化時,setTimeout 仍然在后台執行動畫任務,由於此時頁面處於不可見或不可用狀態,刷新動畫是沒有意義的,完全是浪費CPU資源。而requestAnimationFrame則完全不同,當頁面處理未激活的狀態下,該頁面的屏幕刷新任務也會被系統暫停,因此跟着系統步伐走的requestAnimationFrame也會停止渲染,當頁面被激活時,動畫就從上次停留的地方繼續執行,有效節省了CPU開銷。
- 函數節流:在高頻率事件(resize,scroll等)中,為了防止在一個刷新間隔內發生多次函數執行,使用requestAnimationFrame可保證每個刷新間隔內,函數只被執行一次,這樣既能保證流暢性,也能更好的節省函數執行的開銷。一個刷新間隔內函數執行多次時沒有意義的,因為顯示器每16.7ms刷新一次,多次繪制並不會在屏幕上體現出來。
5、優雅降級
瀏覽器兼容性
由於requestAnimationFrame目前還存在兼容性問題,而且不同的瀏覽器還需要帶不同的前綴。因此需要通過優雅降級的方式對requestAnimationFrame進行封裝,優先使用高級特性,然后再根據不同瀏覽器的情況進行回退,直止只能使用setTimeout的情況。
所以,如果想要簡單的兼容,可以這樣子:
window.requestAnimFrame = (function(){ return window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || function( callback ){ window.setTimeout(callback, 1000 / 60); }; })();
但是呢,並不是所有設備的繪制時間間隔是1000/60 ms
, 以及上面並木有cancel相關方法,所以,就有下面這份更全面的兼容方法:
(function() { var lastTime = 0; var vendors = ['webkit', 'moz']; for(var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { window.requestAnimationFrame = window[vendors[x] + 'RequestAnimationFrame']; window.cancelAnimationFrame = window[vendors[x] + 'CancelAnimationFrame'] || // Webkit中此取消方法的名字變了 window[vendors[x] + 'CancelRequestAnimationFrame']; } if (!window.requestAnimationFrame) { window.requestAnimationFrame = function(callback, element) { var currTime = new Date().getTime(); var timeToCall = Math.max(0, 16.7 - (currTime - lastTime)); var id = window.setTimeout(function() { callback(currTime + timeToCall); }, timeToCall); lastTime = currTime + timeToCall; return id; }; } if (!window.cancelAnimationFrame) { window.cancelAnimationFrame = function(id) { clearTimeout(id); }; } }());
簡單的應用
現在分別使用setInterval、setTimeout和requestAnimationFrame這三個方法制作一個簡單的進制度效果
【1】setInterval
<div id="myDiv" style="width: 0;height: 20px;line-height: 20px;">0%</div> <button id="btn">run</button> <script> var timer; btn.onclick = function(){ clearInterval(timer); myDiv.style.width = '0'; timer = setInterval(function(){ if(parseInt(myDiv.style.width) < 500){ myDiv.style.width = parseInt(myDiv.style.width) + 5 + 'px'; myDiv.innerHTML = parseInt(myDiv.style.width)/5 + '%'; }else{ clearInterval(timer); } },16); } </script>
【2】setTimeout
<div id="myDiv" style="width: 0;height: 20px;line-height: 20px;">0%</div> <button id="btn">run</button> <script> var timer; btn.onclick = function(){ clearTimeout(timer); myDiv.style.width = '0'; timer = setTimeout(function fn(){ if(parseInt(myDiv.style.width) < 500){ myDiv.style.width = parseInt(myDiv.style.width) + 5 + 'px'; myDiv.innerHTML = parseInt(myDiv.style.width)/5 + '%'; timer = setTimeout(fn,16); }else{ clearTimeout(timer); } },16); } </script>
【3】requestAnimationFrame
<div id="myDiv" style="width: 0;height: 20px;line-height: 20px;">0%</div> <button id="btn">run</button> <script> var timer; btn.onclick = function(){ myDiv.style.width = '0'; cancelAnimationFrame(timer); timer = requestAnimationFrame(function fn(){ if(parseInt(myDiv.style.width) < 500){ myDiv.style.width = parseInt(myDiv.style.width) + 5 + 'px'; myDiv.innerHTML = parseInt(myDiv.style.width)/5 + '%'; timer = requestAnimationFrame(fn); }else{ cancelAnimationFrame(timer); } }); } </script>
業務上的應用
掃碼體驗: