如何實現(xiàn)比 setTimeout 快 80 倍的定時器?
很多人都知道,setTimeout 是有最小延遲時間的,根據(jù) MDN 文檔 setTimeout:實際延時比設(shè)定值更久的原因:最小延遲時間[1] 中所說:
在瀏覽器中,setTimeout()/setInterval() 的每調(diào)用一次定時器的最小間隔是 4ms,這通常是由于函數(shù)嵌套導(dǎo)致(嵌套層級達到一定深度)。
在 HTML Standard[2] 規(guī)范中也有提到更具體的:
Timers can be nested; after five such nested timers, however, the interval is forced to be at least four milliseconds.
簡單來說,5 層以上的定時器嵌套會導(dǎo)致至少 4ms 的延遲。
用如下代碼做個測試:
let a = performance.now();
setTimeout(() => {
let b = performance.now();
console.log(b - a);
setTimeout(() => {
let c = performance.now();
console.log(c - b);
setTimeout(() => {
let d = performance.now();
console.log(d - c);
setTimeout(() => {
let e = performance.now();
console.log(e - d);
setTimeout(() => {
let f = performance.now();
console.log(f - e);
setTimeout(() => {
let g = performance.now();
console.log(g - f);
}, 0);
}, 0);
}, 0);
}, 0);
}, 0);
}, 0);
在瀏覽器中的打印結(jié)果大概是這樣的,和規(guī)范一致,第五次執(zhí)行的時候延遲來到了 4ms 以上。

更詳細的原因,可以參考 為什么 setTimeout 有最小時延 4ms ?
探索
假設(shè)我們就需要一個「立刻執(zhí)行」的定時器呢?有什么辦法繞過這個 4ms 的延遲嗎,上面那篇 MDN 文檔的角落里有一些線索:
如果想在瀏覽器中實現(xiàn) 0ms 延時的定時器,你可以參考這里[3]所說的
window.postMessage()。
這篇文章里的作者給出了這樣一段代碼,用 postMessage 來實現(xiàn)真正 0 延遲的定時器:
(function () {
var timeouts = [];
var messageName = 'zero-timeout-message';
// 保持 setTimeout 的形態(tài),只接受單個函數(shù)的參數(shù),延遲始終為 0。
function setZeroTimeout(fn) {
timeouts.push(fn);
window.postMessage(messageName, '*');
}
function handleMessage(event) {
if (event.source == window && event.data == messageName) {
event.stopPropagation();
if (timeouts.length > 0) {
var fn = timeouts.shift();
fn();
}
}
}
window.addEventListener('message', handleMessage, true);
// 把 API 添加到 window 對象上
window.setZeroTimeout = setZeroTimeout;
})();
由于 postMessage 的回調(diào)函數(shù)的執(zhí)行時機和 setTimeout 類似,都屬于宏任務(wù),所以可以簡單利用 postMessage 和 addEventListener('message') 的消息通知組合,來實現(xiàn)模擬定時器的功能。
這樣,執(zhí)行時機類似,但是延遲更小的定時器就完成了。
再利用上面的嵌套定時器的例子來跑一下測試:

全部在 0.1 ~ 0.3 毫秒級別,而且不會隨著嵌套層數(shù)的增多而增加延遲。
測試
從理論上來說,由于 postMessage 的實現(xiàn)沒有被瀏覽器引擎限制速度,一定是比 setTimeout 要快的。但空口無憑,咱們用數(shù)據(jù)說話。
作者設(shè)計了一個實驗方法,就是分別用 postMessage 版定時器和傳統(tǒng)定時器做一個遞歸執(zhí)行計數(shù)函數(shù)的操作,看看同樣計數(shù)到 100 分別需要花多少時間。讀者也可以在這里自己跑一下測試[4]。
實驗代碼:
function runtest() {
var output = document.getElementById('output');
var outputText = document.createTextNode('');
output.appendChild(outputText);
function printOutput(line) {
outputText.data += line + '\n';
}
var i = 0;
var startTime = Date.now();
// 通過遞歸 setZeroTimeout 達到 100 計數(shù)
// 達到 100 后切換成 setTimeout 來實驗
function test1() {
if (++i == 100) {
var endTime = Date.now();
printOutput(
'100 iterations of setZeroTimeout took ' +
(endTime - startTime) +
' milliseconds.'
);
i = 0;
startTime = Date.now();
setTimeout(test2, 0);
} else {
setZeroTimeout(test1);
}
}
setZeroTimeout(test1);
// 通過遞歸 setTimeout 達到 100 計數(shù)
function test2() {
if (++i == 100) {
var endTime = Date.now();
printOutput(
'100 iterations of setTimeout(0) took ' +
(endTime - startTime) +
' milliseconds.'
);
} else {
setTimeout(test2, 0);
}
}
}
實驗代碼很簡單,先通過 setZeroTimeout 也就是 postMessage 版本來遞歸計數(shù)到 100,然后切換成 setTimeout 計數(shù)到 100。
直接放結(jié)論,這個差距不固定,在我的 mac 上用無痕模式排除插件等因素的干擾后,以計數(shù)到 100 為例,大概有 80 ~ 100 倍的時間差距。在我硬件更好的臺式機上,甚至能到 200 倍以上。

Performance 面板
只是看冷冰冰的數(shù)字還不夠過癮,我們打開 Performance 面板,看看更直觀的可視化界面中,postMessage 版的定時器和 setTimeout 版的定時器是如何分布的。

這張分布圖非常直觀的體現(xiàn)出了我們上面所說的所有現(xiàn)象,左邊的 postMessage 版本的定時器分布非常密集,大概在 5ms 以內(nèi)就執(zhí)行完了所有的計數(shù)任務(wù)。
而右邊的 setTimeout 版本相比較下分布的就很稀疏了,而且通過上方的時間軸可以看出,前四次的執(zhí)行間隔大概在 1ms 左右,到了第五次就拉開到 4ms 以上。
作用
也許有同學(xué)會問,有什么場景需要無延遲的定時器?其實在 React 的源碼中,做時間切片的部分就用到了。
借用 React Scheduler 為什么使用 MessageChannel 實現(xiàn)[5] 這篇文章中的一段偽代碼:
const channel = new MessageChannel();
const port = channel.port2;
// 每次 port.postMessage() 調(diào)用就會添加一個宏任務(wù)
// 該宏任務(wù)為調(diào)用 scheduler.scheduleTask 方法
channel.port1.onmessage = scheduler.scheduleTask;
const scheduler = {
scheduleTask() {
// 挑選一個任務(wù)并執(zhí)行
const task = pickTask();
const continuousTask = task();
// 如果當(dāng)前任務(wù)未完成,則在下個宏任務(wù)繼續(xù)執(zhí)行
if (continuousTask) {
port.postMessage(null);
}
},
};
React 把任務(wù)切分成很多片段,這樣就可以通過把任務(wù)交給 postMessage 的回調(diào)函數(shù),來讓瀏覽器主線程拿回控制權(quán),進行一些更優(yōu)先的渲染任務(wù)(比如用戶輸入)。
為什么不用執(zhí)行時機更靠前的微任務(wù)呢?參考我的這篇對 EventLoop 規(guī)范的解讀 深入解析 EventLoop 和瀏覽器渲染、幀動畫、空閑回調(diào)的關(guān)系,關(guān)鍵的原因在于微任務(wù)會在渲染之前執(zhí)行,這樣就算瀏覽器有緊急的渲染任務(wù),也得等微任務(wù)執(zhí)行完才能渲染。
總結(jié)
通過本文,你大概可以了解如下幾個知識點:
setTimeout的 4ms 延遲歷史原因,具體表現(xiàn)。如何通過 postMessage實現(xiàn)一個真正 0 延遲的定時器。postMessage定時器在 React 時間切片中的運用。為什么時間切片需要用宏任務(wù),而不是微任務(wù)。
參考資料
MDN 文檔 setTimeout:實際延時比設(shè)定值更久的原因:最小延遲時間: https://developer.mozilla.org/zh-CN/docs/Web/API/WindowOrWorkerGlobalScope/setTimeout#%E5%AE%9E%E9%99%85%E5%BB%B6%E6%97%B6%E6%AF%94%E8%AE%BE%E5%AE%9A%E5%80%BC%E6%9B%B4%E4%B9%85%E7%9A%84%E5%8E%9F%E5%9B%A0%EF%BC%9A%E6%9C%80%E5%B0%8F%E5%BB%B6%E8%BF%9F%E6%97%B6%E9%97%B4
[2]HTML Standard: https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timers
[3]這里: https://dbaron.org/log/20100309-faster-timeouts
[4]這里自己跑一下測試: https://dbaron.org/mozilla/zero-timeout
[5]React Scheduler 為什么使用 MessageChannel 實現(xiàn): https://juejin.cn/post/6953804914715803678
最后
如果你覺得這篇內(nèi)容對你挺有啟發(fā),我想邀請你幫我三個小忙:
點個「在看」,讓更多的人也能看到這篇內(nèi)容(喜歡不點在看,都是耍流氓 -_-)
歡迎加我微信「huab119」拉你進技術(shù)群,長期交流學(xué)習(xí)...
關(guān)注公眾號「前端勸退師」,持續(xù)為你推送精選好文,也可以加我為好友,隨時聊騷。

