前端魔法堂:可能是你見過最詳細的WebWorker實用指南
- 2020 年 12 月 16 日
- 筆記
- javascript
前言
JavaScript從使用開初就一直基於事件循環的單線程運行模型,即使是成功進軍後端開發的Nodejs也沒有改變這一模型。那麼對於計算密集型的應用,我們必須創建新進程來執行運算,然後執行進程間通信實現傳參和獲取運算結果。否則會造成UI界面卡頓,甚至導致瀏覽器無響應。
從功能實現來看,我們可以通過新增iframe加載同域頁面來創建JSVM進程執行運算從而避免造成界面卡頓的問題。但存在如下問題:
- 這裡涉及到HTML頁面、JavaScript、iframe同源策略、iframe間消息通信的綜合應用,其中實際的運算邏輯都以JavaScript描述,而HTML頁面和iframe同源策略屬於底層基礎設施,而且這些基礎設施沒辦法封裝為一個類庫對外提供服務,這就增大應用開發和運維的難度;
- 進程的創建和銷毀成本絕對比線程的創建和銷毀多得多。
幸運的是HTML5為JavaScript引入多線程運行模型,這也是本文將和大家一起探討的———Web Worker。
困在籠子里的Web Worker
在使用Web Worker前我們要了解它的能力邊界,讓我們避免無謂的撞壁:
- 同源限制
1.1. 以http(s)://
協議加載給WebWorker線程運行的腳本時,其URL必須和UI線程所屬頁面的URL同源;
1.2. 不能加載客戶端本地腳本給WebWorker線程運行(即採用file://
協議),即使UI線程所屬頁面也是本地頁面; - 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的信息依然保持不變。 - 通信限制,UI線程和WebWorker線程間必須通過消息機制進行通信。
Dedicated Web Worker詳解
Web Worker分為Dedicated Web Worker和Shared Web Worker兩類,它們的特性如下:
- Dedicated Web Worker僅為創建它的JSVM進程服務,當其所屬的JSVM進程結束該Dedicated Web Worker線程也將結束;
- Shared Web Worker為創建它的JSVM進程所屬頁面的域名服務,當該域名下的所有JSVM進程均結束時該Shared Web Worker線程才會結束。
基本使用
- 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()
- 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——Blob
和URL.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詳解
共享線程可以和多個同域頁面間通信,當所有相關頁面都關閉時共享線程才會被釋放。
這裡的多個同域頁面包括:
- iframe之間
- 瀏覽器標籤頁之間
簡單示例
- 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)
- 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')
})
示例——廣播
- 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)
- 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線程執行。
- 安裝loader
npm install worker-loader -D
- 配置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')]
}
- UI線程代碼
import MyWorker from './my.worker'
const worker = new MyWorker('');
worker.postMessage('hi')
worker.addEventListener('message', event => console.log(event.data))
- 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,
- UI線程傳遞參數並調用運算函數;
- 在不影響用戶界面響應的前提下等待函數返回值;
- 獲取函數返回值繼續後續代碼。
翻譯為代碼就是
let arg1 = getArg1()
let arg2 = getArg2()
const result = await performCalcuation(arg1, arg2)
doSomething(result)
而UI線程和WebWorker線程的消息機制通信機制顯然會加大代碼複雜度,而Comlink類庫恰好能撫平這道傷疤。
- 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}`)
}
- 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到了嗎?😃
轉載請註明來自: //www.cnblogs.com/fsjohnhuang/p/14141311.html —— 肥仔John