JavaScript 效能利器 —— Web Worker

ThornWu發表於2018-12-12

本文是我的上一篇文章《PWA一隅》 的延伸,介紹與 PWA 中 Service Worker 相關的 Web Worker,希望對你有所幫助,也請多多指教。圍繞 PWA 主題的文章還將陸續釋出,敬請期待。

簡介

Web Worker (工作執行緒) 是 HTML5 中提出的概念,分為兩種型別,專用執行緒(Dedicated Web Worker) 和共享執行緒(Shared Web Worker)。專用執行緒僅能被建立它的指令碼所使用(一個專用執行緒對應一個主執行緒),而共享執行緒能夠在不同的指令碼中使用(一個共享執行緒對應多個主執行緒)。

專用執行緒可以看做是預設情況的 Web Worker,其加上修飾詞的目的是為了與共享執行緒進行區分。本文會較為嚴格地區分兩者,可能較為累贅,但個人認為這是必要的。如果單純以 Web Worker 字樣出現的地方指的是兩者都會有的情況。

用途

Web Worker 的意義在於可以將一些耗時的資料處理操作從主執行緒中剝離,使主執行緒更加專注於頁面渲染和互動。

  • 懶載入
  • 文字分析
  • 流媒體資料處理
  • canvas 圖形繪製
  • 影象處理
  • ...

需要注意的點

  • 有同源限制
  • 無法訪問 DOM 節點
  • 執行在另一個上下文中,無法使用Window物件
  • Web Worker 的執行不會影響主執行緒,但與主執行緒互動時仍受到主執行緒單執行緒的瓶頸制約。換言之,如果 Worker 執行緒頻繁與主執行緒進行互動,主執行緒由於需要處理互動,仍有可能使頁面發生阻塞
  • 共享執行緒可以被多個瀏覽上下文(Browsing context)呼叫,但所有這些瀏覽上下文必須同源(相同的協議,主機和埠號)

瀏覽器支援度

根據 CanI Use 網站的統計,目前約有 93.05% 的瀏覽器支援專用執行緒。

image

而對於共享執行緒,則僅有大約 41.66% 的瀏覽器支援。

image

由於專用執行緒和共享執行緒的構造方法都包含在 window 物件中,我們在使用兩者之前可以對瀏覽器的支援性進行判斷。

if (window.Worker) {
    // ...
}
複製程式碼
if (window.SharedWorker) {
    // ...
}
複製程式碼

執行緒建立

專用執行緒由 Worker()方法建立,可以接收兩個引數,第一個引數是必填的指令碼的位置,第二個引數是可選的配置物件,可以指定 typecredentialsname 三個屬性。

var worker = new Worker('worker.js')
// var worker = new Worker('worker.js', { name: 'dedicatedWorker'})
複製程式碼

共享執行緒使用 Shared Worker() 方法建立,同樣支援兩個引數,用法與 Worker() 一致。

var sharedWorker = new SharedWorker('shared-worker.js')
複製程式碼

值得注意的是,因為 Web Worker 有同源限制,所以在本地除錯的時候也需要通過啟動本地伺服器的方式訪問,使用 file:// 協議直接開啟的話將會丟擲異常。

資料傳遞

Worker 執行緒和主執行緒都通過 postMessage() 方法傳送訊息,通過 onmessage 事件接收訊息。在這個過程中資料並不是被共享的,而是被複制的。值得注意的是 ErrorFunction 物件不能被結構化克隆演算法複製,如果嘗試這麼做的話會導致丟擲 DATA_CLONE_ERR 的異常。另外,postMessage() 一次只能傳送一個物件, 如果需要傳送多個引數可以將引數包裝為陣列或物件再進行傳遞。

關於 postMessage() 和結構化克隆演算法(The structured clone algorithm)將在本文最後進行闡述。

下面是專用執行緒資料傳遞的示例。

// 主執行緒
var worker = new Worker('worker.js')
worker.postMessage([10, 24])
worker.onmessage = function(e) {
    console.log(e.data)
}

// Worker 執行緒
onmessage = function (e) {
    if (e.data.length > 1) {
        postMessage(e.data[1] - e.data[0])
    }
}
複製程式碼

在 Worker 執行緒中,selfthis 都代表子執行緒的全域性物件。對於監聽 message 事件,以下的四種寫法是等同的。

// 寫法 1
self.addEventListener('message', function (e) {
    // ...
})

// 寫法 2
this.addEventListener('message', function (e) {
    // ...
})

// 寫法 3
addEventListener('message', function (e) {
    // ...
})

// 寫法 4
onmessage = function (e) {
    // ...
}
複製程式碼

主執行緒通過 MessagePort 訪問專用執行緒和共享執行緒。專用執行緒的 port 會線上程建立時自動設定,並且不會暴露出來。與專用執行緒不同的是,共享執行緒在傳遞訊息之前,埠必須處於開啟狀態。MDN 上的 MessagePort 關於 start() 方法的描述是:

Starts the sending of messages queued on the port (only needed when using EventTarget.addEventListener; it is implied when using MessagePort.onmessage.)

這句話經過試驗,可以理解為 start() 方法是與 addEventListener 配套使用的。如果我們選擇 onmessage 進行事件監聽,那麼將隱含呼叫 start() 方法。

// 主執行緒
var sharedWorker = new SharedWorker('shared-worker.js')
sharedWorker.port.onmessage = function(e) {
    // 業務邏輯
}
複製程式碼
var sharedWorker = new SharedWorker('shared-worker.js')
sharedWorker.port.addEventListener('message', function(e) {
    // 業務邏輯
}, false)
sharedWorker.port.start() // 需要顯式開啟
複製程式碼

在傳遞訊息時,postMessage() 方法和 onmessage 事件必須通過埠物件呼叫。另外,在 Worker 執行緒中,需要使用 onconnect 事件監聽埠的變化,並使用埠的訊息處理函式進行響應。

// 主執行緒
sharedWorker.port.postMessage([10, 24])
sharedWorker.port.onmessage = function (e) {
    console.log(e.data)
}

// Worker 執行緒
onconnect = function (e) {
    let port = e.ports[0]

    port.onmessage = function (e) {
        if (e.data.length > 1) {
            port.postMessage(e.data[1] - e.data[0])
        }
    }
}
複製程式碼

關閉 Worker

可以在主執行緒中使用 terminate() 方法或在 Worker 執行緒中使用 close() 方法關閉 worker。這兩種方法是等效的,但比較推薦的用法是使用 close(),防止意外關閉正在執行的 Worker 執行緒。Worker 執行緒一旦關閉 Worker 後 Worker 將不再響應。

// 主執行緒
worker.terminate()

// Dedicated Worker 執行緒中
self.close()

// Shared Worker 執行緒中
self.port.close()
複製程式碼

錯誤處理

可以通過在主執行緒或 Worker 執行緒中設定 onerroronmessageerror 的回撥函式對錯誤進行處理。其中,onerror 在 Worker 的 error 事件觸發並冒泡時執行,onmessageerror 在 Worker 收到的訊息不能進行反序列化時觸發(本人經過嘗試沒有辦法觸發 onmessageerror 事件,如果在 worker 執行緒使用 postMessage 方法傳遞一個 Error 或 Function 物件會因為無法序列化優先被 onerror 方法捕獲,而根本不會進入反序列化的過程)。

// 主執行緒
worker.onerror = function () {
    // ...
}

// 主執行緒使用專用執行緒
worker.onmessageerror = function () {
    // ...
}

// 主執行緒使用共享執行緒
worker.port.onmessageerror = function () {
    // ...
}

// worker 執行緒
onerror = function () {

}
複製程式碼

載入外部指令碼

Web Worker 提供了 importScripts() 方法,能夠將外部指令碼檔案載入到 Worker 中。

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

// 以上寫法等價於
importScripts('script1.js', 'script2.js')
複製程式碼

子執行緒

Worker 可以生成子 Worker,但有兩點需要注意。

  • 子 Worker 必須與父網頁同源
  • 子 Worker 中的 URI 相對於父 Worker 所在的位置進行解析

嵌入式 Worker

目前沒有一類標籤可以使 Worker 的程式碼像 <script> 元素一樣嵌入網頁中,但我們可以通過 Blob() 將頁面中的 Worker 程式碼進行解析。

<script id="worker" type="javascript/worker">
// 這段程式碼不會被 JS 引擎直接解析,因為型別是 'javascript/worker'

// 在這裡寫 Worker 執行緒的邏輯
</script>
<script>
    var workerScript = document.querySelector('#worker').textContent
    var blob = new Blob(workerScript, {type: "text/javascript"})
    var worker = new Worker(window.URL.createObjectURL(blob))
</script>
複製程式碼

關於 postMessage

Web Worker 中,Worker 執行緒和主執行緒之間使用結構化克隆演算法(The structured clone algorithm)進行資料通訊。結構化克隆演算法是一種通過遞迴輸入物件構建克隆的演算法,演算法通過儲存之前訪問過的引用的對映,避免無限遍歷迴圈。這一過程可以理解為,在傳送方使用類似 JSON.stringfy() 的方法將引數序列化,在接收方採用類似 JSON.parse() 的方法反序列化。

但是,一次資料傳輸就需要同時經過序列化和反序列化,如果資料量大的話,這個過程本身也可能造成效能問題。因此, Worker 中提出了 Transferable Objects 的概念,當資料量較大時,我們可以選擇在將主執行緒中的資料直接移交給 Worker 執行緒。值得注意的是,這種轉移是徹底的,一旦資料成功轉移,主執行緒將不能訪問該資料。這個移交的過程仍然通過 postMessage 進行傳遞。

postMessage(message, transferList)
複製程式碼

例如,傳遞一個 ArrayBuffer 物件

let aBuffer = new ArrayBuffer(1)
worker.postMessage({ data: aBuffer }, [aBuffer])
複製程式碼

上下文

Worker 工作在一個 WorkerGlobalDataScope 的上下文中。每一個 WorkerGlobalDataScope 物件都有不同的 event loop。這個 event loop 沒有關聯瀏覽器上下文(browsing context),它的任務佇列也只有事件(events)、回撥(callbacks)和聯網的活動(networking activity)。

每一個 WorkerGlobalDataScope 都有一個 closing 標誌,當這個標誌設為 true 時,任務佇列將丟棄之後試圖加入任務佇列的任務,佇列中已經存在的任務不受影響(除非另有指定)。同時,定時器將停止工作,所有掛起(pending)的後臺任務將會被刪除。

Worker 中可以使用的函式和類

由於 Worker 工作的上下文不同於普通的瀏覽器上下文,因此不能訪問 window 以及 window 相關的 API,也不能直接操作 DOM。Worker 中提供了 WorkerNavigatorWorkerLocation 介面,它們分別是 window 中 NavigatorLocation 的子集。除此之外,Worker 還提供了涉及時間、儲存、網路、繪圖等多個種類的介面,以下列舉了其中的一部分,更多的介面可以參考 MDN 文件

時間相關

  • clearInterval()
  • clearTimeout()
  • setInterval()
  • setTimeout

Worker 相關

  • importScripts()
  • close()
  • postMessage()

儲存相關

  • Cache
  • IndexedDB

網路相關

  • Fetch
  • WebSocket
  • XMLHttpRequest

相關連結

參考

擴充套件閱讀

相關文章