前段時間,為了優化某個有點復雜的功能,我采用了shared workers + indexDB,構建了一個高性能的多頁面共享的服務。由於是第一次真正意義上的運用workers,比以前單純的學習有更多體會,所以這里就分享出來!
各種worker概要
有三種worker:普通的worker、shared worker、service worker。(有極少的文檔說有四種,多了一個 audio worker,但其實所謂的audio worker 就是 audio context,用於構建強大的音/視頻處理系統)
- 普通worker,也叫專用worker,僅能被生成它的腳本所使用,全局對象this是DedicatedWorkerGlobalScope對象
- 共享worker,即sharedworker,能被不同的window頁面,iframe,以及worker訪問(當然要遵循同源限制),全局對象this是 SharedWorkerGlobalScope 對象。
- serviceWorker,專為PWA應用而生的worker,構建一個PWA必須要基於https,且所使用的密鑰簽名必須是經過CA認證的,否則你的瀏覽器都將認為不安全,而不會加載你的service worker。由於這個特殊性,我並沒有深入了解service worker!
serviceWorker 一般作為web應用程序、瀏覽器和網絡(如果可用)之前的代理服務器。它們旨在(除開其他方面)創建有效的離線體驗,攔截網絡請求,以及根據網絡是否可用采取合適的行動並更新駐留在服務器上的資源。他們還將允許訪問推送通知和后台同步API。
作為官方標准,3種worker當前的瀏覽器支持性都非常良好,可以放心使用! 呃,等一下,shared worker的支持性好像不太好喲:
不用緊張,不支持的主要是應用場景不多的移動端(移動端應用誰會開啟多窗口?)和ios了,總體可以忽略(如果必須考慮ios的web端,那就要考慮回退方案了)。
如果你要實現的功能中,用戶多窗口操作是很正常的;有數據庫(如indexDB)、socket等鏈接;大量相同的可共用的變量……毫無疑問你應該使用shared worker!
我所要優化的功能就有這些特點,這就是采用shared worker的原因。
worker與主線程的交互
這里只講專用worker 和 sharedWorker兩種(service worker沒有深入了解)。專用worker和sharedWorker差別很小,所以接下來先詳細的把專用worker講解清楚,再講解sharedWorker的不同點。
專用worker和主線程的交互
示例:
// 主線程: const worker = new Worker('./worker.js') worker.onmessage = (e) => { console.log('[main receive]:',e.data ) } worker.postMessage('Hello ,this is main thread') // worker.js: addEventListener('message', function (e) { console.log('[worker receive]:', e.data ) postMessage('Hi,this is worker thread') });
- 主線程和worker 都是通過 postMessage 方法向對方發送消息。
- 雙方也都是通過監聽 message 事件來接收消息(上面分別有兩種監聽方法: addEventListener 和 onmessage ,就是個DOM Event )。
- 事件句柄的data字段的值就是發送消息時傳遞的內容。
運行結果:
postMessage發送 + 監聽message事件接收——交互原理就這么簡單,這也是唯一的交互方式!
深入消息的數據傳遞
數據絕對不會以引用的方式“共享”過去,要么被復制,要么被轉移
拷貝
普通的數據傳遞,是通過拷貝來進行的。也就是發過去的是一份拷貝而非引用,如果是個對象,那么修改對象屬性是互不影響的——數據能獨立變化,互不影響。
和indexDB一樣,拷貝是采用結構化克隆的規范的,經過測試它至少有以下副作用:
- 對象里不能含有方法,也不能拷貝方法
- 對象里不能含有symbol,也不能拷貝symbol,鍵為symbol的屬性會被忽略
- 大多數對象的類信息會丟失。如:傳遞一個 obj=new Person() 收到的數據將沒有 Person這個類信息。
但是如果是一個內置對象,如Number,Boolean這樣的對象,則不會丟失!(注意:這一點和mdn描述的不一樣) - 不可枚舉的屬性(enumerable為false)將會被忽略。
- 屬性的可寫性配置(writable配置)將丟失。
- 經過測試,所有通過 Object.defineProperties 新增的(注意 是新增的!)屬性都將被忽略。
轉移
拷貝在某些情況下會存在性能問題,比如拷貝一個500M的文件,肯定會花較多時間。除了拷貝還提供通過轉移的方式來傳遞數據。
目前只有4種對象支持轉移:ArrayBuffer, MessagePort, ImageBitmap 和 OffscreenCanvas。
ArrayBuffer是原始的二進制緩沖區,文件File,Blob,各種 TypedArray ,都是基於arrayBuffer的。接下來以ArrayBuffer來舉例說明轉移傳遞數據:
可以轉移的數據,也可以通過拷貝來傳遞:
1 // 主線程: 2 const worker = new Worker('./worker.js') 3 const u8 = new Uint8Array(new ArrayBuffer(1)) // 創建一個長度為1的TypedArray u8 4 u8[0] = 1 5 worker.onmessage = (e) => { 6 const receive = e.data 7 console.log('[main receive]:', receive, 'orginal:', u8) 8 } 9 worker.postMessage(u8) // 通過普通的拷貝,將u8傳給worker 10 11 12 // worker.js : 13 addEventListener('message', function (e) { 14 const receive = e.data 15 receive[0] = 9 // worker 收到u8后,改變里面的內容 16 console.log('[worker change]:',receive) 17 postMessage(receive) 18 });
console打印結果:
這個例子僅僅表明,可以轉移的bufferArray也可以通過拷貝傳遞。注意看第二條打印:和預想中的一樣,主線程和worker線程的數據會獨立變化。
轉移傳遞示例:
轉移很簡單,僅僅是在postMessage時,額外傳入第二參數,表明要轉移的對象,將上面例子稍加改造:
1 // 主線程: 2 const worker = new Worker('./worker.js') 3 const u8 = new Uint8Array(new ArrayBuffer(1)) 4 u8[0] = 1 5 worker.onmessage = (e) => { 6 const receive = e.data 7 console.log('[main receive]:', receive, 'orginal:', u8) 8 worker.postMessage('finish') 9 } 10 worker.postMessage(u8 , [u8.buffer]) // 第二個參數表示要轉移的對象:注意這必須是一個數組;注意轉移的是typedArray的buffer,而不是typedArray! 11 12 13 14 // worker.js : 15 let receive 16 addEventListener('message', function (e) { 17 if(e.data==='finish'){ 18 console.log('[worker after transfer]',receive) 19 return; 20 } 21 receive = e.data 22 receive[0] = 9 23 console.log('[worker change]:',receive) 24 postMessage(receive,[receive.buffer]) // 轉移typedArray的buffer,typedArray長度將變成0! 25 26 }, false);
console的打印結果(注意理解兩個空的typedArray,為什么是空的數組,因為buffer的“使用權”被轉移了!):
把二進制數據直接轉移給子線程,一旦轉移,主線程就無法再使用這些二進制數據了!
sharedWorker與專用worker的差異
消息交互的差異:
sharedWorker與主線程交互和專用worker基本一樣,只是多了一個port:
1 // 主線程: 2 const worker = new SharedWorker('worker.js', { name: '公共服務' }) 3 // 創建worker時,除了文件路徑,還可以傳入一些額外的配置:如name。 4 // worker的name有id的功能,不同頁面要想共享sharedWorker,名稱相同是必要條件! 5 const key = Math.random().toString(16).substring(2) 6 worker.port.postMessage(key) // 通過worker.port發送消息 7 worker.port.onmessage = e => { // 通過worker.port接收消息 8 console.log(e.data) 9 } 10 11 12 // worker.js: 13 const buf = [] 14 onconnect = function (evt) { // 當其他線程創建sharedWorker其實是向sharedWorker發了一個鏈接,worker會收到一個connect事件 15 const port = evt.ports[0] // connect事件的句柄中evt.ports[0]是非常重要的對象port,用來向對應線程發送消息和接收對應線程的消息 16 port.onmessage = (m) => { 17 buf.push(m.data) 18 console.log(buf) // 這個打印沒看到?請看調試差異小節! 19 port.postMessage('worker receive:' + m.data) 20 } 21 }
注意看上面的注釋,信息交互都是通過port進行!通常一個sharedWorker可以對應多個主線程,所以sharedWorker多了一個connect事件,通過這個事件獲取各自的port與各自的主線程通信!
需要注意的是,在sharedWorker中,如果不是通過onmessage 而是通過addEventListener監聽message來接收消息,必須顯式調用start開啟連接,否則將無法收到消息,只能發送消息。示例:
// sharedWorker內部: port.start() port.addEventListener('message',e=>{ // ... }) // 主線程內部: worker.port.start() worker.port.addEventListener('message',e=>{ // ... })
調試的差異:
在上方的例子有兩處打印,第8行 主線程打印worker傳過來的消息,第18行worker內部打印緩存下來的[主線程傳過來的]消息。奇怪的是,當你打開開發者工具,在Console中並沒有看到第18行的打印信息!
要想看到第18行打印的信息對sharedWorker進行調試,需要進行下面兩步:
啟動一個新的標簽頁,網址輸入:chrome://inspect/#workers 界面如下:
點擊 inspect(千萬不要點擊terminate,這個是結束worker的),你會看到瀏覽器會打開一個新窗口,新窗口的界面就是開發者工具界面(做過web移動端開發的應該很熟悉這個界面):
切換到Sources頁面,就可以對SharedWorker代碼進行調試了!
全局對象差異:
在主線程中,一切都很好理解,我們通過創建的worker來監聽或發送消息,但在worker內部,則會發現直接調用 postMessage、onmessage等方法。
這是因為在worker內部,有一個全局對象 self,相當於globalThis(如果支持的話),相當於全局作用域下的this,直接調用相當於 self. 調用:
// 專用worker示例: globalThis.addEventListener('message', function (e) {}) self.postMessage(msgObj) // serviceWorker 示例: // 頂級作用域: this.onconnect = function(evt){}
上面的globalThis,self,this 均可以省略,類似於主線程的window!
正像前面提到過的:專用worker全局對象this是DedicatedWorkerGlobalScope對象,sharedWorker則是SharedWorkerGlobalScope 對象,這兩者都是WorkerGlobalScope的派生類,所以可以這樣判斷:
console.log(this instanceof DedicatedWorkerGlobalScope) // 專用worker 中 true, sharedWorker和主線程中報異常錯誤 console.log(this instanceof SharedWorkerGlobalScope) // sharedWorker 中 true, 專用worker和主線程中報異常錯誤 console.log(this instanceof WorkerGlobalScope) // 專用worker和sharedWorker中都是true, 主線程中異常錯誤
線程生命周期差異:
專用worker很好理解:每打開一個頁面就創建一個worker線程,關閉頁面worker就銷毀,刷新一次頁面worker就經歷了一次銷毀和創建的過程,不同頁面互不干擾。
你也可以像下面這樣主動銷毀一個worker:
// 專用worker內部 self.close() // 主動關閉worker連接,后續發送消息將靜默失敗 // 外部主線程: worker.terminate() // 或者外部這樣關閉連接,注意:一旦關閉worker,worker將會被銷毀,worker內的所有進行中的任務(如定時任務)都將直接銷毀
一個sharedWorker可以對應多個主線程,所以:打開頁面時,如果沒有sharedWorker時才創建,否則就共用已經存在的sharedWorker;當只有當前頁面和sharedWorker連接時,關閉當前頁面sharedWorker才會被銷毀,刷新當前頁面sharedWorker才會先銷毀后創建。
sharedWorker的連接也可以主動斷開,但僅僅是斷開鏈接,並不會銷毀sharedWorker,即便是唯一使用sharedWorker的頁面斷開了鏈接。worker內部進行中的任務會正常進行,只是不能正常與主線程通信了!
// 主線程: worker.port.close() // 僅僅關閉連接 // worker內部(拿到port后): port.close() // 僅僅關閉連接
很多人喜歡像下面這樣寫代碼,但請注意注釋中的說明,:
const clients = new Set() // 用於記錄所有與worker連接的線程 this.onconnect = function (c) { let port = c.ports[0] clients.add(port) // 沒有任何方法知道 port 已經斷開鏈接了(如頁面關閉),所以clients只能無限添加port。這會引起內存泄露 // 在你不得不這么做,以實現諸如“向所有頁面發送消息”的需求時,注意控制內存泄露的幅度: // 所有port使用同一個onmessageHandler實例和onmessageerrorHandler實例,是個不錯的選擇! port.onmessage = onmessageHandler port.onmessageerror = onmessageerrorHandler } function onmessageHandler(evt){} function onmessageerrorHandler(evt){}
事件和異常的交互
在面多異常和事件相關的問題時,你必須明白:worker 和 主線程是兩個線程!那么就很好理解:
worker中的事件,主線程是沒法監聽到的,反之亦然;worker中的異常,主線程是無法感知的,反之亦然!再次強調,二者唯一的交互方式就是 postMessage和監聽message事件。
// worker.js內部: // ... other code throw new Error('test error') // 這個錯誤無法被主線程獲取,相反 你會在worker的console中看到“錯誤未捕獲提示”的錯誤提示,而不是主線程的console!
主線程中可以監聽worker的error事件,但請注意這到底是什么error:
worker.onerror = e=>{ // 請注意 這里主線程監聽的是創建worker時的異常,而非worker創建成功后內部運行的異常 // 創建時異常:如下載worker腳本錯誤,路徑錯誤,worker腳本解析錯誤等 }
兩邊都能監聽 messageerror 事件,但是經過測試一直都沒法觸發這個事件,按官方的解釋是:當接收到一個消息,但是消息的數據無法成功解析時,會觸發這個事件。請注意,這里是“接收”!我嘗試發送一個無法拷貝的對象(如含有function字段),但是在發送時就失敗了。
可以看到 onerror 和 onmessageerror事件都是和對方無關的事件!
結語
本文深入講解了 worker 和 sharedWorker 與 主線程的交互。
現在你已經能用兩種worker做一些簡單的工作了,但是在面臨較復雜的工作,以及在面臨webpack這樣的工程中,使用worker(或sharedWorker)會面臨新的問題。敬請期待:深入web workers (下),我將與你詳細探討workers在工程化中的最佳實踐。