Web Workers 分類及 5 個使用場景
原文請查閱這裡,略有刪減,本文采用知識共享署名 4.0 國際許可協議共享,BY Troland。
這是 JavaScript 工作原理的第七章。
本系列持續更新中,Github 地址請查閱這裡。
現在,我們將會剖析 Web Workers:我們將會綜合比較不同型別的 workers,如何組合運用他們的構建模組來進行開發以及不同場景下各自的優缺點。最後,我們將會介紹 5 個 Web Workder 的使用場景。
在前面的詳細介紹的文章中你已經清楚地瞭解到 JavaScript 是單執行緒的事實。然而,JavaScript 也允許開發者編寫非同步程式碼。
非同步程式設計的侷限性
前面我們瞭解到非同步程式設計及其使用時機。
非同步程式設計通過排程部分程式碼使之在事件迴圈中延遲執稈,這樣就允許優先渲染程式介面,從而讓程式執行流暢。
AJAX 請求是一個很好的非同步程式設計的使用場景 。因為請求可能會花很長的時間,所以可以非同步執行它們,然後在客戶端等待資料返回的同時,執行其它程式碼。
// 假設使用 jQuery
jQuery.ajax({
url: `https://api.example.com/endpoint`,
success: function(response) {
// 當資料返回時候的程式碼
}
});
然而,這裡會產生一個問題-AJAX 請求是由瀏覽器網頁 API 進行處理的,可以非同步執行其它程式碼嗎?比如,假設成功回撥的程式碼是 CPU 密集型的:
var result = performCPUIntensiveCalculation();
如果 performCPUIntensiveCalculation
不是一個 HTTP 請求而是一個會阻塞介面渲染的程式碼(比如大量的 for
迴圈),這樣就沒有辦法釋放事件迴圈和瀏覽器的 UI-瀏覽器會被凍結住且失去響應。
這意味著,非同步函式只是是解決了一部分 JavaScript 的單執行緒限制。
在某些情況下,你可以通過使用 setTimeout
來很好地解決由於長時間計算所造成的 UI 阻塞。比如,通過把一個複雜的計算批量拆分為若干個setTimeout
呼叫 ,把它們放在事件迴圈的不同位置執行,然後這樣就可以使得 UI 有時間進行渲染及響應。
讓我們看一個計算數值陣列的平均值的簡單函式。
function average(numbers) {
var len = numbers.length,
sum = 0,
i;
if (len === 0) {
return 0;
}
for (i = 0; i < len; i++) {
sum += numbers[i];
}
return sum / len;
}
可以把以上程式碼重寫為模擬非同步:
function averageAsync(numbers, callback) {
var len = numbers.length,
sum = 0;
if (len === 0) {
return 0;
}
function calculateSumAsync(i) {
if (i < len) {
// 在事件迴圈中呼叫下一個函式
setTimeout(function() {
sum += numbers[i];
calculateSumAsync(i + 1);
}, 0);
} else {
// 到達陣列末尾,呼叫回撥
callback(sum / len);
}
}
calculateSumAsync(0);
}
這裡利用 setTimeout
函式在事件迴圈中循序新增每一次計算。在每一次計算之間,將會有充足的時間來進行其它的計算和解凍瀏覽器。
Web Workders 來救場
HTML5 給我們帶了很多開箱即用的好用的功能,包括:
- SSE(之前文章中提到過並且和 WebSockets 進行了比較)
- Geolocation
- Application cache
- Local Storage
- Drag and Drop
- Web Workers
Web Workers 是瀏覽器內建的執行緒所以可以被用來執行非阻塞事件迴圈的 JavaScript 程式碼。
屌爆了。整個 JavaScript 是基於單執行緒環境的而 Web Workers (部分)可以突破這方面的限制。
Web Workers 允許開發者把長時間執行和密集計算型的任務放在後臺執行而不會阻塞 UI,這會使得應用程式執行得更加流暢。另外,這樣就不用再使用 setTimeout
的黑科技來防止阻塞事件迴圈了。
這裡有一個展示使用和未使用 Web Workers 來進行陣列排序的區別的示例。
Web Workers 概覽
Web Workers 允許你做諸如執行處理 CPU 計算密集型任務的耗時指令碼而不會阻塞 UI 的事情。事實上,所有這些操作都是並行執行的。Web Workers 是真正的多執行緒。
你或許會有疑問-『難道 JavaScript 不是單執行緒的嗎?』。
當你意識到 JavaScript 是一門沒有定義執行緒模型的語言的時候,或許你會感覺非常的驚訝。Web Workers 並不是 JavaScript 的一部分,他們是可以通過 JavaScript 進行操作的瀏覽器功能之一。以前,大多數的瀏覽器是單執行緒的(當然,現在已經變了),而且大多數的 JavaScript 功能是在瀏覽器端實現完成的。Node.js 沒有實現 Web Workers -它有 『cluster』和 『child_process』的概念,這兩者和 Web Workers 有些許差異。
值得注意的是,規範中有三種型別的 Web Workers:
Dedicated Workers
Dedicated Web Workers 是由主程式例項化並且只能與之進行通訊
<center>Dedicated Workers 瀏覽器支援情況</center>
Shared Workers
Shared workers 可以被執行在同源的所有程式訪問(不同的瀏覽的選項卡,內聯框架及其它shared workers)。
<center>Shared Workers 瀏覽器支援情況</center>
Service Workers
Service Worker 是一個由事件驅動的 worker,它由源和路徑組成。它可以控制它關聯的網頁,解釋且修改導航,資源的請求,以及一種非常細粒度的方式來快取資源以讓你非常靈活地控制程式在某些情況下的行為(比如網路不可用)。
<center>Service Workers 瀏覽器支援情況</center>
本篇文章,我們將會專注於 Dedicated Workers 並以 『Web Workers』或者 『Workers』來稱呼它。
Web Workers 執行原理
Web Workers 是以載入 .js
檔案的方式實現的,這些檔案會在頁面中非同步載入。這些請求會被 Web Worker API 完全隱藏。
Workers 使用類執行緒的訊息傳輸-獲取模式。它們非常適合於為使用者提供最新的 UI ,高效能及流暢的體驗。
Web Workers 執行於瀏覽器的一個隔離執行緒之中。因此,他們所執行的程式碼必須被包含在一個單獨的檔案之中。請謹記這一特性。
讓我們看如何建立初始化 worker 吧:
var worker = new Worker(`task.js`);
如果 『task.js』檔案存在且可訪問,瀏覽器會生成一個執行緒來非同步下載檔案。當下載完成的時候,檔案會立即執行然後 worker 開始執行。萬一檔案不存在,worker 會執行失敗且沒有任何提示。
為了啟動建立的 worker,你需要呼叫 postMessage
方法:
worker.postMessage();
Web Worker 通訊
為了在 Web Worker 和 建立它的頁面間進行通訊,你得使用 postMessage
方法或者一個廣播通道。
postMessage 方法
最新的瀏覽器支援方法的第一引數為一個 JSON
物件而舊的瀏覽器只支援字串。
讓我們來看一個例子,通過往 worker 的方法的第一個引數傳入更為複雜的 JSON
物件來理解其建立者頁面是如何與之進行來回通訊的。傳入字串與之類似。
讓我們看下以下的 HTML 頁面(或者更準確地說是 HTML 頁面的一部分)
<button onclick="startComputation()">Start computation</button>
<script>
function startComputation() {
worker.postMessage({`cmd`: `average`, `data`: [1, 2, 3, 4]});
}
var worker = new Worker(`doWork.js`);
worker.addEventListener(`message`, function(e) {
console.log(e.data);
}, false);
</script>
worker 的指令碼如下:
self.addEventListener(`message`, function(e) {
var data = e.data;
switch (data.cmd) {
case `average`:
var result = calculateAverage(data); // 某個數值陣列中計算平均值的函式
self.postMessage(result);
break;
default:
self.postMessage(`Unknown command`);
}
}, false);
當點選按鈕,會在主頁面呼叫 postMessage
方法。
worker.postMessage
行程式碼會把包含 cmd
和 data
屬性及其各自屬性值的 JSON
物件傳入 worker。worker 通過定義監聽 message
事件來處理傳過來的訊息。
當接收到訊息的時候,worker 會執行實際的計算而不會阻塞事件迴圈。worker 會檢查傳進來的 e
事件,然後像一個標準的 JavaScript 函式那樣執行。當執行結束,傳回主頁面計算結果。
在 worker 的上下文中,self
和 this
都指向 worker 的全域性作用域。
有兩種方法來中斷 woker 的執行:主頁面中呼叫
worker.terminate()
或者在 workder 內部呼叫self.close()
廣播通道
Broadcast Channel 是更為普遍的通訊介面。它允許我們向共享同一個源的所有上下文傳送訊息。同一個源下的所有的瀏覽器選項卡,內聯框架或者 workers 都可以傳送和接收訊息:
// 連線到一個廣播通道
var bc = new BroadcastChannel(`test_channel`);
// 傳送簡單資訊示例
bc.postMessage(`This is a test message.`);
// 一個在控制檯列印訊息的簡單事件處理程式示例
// logs the message to the console
bc.onmessage = function (e) {
console.log(e.data);
}
// 關閉通道
bc.close()
視覺上看,你可以通過廣播通道的圖例以更加深刻的理解它。
<center>所有的瀏覽器上下文都是同源的</center>
然而,廣播通道瀏覽器相容性不太好:
訊息大小
有兩種向 Web Workers 傳送訊息的方法:
- 複製訊息:訊息被序列化,複製,然後傳送出去,接著在接收端反序列化。頁面和 worker 沒有共享一個相同的訊息例項,所以在每次傳遞訊息過程中最後的結果都是複製的。大多數瀏覽器是通過在任何一端自動進行 JSON 編碼/解碼訊息值來實現這一功能。正如所預料的那樣,這些對於資料的操作顯著增加了訊息傳送的效能開銷。訊息越大,傳送的時間越長。
- 訊息傳輸:這意味著最初的訊息傳送者一傳送即不再使用(<!–和導彈的發射後不管一樣–>)。資料傳輸非常的快。唯一的限制即只能傳輸 ArrayBuffer 資料物件。
Web Workers 的可用功能
由於 Web Workers 的多執行緒特性,它只能使用一部分 JavaScript 功能。以下是可使用的功能列表:
-
navigator
物件 -
location
物件(只讀) XMLHttpRequest
-
setTimeout()/clearTimeout()
和setInterval()/clearInterval()
- Application Cache
- 使用
importScripts
來引用外部指令碼 - 建立其它 web workers
Web Worker 的侷限性
令人沮喪的是,Web Workers 不能夠訪問一些非常關鍵的 JavaScript 功能:
- DOM(非執行緒安全的)
-
window
物件 -
document
物件 -
parent
物件
這意味著 Web Worker 不能夠操作 DOM(因此不能更新 UI)。有時候,這會讓人很蛋疼,不過一旦你學會如何合理地使 Web Workers,你就會把它當成單獨的『計算機器』來使用而用其它頁面程式碼來操作 UI。Workers 將會為你完成繁重的計算任務然後一旦任務完成,會把結果傳到頁面中並對介面進行必要的更新。
錯誤處理
和任何 JavaScript 程式碼一樣,你會想要處理 Web Workers 中的任何錯誤。當在 worker 執行過程中有錯誤發生的時候,會觸發 ErrorEvent
事件。這個介面包含三個有用的屬性來指出錯誤的地方:
- filename-引起錯誤的 worker 指令碼名稱
- lineno-引起錯誤的程式碼行數
- message-錯誤描述
示例:
function onError(e) {
console.log(`Line: ` + e.lineno);
console.log(`In: ` + e.filename);
console.log(`Message: ` + e.message);
}
var worker = new Worker(`workerWithError.js`);
worker.addEventListener(`error`, onError, false);
worker.postMessage(); // 啟動 worker 而不帶任何訊息
self.addEventListener(`message`, function(e) {
postMessage(x * 2); // 意圖錯誤. `x` 未定義
};
這裡,你可以看到我們建立了一個 worker 然後開始監聽 error
事件。
在 worker 中(在 workerWithError
中),我們通過未在作用域中定義的 x
乘以 2 來建立一個意圖錯誤。異常會傳播到初始化指令碼(即主頁面中)然後呼叫 onError 並傳入關於錯誤的資訊。
Web Workers 最佳使用場景
迄今為止,我們列舉了 Web Workers 的長處及其限制。讓我們看看他們的最佳使用場景:
- 射線追蹤:射線追蹤是一項通過追蹤光線的路徑作為畫素來生成圖片的渲染技術。Ray tracing 使用 CPU 密集型計算來模仿光線的路徑。思路即模仿一些諸如反射,折射,材料等的效果。所有的這些計算邏輯可以放在 Web Worker 中以避免阻塞 UI 執行緒。甚至更好的方法即-你可以輕易地把把圖片的渲染拆分在幾個 workers 中進行(即在各自的 CPU 中進行計算,意思是說利用多個 CPU 來進行計算,可以參考下 nodejs 的 api)。這裡有一個使用 Web Workers 來進行射線追蹤的簡單示例-https://nerget.com/rayjs-mt/r…。
- 加密:端到端的加密由於對保護個人和敏感資料日益嚴格的法律規定而變得越來越流行。加密有時候會非常地耗時,特別是如果當你需要經常加密很多資料的時候(比如,發往伺服器前加密資料)。這是一個使用 Web Worker 的絕佳場景,因為它並不需要訪問 DOM 或者利用其它魔法-它只是純粹使用演算法進行計算而已。一旦在 worker 進行計算,它對於使用者來說是無縫地且不會影響到使用者體驗。
- 預取資料:為了優化網站或者網路應用及提升資料載入時間,你可以使用 Workers 來提前載入部分資料以備不時之需。不像其它技術,Web Workers 在這種情況下是最棒噠,因為它不會影響程式的使用體驗。
- 漸進式網路應用:即使在網路不穩定的情況下,它們必須快速載入。這意味著資料必須本地儲存於瀏覽器中。這時候 IndexDB 及其它類似的 API 就派上用場了。大體上說,一個客戶端儲存是必須的。為了不阻塞 UI 執行緒的渲染,這項工作必須由 Web Workers 來執行。呃,當使用 IndexDB的時候,可以不使用 workers 而使用其非同步介面,但是之前它也含有同步介面(可能會再次引入 ),這時候就必須在 workers 中使用 IndexDB。
這裡需要注意的是在現代瀏覽器已經不支援同步介面了,具體可檢視這裡。
- 拼寫檢查:一個基本的拼寫檢測器是這樣工作的-程式會讀取一個包含拼寫正確的單詞列表的字典檔案。字典會被解析成一個搜尋樹以加快實際的文字搜尋。當檢查器檢查一個單詞的時候,程式會在預構建搜尋樹中進行檢索。如果在樹中沒有檢索到,則會通過提供替代的字元為使用者提供替代的拼寫並檢測單詞是否是有效-是否是使用者需要的單詞。這個檢索過程中的所有工作都可以交由 Web Worker 來完成,這樣使用者就只需輸入單詞和語句而不會阻塞 UI,與此同時 worker 會處理所有的搜尋和服務建議。
在 SessionStack 中對於我們來說效能和可靠性是至關重要的。之所以這麼重要的原因是一旦把 SessionStack 整合進網路應用,它就會開始收集從 DOM 變化,使用者互動到網路請求,未處理異常和除錯資訊的所有一切資訊。所有的資料都是即時傳輸到我們的伺服器的,這樣就允許你以視訊的方式重放網路應用中的所有問題以及觀察使用者端產生的一切問題。所有的一切都只會給你的程式帶來極小的延遲且沒有任何的效能開銷。
這就是為什麼我們使用 Web Workers 來處理監視庫和播放器的邏輯的原因,因為 Web Workers 會幫我們處理諸如使用雜湊來驗證資料完整性,渲染等 CPU 密集型的任務。
在這個網路技術日新月異的時代,我們更加努力地保證 SessionStack 輕巧且不會給使用者程式帶來任何效能影響。
擴充套件
實際工作過程會遇到使用者需要通過解析遠端圖片來獲得圖片 base64 的案例,那麼這時候,如果圖片非常大,就會造成 canvas 的 toDataURL
操作相當的耗時,從而阻塞頁面的渲染。
所以解決思路即把這裡的處理圖片的操作交由 worker 來處理。以下貼出主要的程式碼:
<!DOCTYPE html>
<html lang="zh-cn">
<head>
<meta charset="UTF-8">
<title>Canvas to base64</title>
</head>
<body>
<script>
function loadImageAsync(url) {
if (typeof url !== `string`) {
return Promise.reject(new TypeError(`must specify a string`));
}
return new Promise(function(resolve, reject) {
const image = new Image();
// 允許 canvas 跨域載入圖片
image.crossOrigin="anonymous";
image.onload = function() {
const $canvas = document.createElement(`canvas`);
const ctx = $canvas.getContext(`2d`);
const width = this.width;
const height = this.height;
let imageData;
$canvas.width = width;
$canvas.height = height;
ctx.drawImage(image, 0, 0, width, height);
imageData = ctx.getImageData(0, 0, $canvas.width, $canvas.height);
resolve({image, imageData});
};
image.onerror = function() {
reject(new Error(`Could not load image at ` + url));
};
image.src = url;
});
}
function blobToDataURL(blob) {
return new Promise((fulfill, reject) => {
let reader = new FileReader();
reader.onerror = reject;
reader.onload = (e) => fulfill(reader.result);
reader.readAsDataURL(blob);
})
}
document.addEventListener("DOMContentLoaded", function () {
loadImageAsync(`https://cdn-images-1.medium.com/max/1600/1*4lHHyfEhVB0LnQ3HlhSs8g.png`)
.then(function (image) {
// jpeg-web-worker.js https://github.com/kentmw/jpeg-web-worker
const worker = new Worker(`jpeg-web-worker.js`);
worker.postMessage({
image: image.imageData,
quality: 50
});
worker.onmessage = function(e) {
// e.data is the imageData of the jpeg. {data: U8IntArray, height: int, width: int}
// you can still convert the jpeg imageData into a blog like this:
const blob = new Blob( [e.data.data], {type: `image/png`} );
blobToDataURL(blob).then((imageURL) => {
console.log(`imageUrl:`, imageURL);
})
}
})
.catch(function (err) {
console.log(`Error:`, err.message);
});
});
</script>
</body>
</html>
以上是通過 canvas 來獲取圖片資料,那麼是否有其它方法呢?肯定有的啦,動下腦筋吧少年。
本系列持續更新中,Github 地址請查閱這裡。