從簡單中窺見高端,徹底搞懂任務可中斷機制與任務插隊機制
今天我就用最基礎的方式重新跟大家分享一下什么是任務可中斷。
一、任務拆分
首先,我們要明確的一個前提,是一個完整的函數執行是不可以中斷的。因此如果你把一整個任務全部都放到一個函數中來執行,那么想要做到任務可中斷是不可能的。
例如,現在我有一個任務,往父級元素中插入 10 萬個子節點 <span>1</span>,然后我們可以隨便寫這樣一個函數來完成這個邏輯。
btn.onclick = () => {
let i = 0
for (; i < 100000; i++) {
let span = document.createElement('span')
span.innerText = 1
container.appendChild(span)
}
}
然后這個時候,我們就發現一個問題,當我們點擊之后,頁面上并不會立即顯示插入的內容,而是會卡頓一會兒,才會顯示。
原因是因為 10 萬個節點的插入邏輯是一個同步的過程,JS 邏輯的執行時間過長導致了瀏覽器遲遲無法執行渲染。
那么為了優化這種情況,我們可以考慮將渲染 10 萬個節點這個大的任務,拆分成 10 萬個渲染 1 個節點的小任務。
function task() {
let span = document.createElement('span')
span.innerText = 1
container.appendChild(span)
}
并將這 10 萬個任務,放進一個數組中。
const taskQueue = Array.from({ length: 100000 }, () => task)
執行這 100 萬個任務,就通過遍歷 taskQueue 的方式來執行,這樣,我們就可以通過中斷隊列遍歷的方式,來中斷任務的執行。
二、需要中斷的原因
在瀏覽器中,渲染引擎在每一幀都有機會渲染頁面,那么頁面的表現就不會卡頓。但是剛才我們的情況是,JS 執行時間過長,導致渲染引擎一直沒有機會渲染,所以用戶感受到的就是卡頓。
那么解決這個問題的原理,就是根據瀏覽器渲染頻率,對 JS 要執行的任務進行拆分,JS 執行一部分,然后渲染引擎渲染一部分,完成之后,JS 再繼續執行,渲染引擎再渲染。
通過這樣間隔執行的方式,讓用戶感知不到卡頓的存在。
三、中斷的判斷條件
如果你的顯示器是 60 Hz,那么瀏覽器一幀的渲染間隔時間大約就是 16.7ms
,因此,我們可以利用瀏覽器渲染任務完成之后的空余時間來執行被拆分的 JS 任務,瀏覽器給我們提供了一個鉤子函數 requestIdleCallback 在空余時間執行我們想要的邏輯。
需要注意的是,許多朋友對 1ms 沒什么概念,對于計算機來說,16.7ms 時間其實非常的長,簡單的函數 1 ms 可以執行非常多次。
例如,如果只是簡單的遞增。
var k = 0
let startTime = performance.now()
while (performance.now() - startTime <= 1) {
// console.log('xx')
k += 1
}
console.log(k)
在我的電腦上,1ms k 值最高可以遞增到 6500+,如果我要執行 console.log 函數,最高可以執行 100+ 次。
我們來學習一下 requestIdleCallback 的語法。
requestIdleCallback(callback[, options])
callback 是需要執行的任務,接收一個 IdleDeadline 對象作為參數。IdleDeadline 包含 2 個重要字段。
- didTimeout,布爾值,表示任務是否超時。
- timeRemaining() ,用于獲取當前幀的剩余時間。
options 是一個可選參數,目前只有一個值 timeout,表示如果超過這個時間,任務還沒有執行,則強制執行任務,不需要等待空閑時間。
因此當我們通過上面的 deadline 發現沒有剩余時間執行更多的任務了,那我們就中斷遍歷過程。
四、代碼實現
實現起來非常簡單,我們用 while 循環來遍歷 queueTask,然后根據 deadline 的情況來中斷遍歷過程,代碼如下:
btn.onclick = () => {
btn.innerText = '已點擊,插入中'
requestIdleCallback((deadline) => {
let task;
while ((task = taskQueue.pop()) && !deadline.didTimeout && deadline.timeRemaining() > 0) {
task()
}
})
}
此時因為沒有加入遞歸邏輯去連續觸發 requestIdleCallback,但是我們可以通過連續點擊的方式查看執行效果。
然后我們加入遞歸邏輯讓他們自動把剩余的任務全部執行完,定義一個 performWorkUnit。
function performWorkUnit() {
// 任務執行完畢后結束遞歸
if (taskQueue.length === 0) {
btn.innerText = '執行'
return
}
requestIdleCallback(deadline => {
let task;
while ((task = taskQueue.pop()) && !deadline.didTimeout && deadline.timeRemaining() > 0) {
task()
}
performWorkUnit()
})
}
然后在點擊事件中調用即可。
btn.onclick = () => {
btn.innerText = '已點擊,插入中'
requestIdleCallback(performWorkUnit)
}
執行效果如圖所示,我們會發現卡頓消失了。
此時我們為了更好的觀察效果,讓每一個小任務的執行都阻塞 1ms。
function task() {
const startTime = performance.now()
let span = document.createElement('span')
span.innerText = 1
while (performance.now() - startTime < 1) {
// 阻塞 1 ms
}
container.appendChild(span)
}
然后把任務數量改成 1000。
const taskQueue = Array.from({ length: 1000 }, () => task)
執行效果如下:
五、插隊
我們另外起一個按鈕,專門用于執行一些插隊任務。插隊的邏輯非常簡單,只需要往 taskQueue 中添加任務即可。不過插隊任務的優先級更高一些,因此要通過 push 來添加,以確保任務能夠更早的執行。
首先聲明一個 highPriorityTask 函數用于創建優先級更高的任務。
function highPriorityTask() {
const startTime = performance.now()
let span = document.createElement('span')
span.style.color = 'red'
span.innerHTML = '<strong>插隊任務</strong>'
while (performance.now() - startTime < 1) {
// 阻塞 1 ms
}
container.appendChild(span)
}
新增一個按鈕,用于觸發插隊任務的執行。
pushBtn.onclick = function () {
taskQueue.push(highPriorityTask)
}
我們來看一下執行效果,每當我點擊插隊任務按鈕,就會執行一個優先級更高的任務。
代碼非常的簡單,不過理解可能需要稍微思考一下。因為 performWorkUnit 中遞歸在遍歷隊列 taskQueue,并且這個遞歸過程是一直處于中斷 -> 恢復的過程中,因此,當遍歷被中斷后,在它恢復之前,我們可以往 taskQueue 中插入新的任務到隊列頭部,當它重新開始遍歷時,新加入的任務就會被執行。
這里一個小的細節是,在事件循環的運行規則中,點擊事件的回調會比 requestIdleCallback 更早執行。
六、總結
這個邏輯就是 React 并發模式的底層原理。只不過在 React 中,同時兼容了同步更新與異步更新,并且設計了更加復雜的優先級機制,增加了更多場景的條件判斷,導致源碼看上去變得更加復雜了。
當然,React 由于為了兼容更多的場景,改寫了任務中斷的判斷條件。因為在別的環境里,例如 node/React Native 等,不支持 requestIdleCallback,在這些場景之下,React 把中斷策略改為 5ms 中斷一次,然后利用 performance.now 或者 Date.now 來記錄時間。
/* eslint-disable no-var */
var getCurrentTime;
var hasPerformanceNow = typeof performance === 'object' && typeof performance.now === 'function';
if (hasPerformanceNow) {
var localPerformance = performance;
getCurrentTime = function () {
return localPerformance.now();
};
} else {
var localDate = Date;
var initialTime = localDate.now();
getCurrentTime = function () {
return localDate.now() - initialTime;
};
function shouldYieldToHost() {
var timeElapsed = getCurrentTime() - startTime;
if (timeElapsed < frameInterval) { // 5ms
// 主線程只被阻塞了很短時間;
// smaller than a single frame. Don't yield yet.
return false;
}
// 主線程被阻塞的時間不可忽視
return true;
}
并使用別的方式來替代 requestIdleCallback。
- node/old IE:setImmediate
- DOM/worker:MessageChannel
- 兜底方案:setTimeout
let schedulePerformWorkUntilDeadline;
if (typeof localSetImmediate === 'function') {
// Node.js and old IE.
schedulePerformWorkUntilDeadline = () => {
localSetImmediate(performWorkUntilDeadline);
};
} else if (typeof MessageChannel !== 'undefined') {
// DOM and Worker environments.
// We prefer MessageChannel because of the 4ms setTimeout clamping.
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
schedulePerformWorkUntilDeadline = () => {
port.postMessage(null);
};
} else {
// We should only fallback here in non-browser environments.
schedulePerformWorkUntilDeadline = () => {
// $FlowFixMe[not-a-function] nullable value
// @ts-ignore
localSetTimeout(performWorkUntilDeadline, 0);
};
}