得物技術時間切片的實踐與應用

得物技術發表於2021-10-23

0x1:前言

每一個擁有【高階資深】title的前端工程師,必定會對專案的整體效能優化有自己的獨到見解。這是往前端業務架構方向轉變的必須要具備的能力之一。

本文就給大家介紹一個效能優化的手段之一:時間切片(Time Slicing)

根據W3C效能小組的介紹,超過50ms的任務就是長任務。

序號時間分佈描述
10 to 16 msUsers are exceptionally good at tracking motion, and they dislike it when animations aren't smooth. They perceive animations as smooth so long as 60 new frames are rendered every second. That's 16 ms per frame, including the time it takes for the browser to paint the new frame to the screen, leaving an app about 10 ms to produce a frame.
20 to 100 msRespond to user actions within this time window and users feel like the result is immediate. Any longer, and the connection between action and reaction is broken.
3100 to 1000 msWithin this window, things feel part of a natural and continuous progression of tasks. For most users on the web, loading pages or changing views represents a task.
41000 ms or moreBeyond 1000 milliseconds (1 second), users lose focus on the task they are performing.
510000 ms or moreBeyond 10000 milliseconds (10 seconds), users are frustrated and are likely to abandon tasks. They may or may not come back later.

表格內容摘抄自使用 RAIL 模型評估效能

根據上面的表格描述我們可以知道,當延遲超過100ms,使用者就會察覺到輕微的延遲。所以為了解決這個問題,每個任務不能超過50ms。

為了避免當延遲超過100ms,使用者就會察覺到輕微的延遲這種情況,我們可以使用兩種方案,一種是Web Worker,另一種是時間 切片 (Time Slicing)

0x2:web worker

測試Demo程式碼在此

眾所周知,JavaScript 語言採用的是單執行緒模型,也就是說,所有任務只能在一個執行緒上完成,一次只能做一件事。前面的任務沒做完,後面的任務只能等著。

針對我們業務上來講,一旦我們執行了過多的長任務,執行過程很容易就被阻塞,出現頁面假死的現象。雖然我們可以將任務放在任務佇列中,通過非同步的方式執行,但這並不能改變JS的本質。

所以為了改變這種現狀,whatwg推出了Web Workers

關於web worker,不需要深入,想了解的同學可以檢視MDN - web worker

  1. Web Worker為Web內容在後臺執行緒中執行指令碼提供了一種簡單的方法。
  2. 執行緒可以執行任務而不干擾使用者介面。

<!---->

  1. 可以使用XMLHttpRequest執行 I/O (儘管responseXMLchannel屬性總是為空)。一旦建立, 一個worker 可以將訊息傳送到建立它的JavaScript程式碼, 通過將訊息釋出到該程式碼指定的事件處理程式(反之亦然)。

Worker 執行緒一旦新建成功,就會始終執行,不會被主執行緒上的活動(比如使用者點選按鈕、提交表單)打斷。這樣有利於隨時響應主執行緒的通訊。但是,這也造成了 Worker 比較耗費資源,不應該過度使用,而且一旦使用完畢,就應該關閉。

Web Worker 有以下幾個使用注意點。

  1. 同源限制: 分配給 Worker 執行緒執行的指令碼檔案,必須與主執行緒的指令碼檔案同源。
  2. DOM 限制: Worker 執行緒所在的全域性物件,與主執行緒不一樣,無法讀取主執行緒所在網頁的 DOM 物件,也無法使用documentwindowparent這些物件。但是,Worker 執行緒可以navigator物件和location物件。

<!---->

  1. 通訊聯絡: Worker 執行緒和主執行緒不在同一個上下文環境,它們不能直接通訊,必須通過訊息完成。
  2. 指令碼限制: Worker 執行緒不能執行alert()方法和confirm()方法,但可以 XMLHttpRequest 物件發出 AJAX 請求。

<!---->

  1. 檔案限制: Worker 執行緒無法讀取本地檔案,即不能開啟本機的檔案系統(file://),它所載入的指令碼,必須來自網路。

我們可以看看使用了Web Worker之後的優化效果:

worker.js

self.onmessage = function () {
  const start = performance.now()
  while (performance.now() - start < 1000) {}
  postMessage('done!')
}

myWorker.js

const myWorker = new Worker('./worker.js')
setTimeout(_ => {
  myWorker.postMessage({})
  myWorker.onmessage = function (ev) {
    console.log(ev.data)
  }
}, 5000)

測試Demo程式碼在此 , 有興趣的小夥伴可以down下來學習

0x3:什麼是時間切片

時間切片的核心思想是:當一群任務在一個通道內執行,如果當前的任務不能在50毫秒內執行完,那麼為了不阻塞主執行緒,這個任務應該讓出主執行緒的控制權,使瀏覽器可以處理其他任務。讓出控制權意味著停止執行當前任務,讓瀏覽器去執行其他任務,隨後再回來繼續執行沒有執行完的任務。

所以時間切片的目的是不阻塞主執行緒,而實現目的的技術手段是將一個長任務拆分成很多個不超過50ms的小任務分散在巨集任務佇列中執行。

上圖可以看到主執行緒中有一個長任務,這個任務會阻塞主執行緒。使用時間切片將它切割成很多個小任務後,如下圖所示。

可以看到現在的主執行緒有很多密密麻麻的小任務,我們將它放大後如下圖所示。

可以看到每個小任務中間是有空隙的,代表著任務執行了一小段時間後,將讓出主執行緒的控制權,讓瀏覽器執行其他的任務。

使用時間切片的缺點是,任務執行的總時間變長了,這是因為它每處理完一個小任務後,主執行緒會空閒出來,並且在下一個小任務開始處理之前有一小段延遲。

但是為了避免卡死瀏覽器,這種取捨是很有必要的。

0x4:如何實踐時間切片

時間切片充分利用了“非同步”,在早期,可以使用定時器來實現,我們稱之為“手動切片”,例如:

btn.onclick = function () {
  someThing(); // 執行了50毫秒
  setTimeout(function () {
    otherThing(); // 執行了50毫秒
  });
};

上面程式碼當按鈕被點選時,本應執行100毫秒的任務現在被拆分成了兩個50毫秒的任務。

在實際應用中,我們可以進行一些封裝,封裝後的使用效果類似下面這樣:

btn.onclick = timeSlicing([someThing, otherThing], function () {
  console.log('done~');
});

當然,關於timeSlicing這個函式的API的設計並不是本文的重點,這裡想說明的是,在早期可以利用定時器來實現手動方式的“時間切片”;

如果切片的粒度不大,那麼手動自己改造函式其實也能接受,但是如果需要切割成粒度非常小的邏輯,那麼使用generator函式特性,會更加方便。

ES6帶來了迭代器的概念,並提供了生成器Generator函式用來生成迭代器物件,雖然Generator函式最正統的用法是生成迭代器物件,但這不妨我們利用它的特性做一些其他的事情。

Generator函式提供了yield關鍵字,這個關鍵字可以讓函式暫停執行。然後通過迭代器物件的next方法讓函式繼續執行

利用這個特性,我們可以設計出更方便使用的時間切片,例如:

btn.onclick = timeSlicing(function* () {
  someThing(); // 執行了50毫秒
  yield;
  otherThing(); // 執行了50毫秒
});

可以看到,我們只需要使用yield這個關鍵字就可以將本應執行100毫秒的任務拆分成了兩個50毫秒的任務。

我們甚至可以將yield關鍵字放在迴圈裡:

btn.onclick = timeSlicing(function* () {
  while (true) {
    someThing(); // 執行了50毫秒
    yield;
  }
});

上面程式碼我們寫了一個死迴圈,但依然不會阻塞主執行緒,瀏覽器也不會卡死。

下面我們正式利用Generator開始封裝一個時間切片執行器。利用generator的特性把每一次yield都放在requestIdleCallback裡執行,直到全部執行完畢,就可以輕鬆達到時間切片的效果了。

//首先我們封裝一個時間切片執行器
function timeSlicing(gen) {
 if (typeof gen !== "function")
 throw new Error("TypeError: the param expect a generator function");
 var g = gen();
 if (!g || typeof g.next !== "function")
 return;
 return function next() {
 var start = performance.now();
 var res = null;
 do {
 res = g.next();
 } while (res.done !== true && performance.now() - start < 25);
 if (res.done)
 return;
 window.requestIdleCallback(next);
 };
}
//然後把長任務變成generator函式,交由時間切片執行器來控制執行
const add = function(i){
 let item = document.createElement("li");
 item.innerText = 第${i++}條;
 listDom.appendChild(item);
 }
function* gen(){
 let i=0;
 while(i<100000){
 yield add(i);
 i++
 }
}
//使用時間切片來插入10W條資料
function bigInsert(){
 timeSlice(gen)()
}

0x5:時間切片實現斐波那契數列

每學習一門新程式語言,便就會被要求自己重新實現一遍斐波那契數列演算法。那時,常用的方法即遞迴法和遞推法。那時只對結果感興趣,只要結果出來了,其他的彷彿就無所謂了。

在瞭解了generator生成器的方法後,便開始可以嘗試使用generator方法去切片長任務執行。

首先介紹下斐波那契序 0,1,1,2,3,5,8,... 就每一項的值都是前兩項相加得到的。

遞迴方法:

首先,先把之前的遞迴方法再再再實現一遍。

const fibonacci = (n) => {
    if(n === 0 || n === 1)
        return n;
    return fibonacci(n-1) + fibonacci(n-2);
}

// 呼叫
console.log(fibonacci(40))

遞迴的思路很簡單,即不斷呼叫自身方法,直到n為1或0之後,開始一層層返回資料。

使用遞迴計算大數字時,效能會特別低,原因有以下2點:

  1. 在遞迴過程中,每建立一個新函式,直譯器都會建立一個新的函式棧幀,並且壓在當前函式的棧幀上,這就形成了呼叫棧。因而,當遞迴層數過大之後,就可能造成呼叫棧佔用記憶體過大或者溢位。
  2. 分析可以發現,遞迴造成了大量的重複計算。

generator生成器:

Generator是ES2015的新特性,得益於該特性,我們可以使用生成器方法,製作一個斐波那契數列生成器。

function *fibonacci(n, current = 0, next = 1) {
    if (n === 0) {
        return current;
    }
    yield current;
    yield *fibonacci(n-1, next, current + next);
}

// 呼叫
const [...data] = fibonacci(num)
console.log(data);

測試Demo程式碼在此

0x6:總結

時間切片不是什麼高階的api,而是一種根據瀏覽器渲染特性衍生出的優化方案,是一種優化思想,把計算量過大,容易阻塞渲染的邏輯切割成一個個小的任務來執行,留給瀏覽器渲染的時間來達到肉眼可見的流暢,本質上並沒有優化什麼js的計算效能,所以,有些演算法的邏輯該優化還是需要從演算法的思想上去優化。

文/Davis

關注得物技術,做最潮技術人!

相關文章