博客 / 詳情

返回

如何實現精準的計時器

計時器

計時器在前端有很多應用場景,比如電商業務中秒殺和搶購活動的倒計時。在探討計時器之前先來回顧下它們的基本概念:

基本定義與用法

1、定義

setTimeout()用於指定在一定時間(單位毫秒)後執行某些代碼
setInterval()用於指定每隔一段時間(單位毫秒)執行某些代碼

2、參數

第一個參數 function,必填,回調函數。或者是一段字符串代碼,但是這種方式不建議使用,就和使用eval()一樣,有安全風險;而且還有作用域問題(字符串會在全局作用域內被解釋執行

// --run--
const fn1 = () => {
    console.log("執行fn1");
};
(function() {
    const fn2 = () => {
        console.log(222222)
    };
    setTimeout("fn1()", 1000)
})();
// 輸入:執行fn1
// --run--
const fn1 = () => {
    console.log("執行fn1");
};
(function() {
    const fn2 = () => {
        console.log(222222)
    };
    setTimeout("fn2()", 1000)
})();
// 沒有輸入,全局沒有fn2

第二個參數 delay,可選,單位是 ms,而不是要執行代碼的確切時間。JavaScript 是單線程的,所以每次只能執行一段代碼。為了調度不同代碼的執行,JavaScript 維護了一個任務隊列。其中的任務會按照添加到隊列的先後順序執行。setTimeout()的第二個參數只是告訴 JavaScript 引擎在指定的毫秒數過後把任務添加到這個隊列。如果隊列是空的,則會立即執行該代碼。如果隊列不是空的,則代碼必須等待前面的任務執行完才能執行。

第三個參數 param1,param2,param3...,可選,是傳遞給回調函數的參數

setTimeout(function (a, b) {
    console.log(a, b)
}, 2000, '我是', '定時器')

有一道循環定時器打印題,我們一起來看看

// --run--
for (var index = 0; index < 5; index++) {
    setTimeout(() => console.log(index), 1000);
}
// 因為var定義的index變量沒有塊級作用域的概念,所以每秒打印值都是5

var改成let,使每次定時器回調函數讀取自身父級作用域的index

// --run--
for (let index = 0; index < 5; index++) {
    setTimeout(() => console.log(index), 1000);
}

傳遞第三個參數給回調函數可以解決作用域問題

// --run--
for (var index = 0; index < 5; index++) {
    setTimeout((idx) => console.log(idx), 1000, index);
}

3、返回值

返回一個 ID(數字),可以將這個 ID 傳遞給clearTimeout()clearInterval()來取消執行。setTimeout()setInterval()共用一個編號池,技術上,clearTimeout()clearInterval()可以互換使用,但是為了避免混淆,一般不這麼做

setTimeout

setTimeoutsetInterval 實現原理一致,setTimeout(fn,0) 會將「事件」放入task queue的尾部,在下一次loop中,當「同步任務」與task queue中現有事件都執行完之後再執行。

setInterval

setInterval如果使用「固定步長」(間隔時間為定值),指的是向隊列添加新任務之前等待的時間。比如,調用 setInterval()的時間為 01:00:00,間隔時間為 3000 毫秒。這意味着 01:00:03 時,瀏覽器會把「任務」添加到「執行隊列」。瀏覽器不關心這個「任務」什麼時候執行或者執行要花多長時間。因此,到了 01:00:06,它會再向「隊列」中添加一個「任務」。由此可看出,執行時間短、非阻塞的回調函數比較適合 setInterval()

時間精度

setTimeoutsetInterval都存在「時間精度問題」,至少在4ms以上(4ms 一次 event loop,也即是最少4ms才檢查一次setTimeout的時間是否達到),根據瀏覽器、設備是否插電源等有所不同,最多能達到近16ms。為了解決這個問題,加快響應速度,產生了「setImmediate APIsetImmediate.js項目」與「requestAnimationFrame」,前者解決「觸發之後,立即調用回調函數,希望延遲儘可能短」的情況,後者可以實現「流暢的JS動畫」

// --run--
let last = 0;
let iterations = 10;

function timeout() {
  // 記錄調用時間
  logline(Date.now());
  // 如果還沒結束,計劃下次調用
  if (iterations-- > 0) {
    setTimeout(timeout, 0);
  }
}
function run() {
  // 初始化迭代次數和開始時間戳
  iterations = 10;
  last = Date.now();
  // 開啓計時器
  setTimeout(timeout, 0);
}

function logline(now) {
  // 輸出上一個時間戳、新的時間戳及差值
  console.log('之前:%d,現在:%d,實際延時:%d', last, now, now - last);
  last = now;
}
run();

img-20230511114208.png

// --run--
let last = 0;
let iterations = 10;
let itv = null;

function timeout() {
  // 記錄調用時間
  logline(Date.now());
  // 如果還沒結束,計劃下次調用
  if (iterations-- > 0) {
    clearInterval(itv);
  }
}
function run() {
  // 初始化迭代次數和開始時間戳
  iterations = 10;
  last = Date.now();
  // 開啓計時器
  itv = setInterval(timeout, 0);
}

function logline(now) {
  // 輸出上一個時間戳、新的時間戳及差值
  console.log('之前:%d,現在:%d,實際延時:%d', last, now, now - last);
  last = now;
}
run();

img-20230512113933.png

事件積壓

如果主線程(或執行棧)中的任務與task queue中的其它任務再加上setInterval中回調函數的總執行時間超過了「固定步長」(200ms),那麼setInterval的回調函數就會「延後執行」,長時間運行就會產生大量「積壓」在內存中待執行的函數,如果主線程終於空閒下來,那麼就會立刻執行「積壓」的大量函數,中間不會有任何停頓。例子如下:(補充:Date.now IE9以上支持,相對new Date()來説減少創建一次對象的時間和內存)

// 假設主線程代碼執行時長300ms,每個定時回調執行時長300ms,固定步長200ms
// --run--
const itv = setInterval(() => {
    const startTime = Date.now();
    while(Date.now() - startTime < 300) {}
}, 200);

mainThreadRun(); // 300ms時長

img-20230510173755.png

當主線程或者定時器回調函數執行時長越長,「事件積壓」就越嚴重。為了避免長時間運行產生大量「積壓」在內存中待執行的函數,產生性能損耗,現在瀏覽器會保證「當任務隊列中沒有定時器的任何其它代碼實例時,才將新的定時器添加到任務隊列」。

img-20230510174807.png

事件積壓解決方案

如果有些瀏覽器沒有做此優化,一定要使用setInterval的話,避免事件積壓的解決辦法有(摘自『javascript高級程序設計』):
1、間隔時間使用百分比: 開始值 + (目標值 - 開始值) * (Date.now() - 開始時間)/ 時間區間;

假設有這樣一個動畫功能需求:把一個div的寬度從100px變化到200px。寫出來的代碼可能是這樣的:

<div id="test1" style="width: 100px; height: 100px; background: blue; color: white;"></div>
function animate1(element, endValue, duration) {
    var startTime = new Date(),
        startValue = parseInt(element.style.width),
        step = 1;
    
    var timerId = setInterval(function() {
        var nextValue = parseInt(element.style.width) + step;
        element.style.width = nextValue + 'px';
        if (nextValue >= endValue) {
            clearInterval(timerId);
            // 顯示動畫耗時
            element.innerHTML = new Date - startTime;
        }
    }, duration / (endValue - startValue) * step);
}

animate1(document.getElementById('test1'), 200, 1000);

img-20230524180030.gif
原理是每隔一定時間(10ms)增加1px,一直到200px為止。然而,動畫結束後顯示的耗時卻不止1000ms,有1011ms。究其原因,是因為setInterval並不能嚴格保證執行間隔。

img-20230525192713.png

有沒有更好的做法呢?下面先來看一道小學數學題:

A樓和B樓相距100米,一個人勻速從A樓走到B樓,走了5分鐘到達目的地,問第3分鐘時他距離A樓多遠?

勻速運動中計算某個時刻路程的計算公式為:路程 * 當前時間 / 時間 。所以答案應為 100 * 3 / 5 = 60

這道題帶來的啓發是,某個時刻的路程是可以通過特定公式計算出來的。同理,動畫過程中某個時刻的值也可以通過公式計算出來,而不是累加得出:

<div id="test2" style="width: 100px; height: 100px; background: red; color: white;"></div>
function animate2(element, endValue, duration) {
    var startTime = new Date(),
        startValue = parseInt(element.style.width);

    var timerId = setInterval(function() {
        var percentage = (new Date - startTime) / duration;

        var stepValue = startValue + (endValue - startValue) * percentage;
        element.style.width = stepValue + 'px';

        if (percentage >= 1) {
            clearInterval(timerId);
            element.innerHTML = new Date - startTime;
        }
    }, 16.6);
}

animate2(document.getElementById('test2'), 200, 1000);

img-20230525093929.gif

這樣改良之後,可以看到動畫執行耗時最多隻會有幾毫秒的誤差。但是問題還沒完全解決,在瀏覽器開發工具中檢查test2元素可以發現,test2的最終寬度可能不止200px。仔細檢查animate2函數的代碼可以發現:

  • percentage的值可能大於1,可以通過Math.min限制最大值解決。
  • 即使保證了percentage的值不大於1,只要endValuestartValue為小數,(endValue - startValue) * percentage的值也可能產生誤差,因為JavaScript小數運算的精度不夠。其實我們要保證的只是最終值的準確性,所以在percentage為1的時候,直接使用endValue即可。

於是,animate2函數的代碼修改為:

function animate2(element, endValue, duration) {
    var startTime = new Date(),
        startValue = parseInt(element.style.width);

    var timerId = setInterval(function() {
        // 保證百分率不大於1
        var percentage = Math.min(1, (new Date - startTime) / duration);

        var stepValue;
        if (percentage >= 1) {
            // 保證最終值的準確性
            stepValue = endValue;
        } else {
            stepValue = startValue + (endValue - startValue) * percentage;
        }
        element.style.width = stepValue + 'px';

        if (percentage >= 1) {
            clearInterval(timerId);
            element.innerHTML = new Date - startTime;
        }
    }, 16.6);
}

2、如果你的代碼邏輯執行時間可能比定時器時間間隔要長,建議你使用遞歸調用了 setTimeout() 的具名函數。例如,使用 setInterval() 以 5 秒的間隔輪詢服務器,可能因網絡延遲、服務器無響應以及許多其他的問題而導致請求無法在分配的時間內完成。因此,你可能會發現排隊的 XHR 請求沒有按順序返回。

在這些場景下,應首選遞歸調用 setTimeout() 的模式:

(function loop(){
    setTimeout(function() {
        // Your logic here

        loop();
    }, delay);
})();

在上面的代碼片段中,聲明瞭一個具名函數 loop(),並被立即執行。loop() 在完成代碼邏輯的執行後,會在內部遞歸調用 setTimeout()。雖然該模式不保證以固定的時間間隔執行,但它保證了上一次定時任務在遞歸前已經完成。

setTimeout執行動畫

舉一個例子來思考下,憤怒的小鳥遊戲中,小鳥飛過屏幕時,用户應該在每次屏幕刷新時體驗到小鳥以相同的速度前進。假設顯示器刷新頻率60Hz16又2/3毫秒渲染一次),屏幕將在以下時間(以毫秒為單位)更新:0、16又2/333又1/35066又2/383又1/3100等。再假設定時器固定步長15ms,並(有些樂觀地)每幀處理javascript和渲染只需要0ms,那麼「setTimeout中設定的時間間隔」+「回調函數執行時間」+「在顯示器上繪製/改變動畫的下一幀的時間」等於15ms,每10(16 2/3) / ((16 2/3)- 15)=10』幀會多出一幀來,結果就是在第10幀的時候,有兩個回調動畫函數連續執行了,於是動畫不再平滑了…(詳見這篇囉嗦的文章),更不要説還要考慮setTimeout的「時間精度」問題(4ms 一次 event loop,也即是最少4ms才檢查一次setTimeout的時間是否達到)。

小鳥在屏幕上的X位置與rAF處理程序運行時所經過的時間成正比,因為它正在插入鳥的位置,並且rAF處理將在時間0、15、30、45、60等處運行。因此,我們可以確定每幀小鳥的視覺X位置:

第 0 幀,時間 0ms,位置:0,與上一幀的增量:
第 1 幀,時間 16又2/3 毫秒,位置:15,與最後一幀的增量:15
第 2 幀,時間 33又1/3 毫秒,位置:30,與最後一幀的增量:15
第 3 幀,時間 50又0/3 毫秒,位置:45,與最後一幀的增量:15
第 4 幀,時間 66又2/3 毫秒,位置:60,與最後一幀的增量:15
第 5 幀,時間 83又1/3 毫秒,位置:75,與最後一幀的增量:15
第 6 幀,時間 100又0/0 毫秒,位置:90,與最後一幀的增量:15
第 7 幀,時間 116又2/3 毫秒,位置:105,與最後一幀的增量:15
第 8 幀,時間 133又1/3 毫秒,位置:120,與最後一幀的增量:15
第 9 幀,時間 150又0/3 毫秒,位置:150,與最後一幀的增量:30
第 10 幀,時間 166又2/3 毫秒,位置:165,與最後一幀的增量:15
第 11 幀,時間 183又1/3 毫秒,位置:180,與最後一幀的增量:15
第 12 幀,時間 200又0/0 毫秒,位置:195,與最後一幀的增量:15

requestAnimationFrame

requestAnimationFrame 會把每一幀中的所有DOM操作集中起來,在「一次重繪或迴流中就完成」,並且「重繪或迴流的時間間隔緊緊跟隨瀏覽器的刷新頻率」,一般來説,這個頻率為每秒60

img-20230511164712.png

在隱藏或不可見的元素中,requestAnimationFrame將不會進行重繪或迴流,這當然就意味着更少的的cpugpu和內存使用量。

// --run--
var i = 0, _load = +new Date(), loop = 1000/60;
function f(){
    var _now = +new Date();
    console.log(i++, (_now-_load)/loop);
    _load = _now;
    requestAnimationFrame(f);
}

img-20230511171944.png

setTimeout相比,requestAnimationFrame不是自己指定回調函數運行的時間,而是跟着瀏覽器內建的刷新頻率來執行回調,這當然就能達到瀏覽器所能實現動畫的最佳效果了。

但另外一方面,requestAnimationFrame的預期執行時間要比setTimeout要長,因為setTimeout的最小執行時間是由「瀏覽器的時間精度」決定的,但raf會跟隨瀏覽器DOM的刷新頻率來執行,理論為16又2/3ms。但是,在setTimeout中如果進行了DOM操作(尤其是產生了重繪)通常不會立即執行,而是等待瀏覽器內建刷新時才執行。因此對於「動畫」來説的話,raf要遠遠比setTimeout適合得多。

rAFsetTimeout性能比較:(據某些人説,早期的raf性能堪憂,尤其是在手機上,反而不如setTimeout
MacBook Pro Chrome 112.0.5615.137(正式版本) (arm64):

  • setTimeout用時:30947ms
  • rAF用時:16624ms

並且細心觀察,可以發現rAF的動畫效果更加絲滑

setTimeout性能測試:

// --run--
var raf, i= 1, body = document.querySelector('body');
body.innerHTML = '<div id="sq" style="position:fixed;width:30px;height:30px;top:50px;left:50px;background:red;"></div>';
var sq = document.querySelector("#sq");
var pause = 10;//回調函數執行時間
var _load = +new Date();
var t = 1000/60;
function run1(){
    i++;
    sq.style.left = sq.offsetLeft + 1 + 'px';
    var start = Date.now();
    while(Date.now() - start < pause) {}
    if(i == 1000){
        console.log(Date.now() - _load);
    }
    raf = setTimeout(run1, t);
}
function stop(){
    clearTimeout(raf);
}
run1();

img-20230525104124.gif

rAF性能測試:

// --run--
var raf, i= 1, body = document.querySelector('body');
body.innerHTML = '<div id="sq" style="position:fixed;width:30px;height:30px;top:50px;left:50px;background:red;"></div>';
var sq = document.querySelector("#sq");
var pause = 10;//回調函數執行時間
var _load = +new Date();
function run(){
    i++;
    sq.style.left = sq.offsetLeft + 1 + 'px';
    var start = Date.now();
    while(Date.now() - start < pause) {}
    if(i == 1000){
        console.log(Date.now() - _load);
    }
    raf = requestAnimationFrame(run);
}
function stop(){
    cancelAnimationFrame(raf);
}
run();

img-20230525104218.gif

由於requestAnimationFrame的特性之一:會把每一幀中的所有DOM操作集中起來,在「一次重繪或迴流中就完成」,因此有github項目fastdom

後台最小超時延遲

為了優化後台標籤的加載損耗(以及降低耗電量),瀏覽器會在「非活動標籤」中強制執行一個「最小的超時延遲」。如果一個頁面正在使用網絡音頻 API AudioContext 播放聲音,也可以不執行該延遲。

這方面的具體情況與瀏覽器有關:

  • Firefox 桌面版和 Chrome 針對不活動標籤都有一個 「1 秒的最小超時值」。
  • 安卓版 Firefox 瀏覽器對不活動的標籤有一個至少 15 分鐘的超時,並可能完全卸載它們。
  • 如果標籤中包含 AudioContextFirefox 不會對非活動標籤進行節流。
// --run--
document.addEventListener("visibilitychange", () => {
    if (document.hidden) {
        console.log("tab切入後台~")
    } else {
        console.log("tab切入前台~")
    }
})
let last = Date.now();
const itv = setInterval(() => {
    const now = Date.now();
    console.log('diff:', now - last);
    last = now;
}, 500);

img-20230511110059.png

倒計時

通常電商業務都會有倒計時功能的秒殺和搶購活動,實現倒計時功能一般會從服務端獲取剩餘時間來計算,每走動一秒就刷新倒計時顯示。

使用 setInterval 實現計時

// --run--
const startTime = Date.now();
let count = 0;
const timer = setInterval(() => {
    count++;
    console.log("誤差:", Date.now() - (startTime + count * 1000) + "ms");
    if (count === 10) {
        clearInterval(timer);
    }
}, 1000)

new Date().getTime() - (startTime + count * 1000)理想情況下應該是 0ms,然而事實並不是這樣,而是存在着誤差:

img-20230424112953.png

使用 setTimeout 實現計時

// --run--
const startTime = Date.now();
let count = 0;
let timer = setTimeout(func, 1000);
function func() {
    count++;
    console.log("誤差:", Date.now() - (startTime + count * 1000) + "ms");
    if (count < 10) {
        clearTimeout(timer);
        timer = setTimeout(func, 1000);
    } else {
        clearTimeout(timer);
    }
}

setTimeout 也同樣存在着誤差,而且時間越來越大(setTimeout 需要在同步代碼執行完成後才重新開始計時):

img-20230424113440.png

為什麼會存在誤差?

這裏涉及到 JS 的代碼執行順序問題, JS 屬於單線程,代碼執行的時候首先是執行主線程的任務,也就是同步的代碼,如果遇到異步的代碼塊,並不會立即執行,而是丟進任務隊列中,任務隊列是先進先出,待主線程的代碼執行完畢以後,才會依次的執行任務隊列中的函數。所以,計時器函數實際執行時間一定大於指定的時間間隔。
img-20230525110021.png

因此,對於 setInterval 來説,每次將函數丟進任務隊列中,而每次函數的實際執行時間又都是大於指定的時間間隔的,一旦執行的次數多了,誤差就會越來越大。

如何得到一個比較準確的計時器?

1、使用while
簡單粗暴,我們可以直接用while語句阻塞主線程,不斷計算當前時間和下一次時間的差值。一旦大於等於0,則立即執行。

function intervalTimer(time) {
  let counter = 1;
  const startTime = Date.now();
  function main() {
    const nowTime = Date.now();
    const nextTime = startTime + counter * time;
    if (nowTime - nextTime >= 0) {
      console.log('deviation', nowTime - nextTime);
      counter += 1;
    }
  }
  while (true) {
    main();
  }
}
intervalTimer(1000);
// deviation 0
// deviation 0
// deviation 0
// deviation 0

我們可以看到差值穩定在0,但是這個方法阻塞了JS執行線程,導致JS執行線程無法停下來從隊列中取出任務。這會導致頁面凍結,無法響應任何操作。這是破壞性的,所以不可取。

2、使用requestAnimationFrame
瀏覽器提供了requestAnimationFrame API,它告訴瀏覽器你要執行一個動畫,要求瀏覽器在下次重繪前調用指定的回調函數來更新動畫。該回調函數將在瀏覽器下一次重繪之前執行。每秒執行的次數將根據屏幕的刷新率來確定。60Hz的刷新率意味着每秒會有60次,也就是16.6ms左右。

function intervalTimer(time) {
  let counter = 1;
  const startTime = Date.now();
  function main() {
    const nowTime = Date.now();
    const nextTime = startTime + counter * time;
    if (nowTime - nextTime >= 0) {
      console.log('deviation', nowTime - nextTime);
      counter += 1;
    }
    window.requestAnimationFrame(main);
  }
  main();
}
intervalTimer(1000);
// deviation 5
// deviation 7
// deviation 9
// deviation 12

我們可以發現,根據瀏覽器幀率執行計時,很容易造成時間不準確,因為幀率不會一直穩定在16.6ms。

3、使用setTimeout + 系統時間偏移量
該方案的原理是利用當前系統的準確時間,在每次之後進行補償校正,setTimeout保證後續的定時時間為補償後的時間,從而減小時間差。

img-20230525143605.png

// --run--
document.body.innerHTML = "<div id='countdown'></div>"
const interval = 1000;
const startTime = Date.now();
// 模擬服務器返回的剩餘時間
let time = 600000;
let count = 0;
let timeCounter;

function createTime(diff) {
    if (diff <= 0) {
        document.getElementById("countdown").innerHTML = `<span>00時00分00秒</span>`;
    } else {
        const hour = Math.floor(diff / (60 * 60 * 1000));
        const minute = Math.floor((diff - hour * 60 * 60 * 1000) / (60 * 1000));
        const second = Math.floor((diff - hour * 60 * 60 * 1000 - minute * 60 * 1000) / 1000);
        document.getElementById("countdown").innerHTML = `<span>${hour}時${minute >= 10 ? minute : `0${minute}`}分${second >= 10 ? second : `0${second}`}秒</span>`;
    }
}
function countDown() {
    count++;
    const gap = Date.now() - (startTime + count * interval);
    let nextTime = interval - gap;
    if (nextTime < 0) {
        nextTime = 0;
    }
    // time -= interval;
    const remainTime = time - (Date.now() - startTime);
    console.log(`誤差:${gap} ms,下一次執行:${nextTime} ms 後,離活動開始還有:${remainTime} ms`);
    createTime(remainTime);
    clearTimeout(timeCounter);
    timeCounter = setTimeout(countDown, nextTime);
}

createTime(time);
timeCounter = setTimeout(countDown, interval);
// 誤差:8 ms,下一次執行:992 ms 後,離活動開始還有:58992 ms
// 誤差:13 ms,下一次執行:987 ms 後,離活動開始還有:57987 ms
// 誤差:12 ms,下一次執行:988 ms 後,離活動開始還有:56988 ms
// 誤差:12 ms,下一次執行:988 ms 後,離活動開始還有:55988 ms
// tab切到後台
// 誤差:227 ms,下一次執行:773 ms 後,離活動開始還有:54773 ms
// 誤差:491 ms,下一次執行:509 ms 後,離活動開始還有:53509 ms
// 誤差:392 ms,下一次執行:608 ms 後,離活動開始還有:52607 ms
// 誤差:281 ms,下一次執行:719 ms 後,離活動開始還有:51719 ms
// 誤差:438 ms,下一次執行:562 ms 後,離活動開始還有:50562 ms
// tab切回前台
// 誤差:13 ms,下一次執行:987 ms 後,離活動開始還有:49987 ms
// 誤差:13 ms,下一次執行:987 ms 後,離活動開始還有:48987 ms
// 誤差:10 ms,下一次執行:990 ms 後,離活動開始還有:47990 ms
// ...

剩餘時間不能按照正常的間隔時間累減(time -= interval

  • 每次執行時間大於設置的間隔時間,存在誤差;
  • tab頁面切到後台,實際執行的間隔時間大於1000ms;

不管執行的間隔時間如何變化,只要準確計算出每次的剩餘時間(time - (Date.now() - startTime))就可以得到精準的倒計時。

可以看到每次會對誤差做時間補償,並且精準計算剩餘時間,幾乎是沒有誤差的

但是,還有一種特殊情況需要考慮,假如倒計時正在準確計時中,突然某刻有一個長任務(執行時間5000ms)進入隊列,當長任務進入調用棧執行時就會堵塞倒計時任務的執行,我們就會看到一個現象,計時停滯了(假設在01時36分55秒),待到長任務執行完後,計時任務才進入調用棧執行,會看到倒計時從01時36分55秒跳到01時36分50秒開始計時。

加上一個長任務:

const longTask = () => {
    const startTime = Date.now();
    while(Date.now() - startTime < 5000) {}
}
longTask();

img-20230525180713.gif

更快的異步執行

不是為了「動畫」,而是單純的希望最快速的執行異步回調:

使用異步函數:setTimeout、raf、setImmediate
1、setTimeout會有「時間精度問題」

// --run--
var now = function(){
    return performance ? performance.now() : +new Date();
};
var i = now();
setTimeout(function(){
    setTimeout(function(){
        console.log(now()-j);
    },0);
    var j = now();
    console.log(j-i);
},0);
// 0.3999999910593033
// 1.20000000298023224

2、rAF會跟隨瀏覽器內置重繪頁面的頻率,約60Hzchrome上測試:第一次時間多在1ms內,第二次調用時間大於10ms

// --run--
var now = function(){
    return performance ? performance.now() : +new Date();
};
var i = now();
requestAnimationFrame(function(){
    requestAnimationFrame(function(){
        console.log(now()-j);
    });
    var j = now();
    console.log(j-i);
});
// 0.5
// 13

3、setImmediate:僅IE10支持,尚未成為標準。但NodeJS已經支持並推薦使用此方法。另外,github上有setImmediate.js項目,用其它方法實現了setImmediate功能。

4、postMessage
onmessage:和iframe通信時常常會使用到onmessage方法,但是如果同一個window postMessage給自身,其實也相當於異步執行了一個function

// --run--
var doSth = function(){};
window.addEventListener("message", doSth, true);
window.postMessage("", "*");

5、另外,還可以利用script標籤,實現函數異步執行(把script添加到文檔也會執行onreadystatechange 但是該方法只能在IE下瀏覽器裏使用),例如:

var newScript = document.createElement("script");
var explorer = window.navigator.userAgent;
if (explorer.indexOf('MSIE') >= 0) {
    // ie
    script.onreadystatechange = doSth;
} else {
    // chrome
    script.onload = doSth;
}
document.documentElement.appendChild(newScript);

理論上,執行回調函數的等待時間排序:
setImmediate < readystatechange < onmessage < setTimeout 0 < requestAnimationFrame

另外,在「setImmediate.js項目」中説了它的實現策略,對上文進行一個有力的補充:

## The Tricks

### `process.nextTick`

In Node.js versions below 0.9, `setImmediate` is not available, but [`process.nextTick`][nextTick] is—and in those versions, `process.nextTick` uses macrotask semantics. So, we use it to shim support for a global `setImmediate`.

In Node.js 0.9 and above, `process.nextTick` moved to microtask semantics, but `setImmediate` was introduced with macrotask semantics, so there's no need to polyfill anything.

Note that we check for *actual* Node.js environments, not emulated ones like those produced by browserify or similar. Such emulated environments often already include a `process.nextTick` shim that's not as browser-compatible as setImmediate.js.

### `postMessage`

In Firefox 3+, Internet Explorer 9+, all modern WebKit browsers, and Opera 9.5+, [`postMessage`][postMessage] is available and provides a good way to queue tasks on the event loop. It's quite the abuse, using a cross-document messaging protocol within the same document simply to get access to the event loop task queue, but until there are native implementations, this is the best option.

Note that Internet Explorer 8 includes a synchronous version of `postMessage`. We detect this, or any other such synchronous implementation, and fall back to another trick.

### `MessageChannel`

Unfortunately, `postMessage` has completely different semantics inside web workers, and so cannot be used there. So we turn to [`MessageChannel`][MessageChannel], which has worse browser support, but does work inside a web worker.

### `<script> onreadystatechange`

For our last trick, we pull something out to make things fast in Internet Explorer versions 6 through 8: namely, creating a `<script>` element and firing our calls in its `onreadystatechange` event. This does execute in a future turn of the event loop, and is also faster than `setTimeout(…, 0)`, so hey, why not?

參考

JS中的事件循環與定時器
setTimeout 和 setInterval,你們兩位同學注意點時間~
JavaScript動畫實現原理
How to Get Accurate Countdown in JavaScript
setTimeout() 全局函數

user avatar huishou 頭像
1 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.