做者:sshhtml
不少人都知道,setTimeout
是有最小延遲時間的,根據MDN 文檔 setTimeout:實際延時比設定值更久的緣由:最小延遲時間中所說:瀏覽器
在瀏覽器中,setTimeout()/setInterval() 的每調用一次定時器的最小間隔是 4ms,這一般是因爲函數嵌套致使(嵌套層級達到必定深度)。markdown
在HTML Standard規範中也有提到更具體的:app
Timers can be nested; after five such nested timers, however, the interval is forced to be at least four milliseconds.ssh
簡單來講,5 層以上的定時器嵌套會致使至少 4ms 的延遲。函數
用以下代碼作個測試:oop
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);
複製代碼
在瀏覽器中的打印結果大概是這樣的,和規範一致,第五次執行的時候延遲來到了 4ms 以上。post
更詳細的緣由,能夠參考爲何 setTimeout 有最小時延 4ms ?測試
假設咱們就須要一個「馬上執行」的定時器呢?有什麼辦法繞過這個 4ms 的延遲嗎,上面那篇 MDN 文檔的角落裏有一些線索:動畫
若是想在瀏覽器中實現 0ms 延時的定時器,你能夠參考這裏所說的
window.postMessage()
。
這篇文章裏的做者給出了這樣一段代碼,用postMessage
來實現真正 0 延遲的定時器:
(function () {
var timeouts = [];
var messageName = 'zero-timeout-message';
// 保持 setTimeout 的形態,只接受單個函數的參數,延遲始終爲 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
的回調函數的執行時機和setTimeout
相似,都屬於宏任務,因此能夠簡單利用postMessage
和addEventListener('message')
的消息通知組合,來實現模擬定時器的功能。
這樣,執行時機相似,可是延遲更小的定時器就完成了。
再利用上面的嵌套定時器的例子來跑一下測試:
所有在 0.1 ~ 0.3 毫秒級別,並且不會隨着嵌套層數的增多而增長延遲。
從理論上來講,因爲postMessage
的實現沒有被瀏覽器引擎限制速度,必定是比 setTimeout 要快的。但空口無憑,我們用數聽說話。
做者設計了一個實驗方法,就是分別用postMessage
版定時器和傳統定時器作一個遞歸執行計數函數的操做,看看一樣計數到 100 分別須要花多少時間。讀者也能夠在這裏本身跑一下測試。
實驗代碼:
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 計數
// 達到 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 計數
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
版原本遞歸計數到 100,而後切換成 setTimeout 計數到 100。
直接放結論,這個差距不固定,在個人 mac 上用無痕模式排除插件等因素的干擾後,以計數到 100 爲例,大概有 80 ~ 100 倍的時間差距。在我硬件更好的臺式機上,甚至能到 200 倍以上。
只是看冷冰冰的數字還不夠過癮,咱們打開 Performance 面板,看看更直觀的可視化界面中,postMessage
版的定時器和setTimeout
版的定時器是如何分佈的。
這張分佈圖很是直觀的體現出了咱們上面所說的全部現象,左邊的postMessage
版本的定時器分佈很是密集,大概在 5ms 之內就執行完了全部的計數任務。
而右邊的setTimeout
版本相比較下分佈的就很稀疏了,並且經過上方的時間軸能夠看出,前四次的執行間隔大概在 1ms 左右,到了第五次就拉開到 4ms 以上。
也許有同窗會問,有什麼場景須要無延遲的定時器?其實在 React 的源碼中,作時間切片的部分就用到了。
借用React Scheduler 爲何使用 MessageChannel 實現這篇文章中的一段僞代碼:
const channel = new MessageChannel();
const port = channel.port2;
// 每次 port.postMessage() 調用就會添加一個宏任務
// 該宏任務爲調用 scheduler.scheduleTask 方法
channel.port1.onmessage = scheduler.scheduleTask;
const scheduler = {
scheduleTask() {
// 挑選一個任務並執行
const task = pickTask();
const continuousTask = task();
// 若是當前任務未完成,則在下個宏任務繼續執行
if (continuousTask) {
port.postMessage(null);
}
},
};
複製代碼
React 把任務切分紅不少片斷,這樣就能夠經過把任務交給postMessage
的回調函數,來讓瀏覽器主線程拿回控制權,進行一些更優先的渲染任務(好比用戶輸入)。
爲何不用執行時機更靠前的微任務呢?參考個人這篇對 EventLoop 規範的解讀深刻解析 EventLoop 和瀏覽器渲染、幀動畫、空閒回調的關係,關鍵的緣由在於微任務會在渲染以前執行,這樣就算瀏覽器有緊急的渲染任務,也得等微任務執行完才能渲染。
經過本文,你大概能夠了解以下幾個知識點:
setTimeout
的 4ms 延遲歷史緣由,具體表現。postMessage
實現一個真正 0 延遲的定時器。postMessage
定時器在 React 時間切片中的運用。