前端魔法堂:可能是你見過最詳細的WebWorker實用指南

^_^肥仔John發表於2020-12-16
前端魔法堂:可能是你見過最詳細的WebWorker實用指南

前言

JavaScript從使用開初就一直基於事件迴圈的單執行緒執行模型,即使是成功進軍後端開發的Nodejs也沒有改變這一模型。那麼對於計算密集型的應用,我們必須建立新程式來執行運算,然後執行程式間通訊實現傳參和獲取運算結果。否則會造成UI介面卡頓,甚至導致瀏覽器無響應。
從功能實現來看,我們可以通過新增iframe載入同域頁面來建立JSVM程式執行運算從而避免造成介面卡頓的問題。但存在如下問題:

  1. 這裡涉及到HTML頁面、JavaScript、iframe同源策略、iframe間訊息通訊的綜合應用,其中實際的運算邏輯都以JavaScript描述,而HTML頁面和iframe同源策略屬於底層基礎設施,而且這些基礎設施沒辦法封裝為一個類庫對外提供服務,這就增大應用開發和運維的難度;
  2. 程式的建立和銷燬成本絕對比執行緒的建立和銷燬多得多。

幸運的是HTML5為JavaScript引入多執行緒執行模型,這也是本文將和大家一起探討的———Web Worker。

困在籠子裡的Web Worker

在使用Web Worker前我們要了解它的能力邊界,讓我們避免無謂的撞壁:

  1. 同源限制
    1.1. 以http(s)://協議載入給WebWorker執行緒執行的指令碼時,其URL必須和UI執行緒所屬頁面的URL同源;
    1.2. 不能載入客戶端本地指令碼給WebWorker執行緒執行(即採用file://協議),即使UI執行緒所屬頁面也是本地頁面;
  2. DOM和BOM限制
    1.1. 無法訪問UI執行緒所屬頁面的任何DOM元素;
    1.2. 可訪問如下BOM元素
    1.2.1. XMLHttpRequest/fetch
    1.2.2. setTimeout/clearTimeout
    1.2.3. setInterval/clearInterval
    1.2.4. location,注意該location指向的是WebWorker建立時以UI執行緒所屬頁面的當前Location為基礎建立的WorkerLocation物件,即使此後頁面被多次重定向,該location的資訊依然保持不變。
    1.2.5. navigator,注意該navigator指向的是WebWorker建立時以UI執行緒所屬頁面的當前Navigator為基礎建立的WorkerNavigator物件,即使此後頁面被多次重定向,該navigator的資訊依然保持不變。
  3. 通訊限制,UI執行緒和WebWorker執行緒間必須通過訊息機制進行通訊。

Dedicated Web Worker詳解

Web Worker分為Dedicated Web Worker和Shared Web Worker兩類,它們的特性如下:

  1. Dedicated Web Worker僅為建立它的JSVM程式服務,當其所屬的JSVM程式結束該Dedicated Web Worker執行緒也將結束;
  2. Shared Web Worker為建立它的JSVM程式所屬頁面的域名服務,當該域名下的所有JSVM程式均結束時該Shared Web Worker執行緒才會結束。

基本使用

  1. UI執行緒
const worker = new Worker('work.js') // 若下載失敗如404,則會默默地失敗不會拋異常,即無法通過try/catch捕獲。                                                                                                                      
const workerWithName = new Worker('work.js', {name: 'worker2'}) // 為Worker執行緒命名,那麼在Worker執行緒內的程式碼可通過 self.name 獲取該名稱。                                                                                        
                                                                                                                                                                                                                                              
worker.postMessage('Send message to worker.') // 傳送文字訊息                                                                                                                                                                     
worker.postMessage({type: 'message', payload: ['hi']}) // 傳送JavaScript物件,會先執行序列化為JSON文字訊息再傳送,然後在接收端自動反序列化為JavaScript物件。                                                                      
const uInt8Array = new Uint8Array(new ArrayBuffer(10))                                                                                                                                                                            
for (let i = 0; i < uint8array.length; ++i) {                                                                                                                                                                                     
  uInt8Array[i] = i * 2                                                                                                                                                                                                         
}                                                                                                                                                                                                                                 
worker.postMessage(uInt8Array) // 以先序列化後反序列化的方式傳送二進位制資料,傳送後主執行緒仍然能訪問uInt8Array變數的資料,但會造成效能問題。                                                                                        
worker.postMessage(uInt8Array, [uInt8Array]) // 以Transferable Objets的方式傳送二進位制資料,傳送後主執行緒無法訪問uInt8Array變數的資料,但不會造成效能問題,適合用於影像、聲音和3D等大檔案運算。                                     
                                                                                                                                                                                                                                              
// 接收worker執行緒向主執行緒傳送的訊息                                                                                                                                                                                               
worker.onmessage = event => {                                                                                                                                                                                                     
  console.log(event.data)                                                                                                                                                                                                       
}                                                                                                                                                                                                                                 
worker.addEventListener('message', event => {                                                                                                                                                                                     
  console.log(event.data)                                                                                                                                                                                                       
})                                                                                                                                                                                                                                
                                                                                                                                                                                                                                              
// 當傳送的訊息序列化失敗時觸發該事件。                                                                                                                                                                                           
worker.onmessageerror = error => console.error(error)                                                                                                                                                                             
                                                                                                                                                                                                                                              
// 捕獲Worker執行緒發生的異常                                                                                                                                                                                                       
worker.onerror = error => {                                                                                                                                                                                                       
  console.error(error)                                                                                                                                                                                                          
}                                                                                                                                                                                                                                 
                                                                                                                                                                                                                                              
// 關閉worker執行緒                                                                                                                                                                                                                 
worker.terminate()                                                                                                                                                                                                                
  1. Worker執行緒
// Worker執行緒的全域性物件為WorkerGlobalScrip,通過self或this引用。呼叫全域性物件的屬性和方法時可以省略全域性物件。                                                                                                                      
                                                                                                                                                                                                                                              
// 接收主執行緒向worker執行緒傳送的訊息                                                                                                                                                                                               
self.addEventListener('message', event => {                                                                                                                                                                                       
  console.log(event.data)                                                                                                                                                                                                       
})                                                                                                                                                                                                                                
addEventListener('message', event => {                                                                                                                                                                                            
  console.log(event.data)                                                                                                                                                                                                       
})                                                                                                                                                                                                                                
this.onmessage = event => {                                                                                                                                                                                                       
  console.log(event.data)                                                                                                                                                                                                       
}                                                                                                                                                                                                                                 
// 當傳送的訊息序列化失敗時觸發該事件。                                                                                                                                                                                           
self.onmessageerror = error => console.error(error)                                                                                                                                                                               
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      // 向主執行緒傳送訊息                                                                                                                                                                                                               
self.postMessage('send text to main worker')                                                                                                                                                                                      
                                                                                                                                                                                                                                              
// 結束自身所在的Worker執行緒                                                                                                                                                                                                       
self.close()                                                                                                                                                                                                                      
                                                                                                                                                                                                                                              
// 匯入其他指令碼到當前的Worker執行緒,不要求所引用的指令碼必須同域。                                                                                                                                                                   
self.importScripts('script1.js', 'script2.js')                                                                                                                                                                                    

通過WebWorker執行本頁尾本

方式1——BlobURL.createObjectURL

限制:UI執行緒所屬頁面不是本地頁面,即必須為http(s)://協議。

const script = `addEventListener('message', event => {                                                                                                                                                                                    
  console.log(event.data)                                                                                                                                                                                                               
  postMessage('echo')                                                                                                                                                                                                                   
}`                                                                                                                                                                                                                                        
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            
const blob = new Blob([script])                                                                                                                                                                                                           
const url = URL.createObjectURL(blob)                                                                                                                                                                                                     
const worker = new Worker(url)                                                                                                                                                                                                            
worker.onmessage = event => console.log(event.data)                                                                                                                                                                                       
worker.postMessage('main thread')                                                                                                                                                                                                         
setTimeout(()=>{                                                                                                                                                                                                                          
  worker.terminate()                                                                                                                                                                                                                    
  URL.revokeObjectURL(url) // 必須手動釋放資源,否則需要重新整理Browser Context時才會被釋放。                                                                                                                                               
}, 1000)  

方式2——Data URL

限制:無法利用JavaScript的ASI機制少寫分號。
優點:即使UI執行緒所屬頁面是本地頁面也可以執行。

// 由於Data URL的內容為必須壓縮為一行,因此JavaScript無法利用換行符達到分號的效果。                                                                                                                                                       
const script = `addEventListener('message', event => {                                                                                                                                                                                    
  console.log(event.data);                                                                                                                                                                                                              
  postMessage('echo');                                                                                                                                                                                                                  
}`                                                                                                                                                                                                                                        
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       
const worker = new Worker(`data:,${script}`)                                                                                                                                                                                              
// 或 const worker = new Worker(`data:application/javascript,${script}`)                                                                                                                                                                  
worker.onmessage = event => console.log(event.data)                                                                                                                                                                                       
worker.postMessage('main thread')  

Shared Web Worker詳解

共享執行緒可以和多個同域頁面間通訊,當所有相關頁面都關閉時共享執行緒才會被釋放。
這裡的多個同域頁面包括:

  1. iframe之間
  2. 瀏覽器標籤頁之間

簡單示例

  1. UI主執行緒
const worker = new SharedWorker('./worker.js')                                                                                                                                                                                             
worker.port.addEventListener('message', e => {                                                                                                                                                                                             
  console.log(e.data)                                                                                                                                                                                                                      
}, false)                                                                                                                                                                                                                                  
worker.port.start()  // 連線worker執行緒                                                                                                                                                                                                     
worker.port.postMessage('hi')                                                                                                                                                                                                              
                                                                                                                                                                                                                                              
setTimeout(()=>{                                                                                                                                                                                                                           
  worker.port.close() // 關閉連線                                                                                                                                                                                                          
}, 10000)                                                                                                                                                                                                                                  
  1. Shared Web Worker執行緒
let conns = 0                                                                                                                                                                                                                              
                                                                                                                                                                                                                                              
// 當UI執行緒執行worker.port.start()時觸發建立連線                                                                                                                                                                                           
self.addEventListener('connect', e => {                                                                                                                                                                                                    
  const port = e.ports[0]                                                                                                                                                                                                                  
  conns+=1                                                                                                                                                                                                                                 
                                                                                                                                                                                                                                              
  port.addEventListener('message', e => {                                                                                                                                                                                                  
    console.log(e.data)  // 注意console物件指向第一個建立Worker執行緒的UI執行緒的console物件。即如果A先建立Worker執行緒,那麼後續B、C等UI執行緒執行worker.port.postMessage時回顯信心依然會傳送給A頁面。                                            
  })                                                                                                                                                                                                                                       
                                                                                                                                                                                                                                              
  // 建立雙向連線,可相互通訊                                                                                                                                                                                                              
  port.start()                                                                                                                                                                                                                             
  port.postMessage('hey')                                                                                                                                                                                                                  
})                                                                                                                                                                                                                                         

示例——廣播

  1. UI主執行緒
   const worker = new SharedWorker('./worker.js')                                                                                                                                                                                             
   worker.port.addEventListener('message', e => {                                                                                                                                                                                             
     console.log('SUM:', e.data)                                                                                                                                                                                                              
   }, false)                                                                                                                                                                                                                                  
   worker.port.start()  // 連線worker執行緒                                                                                                                                                                                                     
                                                                                                                                                                                                                                              
   const button = document.createElement('button')                                                                                                                                                                                            
   button.textContent = 'Increment'                                                                                                                                                                                                           
   button.onclick = () => worker.port.postMessage(1)                                                                                                                                                                                          
   document.body.appendChild(button)                                                                                                                                                                                                          
  1. Shared Web Worker執行緒
   let sum = 0                                                                                                                                                                                                                                
   const conns = []                                                                                                                                                                                                                           
   self.addEventListener('connect', e => {                                                                                                                                                                                                    
     const port = e.ports[0]                                                                                                                                                                                                                  
     conns.push(port)                                                                                                                                                                                                                         
                                                                                                                                                                                                                                              
     port.addEventListener('message', e => {                                                                                                                                                                                                  
       sum += e.data                                                                                                                                                                                                                          
       conns.forEach(conn => conn.postMessage(sum))                                                                                                                                                                                           
     })                                                                                                                                                                                                                                       
                                                                                                                                                                                                                                              
     port.start()                                                                                                                                                                                                                             
   })                                                                                                                                                                                                                                         

即使是Web Worker也阻止不了你卡死瀏覽器的決心

通過WebWorker執行計算密集型任務是否就可以肆無忌憚地編寫程式碼,並保證使用者介面操作流暢呢?當然不是啦,工具永遠只能讓你更好地完成工作,但無法禁止你用錯。
只要在頻繁持續執行的程式碼中加入console物件方法的呼叫,加上一不小心開啟Devtools工具,卡死瀏覽器簡直不能再就簡單了。這是為什麼呢?
因為UI執行緒在建立WebWorker執行緒時會將自身的console物件繫結給WebWorker執行緒的console屬性上,那麼WebWorker執行緒是以同步阻塞方式呼叫console將引數傳遞給UI執行緒的console物件,自然會佔用UI執行緒的處理時間。

工程化——通過Webpack的worker-loader打包程式碼

上面說了這麼多那實際專案中應該怎麼使用呢?或者說如何更好的整合到工程自動化工具——Webpack呢?
worker-loader和shared-worker-loader就是我們想要的。
通過worker-loader將程式碼轉換為Blob型別,並通過URL.createObjectURL建立url分配給WebWorker執行緒執行。

  1. 安裝loader
npm install worker-loader -D
  1. 配置Webpack.config.js
// 處理worker程式碼的loader必須位於js和ts之前                                                                                                                                                                                              
{                                                                                                                                                                                                                                        
 test: /\.worker\.ts$/,                                                                                                                                                                                                                 
 use: {                                                                                                                                                                                                                                 
 loader: 'worker-loader',                                                                                                                                                                                                             
 options: {                                                                                                                                                                                                                           
   name: '[name]:[hash:8].js', // 打包後的chunk的名稱                                                                                                                                                                                 
   inline: true // 開啟內聯模式,將chunk的內容轉換為Blob物件內嵌到程式碼中。                                                                                                                                                            
   }                                                                                                                                                                                                                                    
 }                                                                                                                                                                                                                                      
},                                                                                                                                                                                                                                      
{                                                                                                                                                                                                                                        
 test: /\.js$/,                                                                                                                                                                                                                         
 use: {                                                                                                                                                                                                                                 
  loader: 'babel-loader'                                                                                                                                                                                                               
 },                                                                                                                                                                                                                                     
 exclude: [path.resolve(__dirname, 'node_modules')]                                                                                                                                                                                     
},                                                                                                                                                                                                                                       
{                                                                                                                                                                                                                                        
 test: /\.ts(x?)$/,                                                                                                                                                                                                                     
 use: [                                                                                                                                                                                                                                 
   { loader: 'babel-loader' },                                                                                                                                                                                                          
   { loader: 'ts-loader' } // loader順序從後往前執行                                                                                                                                                                                    
 ],                                                                                                                                                                                                                                     
 exclude: [path.resolve(__dirname, 'node_modules')]                                                                                                                                                                                     
}                                                                                                                                                                                                                                                
  1. UI執行緒程式碼
import MyWorker from './my.worker'                                                                                                                                                                                                       
                                                                                                                                                                                                                                              
const worker = new MyWorker('');                                                                                                                                                                                                         
worker.postMessage('hi')                                                                                                                                                                                                                 
worker.addEventListener('message', event => console.log(event.data))   
  1. Worker執行緒程式碼
cosnt worker: Worker = self as any                                                                                                                                                                                                       
worker.addEventListener('message', event => console.log(event.data))                                                                                                                                                                     
                                                                                                                                                                                                                                              
export default null as any // 標識當前為TS模組,避免報xxx.ts is not a module的異常   

工程化——RPC類庫Comlink

一般場景下我們會這樣使用WebWorker,

  1. UI執行緒傳遞引數並呼叫運算函式;
  2. 在不影響使用者介面響應的前提下等待函式返回值;
  3. 獲取函式返回值繼續後續程式碼。
    翻譯為程式碼就是
let arg1 = getArg1()
let arg2 = getArg2()
const result = await performCalcuation(arg1, arg2)
doSomething(result)

而UI執行緒和WebWorker執行緒的訊息機制通訊機制顯然會加大程式碼複雜度,而Comlink類庫恰好能撫平這道傷疤。

  1. UI執行緒
import * as Comlink from 'comlink'                                                                                                                                                                                                       
                                                                                                                                                                                                                                              
async function init() {                                                                                                                                                                                                                  
 const cl = Comlink.wrap(new Worker('worker.js'))                                                                                                                                                                                     
 console.log(`Counter: ${await cl.counter}`)                                                                                                                                                                                          
 await cl.inc()                                                                                                                                                                                                                       
 console.log(`Counter: ${await cl.counter}`)                                                                                                                                                                                          
}                                                                                                                                                                                                                                        
  1. Worker執行緒
import * as Comlink from 'comlink'                                                                                                                                                                                                       
                                                                                                                                                                                                                                              
const obj = {                                                                                                                                                                                                                            
  counter: 0,                                                                                                                                                                                                                            
  inc() {                                                                                                                                                                                                                                
    this.counter+=1                                                                                                                                                                                                                      
  }                                                                                                                                                                                                                                      
}                                                                                                                                                                                                                                        
Comlink.expose(obj)                                                                                                                                                                                                                      

Electron中使用WebWorker

Electron中使用Web Worker的同源限制中開了個口——UI執行緒所屬頁面URL為本地檔案時,所分配給Web Worker的指令碼可為本地指令碼。
其實Electron打包後讀取的HTML頁面、指令碼等都是本地檔案,如果不能分配本地指令碼給Web Worker執行,那就進入死衚衕了。

const path = window.require('path')                                                                                                                                                                                                                                     
                                                                                                                                                                                                                                                                          
const worker = new Worker(path.resolve(__dirname, 'worker.js'))                                                                                                                                                                                                         

上述程式碼僅表示Electron可以分配本地指令碼給WebWorker執行緒執行,但實際開發階段一般是通過 http(s)?/ 協議載入頁面資源,而釋出時才會打包為本地資源。
所以這裡還要分為開發階段用和釋出用程式碼,還涉及資源的路徑問題,所以還不如直接轉換為Blob資料內嵌到UI執行緒的程式碼中更便捷。

總結

隨著邊緣計算的興起,客戶端承擔部分計算任務提高運算時效性和降低服務端壓力必將成為趨勢。WebWorker這一祕技你Get到了嗎??
轉載請註明來自: https://www.cnblogs.com/fsjohnhuang/p/14141311.html —— 肥仔John

相關文章