梳理 Web Worker 及實戰場景

JackySummer發表於2022-12-15

前言

有一些前端技術點,即使以前用過,但沒有自己動手歸納總結過,許久還是要回過頭來還是需要重新梳理。於是,本文就來梳理一下 Web Worker。

為什麼需要 Web Worker

由於JavaScript語言採用的是單執行緒,同一時刻只能做一件事,如果有多個同步計算任務執行,則在這段同步計算邏輯執行完之前,它下方的程式碼不會執行,從而造成了阻塞,使用者的互動也可能無響應。

但如果把這段同步計算邏輯放到Web Worker執行,在這段邏輯計算執行期間依然可以執行它下方的程式碼,使用者的操作也可以響應了。

Web Worker 是什麼

HTML5 提供並規範了 Web Worker 這樣一套 API,它允許一段 JavaScript 程式執行在主執行緒之外的另外一個執行緒(Worker 執行緒)中。

Web Worker 的作用,就是為 JavaScript 創造多執行緒環境,允許主執行緒建立 Worker 執行緒,將一些任務分配給後者執行。這樣的好處是,一些計算密集型或高延遲的任務,被 Worker 執行緒負擔了,主執行緒就會很流暢,不會被阻塞或拖慢。

Web Worker 的分類

Web Worker 根據工作環境的不同,可分為專用執行緒 Dedicated Worker和共享執行緒 Shared Worker。

Dedicated Worker的Worker只能從建立該Woker的指令碼中訪問,而SharedWorker則可以被多個指令碼所訪問。

在開發中如果使用到 Web Worker,目前大部分主要還是使用 Dedicated Worker的場景多,它只能為一個頁面所使用,本文講的也是這一類;而Shared Worker可以被多個頁面共享,為跨瀏覽器 tab 共享資料提供了一種解決方案。

Web Worker的使用限制

同源限制

分配給 Worker 執行緒執行的指令碼檔案,必須與主執行緒的指令碼檔案同源。

檔案限制

Worker 執行緒無法讀取本地檔案(file://),會拒絕使用 file 協議來建立 Worker例項,它所載入的指令碼,必須來自網路。

DOM 操作限制

Worker 執行緒所在的全域性物件,與主執行緒不一樣,區別是:

  • 無法讀取主執行緒所在網頁的 DOM 物件
  • 無法使用documentwindowparent這些物件

通訊限制

Worker 執行緒和主執行緒不在同一個上下文環境,它們不能直接通訊,必須透過訊息完成,互動方法是postMessageonMessage,並且在資料傳遞的時候, Worker 是使用複製的方式。

指令碼限制

Worker 執行緒不能執行alert()方法和confirm()方法,但可以使用 XMLHttpRequest 物件發出 AJAX 請求,也可以使用setTimeout/setInterval等API

基本 API

const worker = new Worker(aURL, options);
  • worker.postMessage: 向 worker 的內部作用域傳送一個訊息,訊息可由任何 JavaScript 物件組成
  • worker.terminate: 立即終止 worker。該方法並不會等待 worker 去完成它剩餘的操作;worker 將會被立刻停止
  • worker.onmessage:當 worker 的父級接收到來自其 worker 的訊息時,會在 Worker 物件上觸發 message 事件
  • worker.onerror: 當 worker 出現執行中錯誤時,它的 onerror 事件處理函式會被呼叫。它會收到一個擴充套件了 ErrorEvent 介面的名為 error 的事件
worker.addEventListener('error', function (e) {
    console.log(e.message) // 可讀性良好的錯誤訊息
    console.log(e.filename) // 發生錯誤的指令碼檔名
    console.log(e.lineno) // 發生錯誤時所在指令碼檔案的行號
})

常見的使用方式

1. 直接指定指令碼檔案

const myWorker = new Worker(aURL, options);

aURL表示 worker 將執行的指令碼的 URL(指令碼檔案), 即 Web Worker 所要執行的任務。

案例如下:

// 主執行緒下建立worker執行緒
const worker = new Worker('./worker.js')

// 監聽接收worker執行緒發的訊息
worker.onmessage = function (e) {
  console.log('主執行緒收到worker執行緒訊息:', e.data)
}

// 向worker執行緒傳送訊息
worker.postMessage('主執行緒傳送hello world')

worker.js

// self 代表子執行緒自身,即子執行緒的全域性物件
self.addEventListener("message", function (e) {
  // e.data表示主執行緒傳送過來的資料
  self.postMessage("worker執行緒收到的:" + e.data); // 向主執行緒傳送訊息
});
Web Worker 的執行上下文名稱是 self,無法呼叫主執行緒的 window 物件的。上述寫法等同於以下寫法:
this.addEventListener("message", function (e) {
  // e.data表示主執行緒傳送過來的資料
  this.postMessage("worker執行緒收到的:" + e.data); // 向主執行緒傳送訊息
});

將JS檔案引入html掛在本地開發環境執行,執行結果如下:

主執行緒收到worker執行緒訊息: worker執行緒收到的:主執行緒傳送hello world 

2. 使用 Blob URL 建立

除了這種透過引入js檔案的方式,也可以透過URL.createObjectURL()建立URL物件,建立內嵌的worker

/**
 * const blob = new Blob(array, options);
 * Blob() 建構函式返回一個新的 Blob 物件。blob 的內容由引數陣列中給出的值的串聯組成。
 * @params array 是一個由ArrayBuffer, ArrayBufferView, Blob, DOMString 等物件構成的 Array
 * @options type,預設值為 "",它代表了將會被放入到 blob 中的陣列內容的 MIME 型別。還有兩個這裡忽略不列舉了
 */
 
/**
 * URL.createObjectURL():靜態方法會建立一個 DOMString,其中包含一個表示引數中給出的物件的 URL。這個 URL 的生命週期和建立它的視窗中的 document 繫結。這個新的 URL 物件表示指定的 File 物件或 Blob 物件
 */
const worker = new Worker(URL.createObjectURL(blob));
  • Blob 物件表示一個不可變、原始資料的類檔案物件,它的資料可以按文字或二進位制的格式進行讀取。File 介面基於 Blob,繼承了 blob 的功能並將其擴充套件以支援使用者系統上的檔案。
  • Blob URL/Object URL 是一種偽協議,允許 Blob 和 File 物件用作影像,下載二進位制資料連結等的 URL 源。在瀏覽器中,我們使用 URL.createObjectURL 方法來建立 Blob URL,該方法接收一個 Blob 物件,併為其建立一個唯一的 URL,其形式為 blob:<origin>/<uuid>
  • 瀏覽器內部為每個透過 URL.createObjectURL 生成的 URL 儲存了一個 URL 到 Blob 對映。因此,此類 URL 較短,但可以訪問 Blob。生成的 URL 僅在當前文件開啟的狀態下才有效,它儲存在記憶體中的。它允許引用 <img><a> 中的 Blob,但如果你訪問的 Blob URL 不再存在,則會從瀏覽器中收到 404 錯誤
function func() {
  console.log('hello')
}

function createWorker(fn) {
  // const blob = new Blob([fn.toString() + ' fn()'], { type: 'text/javascript' })
  const blob = new Blob([`(${fn.toString()})()`], { type: 'text/javascript' })
  return URL.createObjectURL(blob)
}

createWorker(func)

Worker 執行緒中引入指令碼

Worker執行緒內部要載入其他指令碼,可以使用 importScripts()

// worker.js
importScripts("constants.js");

// self 代表子執行緒自身,即子執行緒的全域性物件
self.addEventListener("message", function (e) {
  self.postMessage(foo); // 可拿到 `foo`、`getAge()`、`getName`的結果值 
});


// constants.js
const foo = "變數";

function getAge() {
  return 25;
}

const getName = () => {
  return "jacky";
};

還可以同時載入多個指令碼

importScripts('script1.js', 'script2.js');

實戰應用場景

處理大量CPU耗時計算操作

大家最關心的還是 Web Worker 實戰場景,開頭我們說到,當有大量複雜計算場景時,可使用 Web Worker

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>worker計算</title>
  </head>
  <body>
    <div>計算從 1 到給定數值的總和</div>
    <input type="text" placeholder="請輸入數字" id="num" />
    <button onclick="calc()">開始計算</button>
    <span>計算結果為:<span id="result">-</span></span>

    <div>在計算期間你可以填XX表單</div>
    <input type="text" placeholder="請輸入姓名" />
    <input type="text" placeholder="請輸入年齡" />

    <script>
      function calc() {
        const num = parseInt(document.getElementById('num').value)
        let result = 0
        let startTime = performance.now()
        // 計算求和(模擬複雜計算)
        for (let i = 0; i <= num; i++) {
          result += i
        }
        // 由於是同步計算,在沒計算完成之前下面的程式碼都無法執行
        const time = performance.now() - startTime
        console.log('總計算花費時間:', time)
        document.getElementById('result').innerHTML = result
      }
    </script>
  </body>
</html>

image.png

如上,第一個輸入框與按鈕是負責模擬複雜計算的,比如輸入 10000000000,點選開始計算,這時主執行緒處理一直在處理同步計算邏輯,在完成計算之前,會發現頁面處於卡頓的狀態,下方的兩個輸入框也無法點選互動,在我的電腦這部分計算是花了14s左右,這個卡頓時間給使用者的體驗就很差了。

開啟控制檯呼叫也可以看到這裡CPU使用率是100%

image.png

如果把這部分計算交給 Web Worker 來處理,修改程式碼:

<script>
const worker = new Worker('./worker.js')

function calc() {
    const num = parseInt(document.getElementById('num').value)
    worker.postMessage(num)
}

worker.onmessage = function (e) {
    document.getElementById('result').innerHTML = e.data
}
</script>

./worker.js

function calc(num) {
  let result = 0
  let startTime = performance.now()
  // 計算求和(模擬複雜計算)
  for (let i = 0; i <= num; i++) {
    result += i
  }
  // 由於是同步計算,在沒計算完成之前下面的程式碼都無法執行
  const time = performance.now() - startTime
  console.log('總計算花費時間:', time)
  self.postMessage(result)
}

self.onmessage = function (e) {
  calc(e.data)
}

然後重複上述一樣的操作,輸入 10000000000 計算,會發現下方兩個輸入框可正常流暢輸入,整個頁面也不卡頓。

Worker 執行獨立於主執行緒的後臺執行緒中,分擔執行了大量佔用CPU密集型的操作(但執行時間並不會變短),解放了主執行緒,主執行緒就能及時響應使用者操作而不會造成卡頓的現象。使用Web Worker後,控制檯工具可看到CPU使用率處於較低正常水平,計算過程跟沒計算之前的水平一樣。

image.png

音影片 canvas 繪製錄屏

這個是我工作中遇到的場景,透過 繪製 WebRTC 影片流錄製影片,最後生成影片。

以前寫過一篇文章如何實現前端錄屏,這篇基本就沒怎麼認真寫,就是純屬記錄,而且現在的程式碼跟這篇文章的示例程式碼差別很大(就是說示例程式碼改進空間很大),建議總體看下思路,實現具體細節就不細究了,畢竟思路相通。

就以上面這篇文章的程式碼作為示例(現最新程式碼跟公司程式碼業務結合就不放了),有個比較耗CPU的操作

// 16ms一次的定時器
refreshTimer.current = window.setInterval(onRefreshTimer, 16) 

// onRefreshTimer 函式里面做的實際就是高頻執行 recorderDrawFrame() 方法
// 錄屏繪製操作
const recorderDrawFrame = () => {
    const $recorderCanvas = recorderCanvas.current!
    const $player = videoRef.current!
    const ctx = recorderContext.current!
    const { width, height } = getResolution()
    $recorderCanvas.width = width
    $recorderCanvas.height = height
    
    // 其中這個繪製函式對CPU佔用率會比較高(在低配置的電腦瀏覽器上)
    ctx.drawImage(
      $player,
      0,
      0,
      $player.videoWidth,
      $player.videoHeight,
      0,
      0,
      $recorderCanvas.width,
      $recorderCanvas.height,
    )
    drawWatermark(ctx, width)
}

那麼怎麼做最佳化呢?就是把整個 onRefreshTimer 這個定時器函式交給 Web Worker執行。

上面說到,Web Worker 雖有DOM操作的限制,但可以使用 setTimeout/setInterval等API,所以具體實現就是把 Worker 封裝為類,在類內部處理好邏輯,然後暴露 setInterval 等方法給外部例項呼叫。

if (worker) {
    refreshTimer.current = worker.setInterval(onRefreshTimer, 16)
}

其他場景

在網上看過的文章,其他場景比如有:

  • 前端匯出生成excel
  • 圖片批次壓縮等

可參考閱讀:我不允許你們學會了worker卻還沒有應用場景

結語

Web Worker 日常業務開發整體用到的次數估計不多,但如果有遇到上述提到的那些類似的場景,我們最佳化的思考方向又可以多一個選擇了。

參考


本文正在參加「金石計劃 . 瓜分6萬現金大獎」

相關文章