這是探索 JavaScript 及其內建元件系列文章的第 7 篇。在認識和描述這些核心元素的過程中,我們也會分享我們在構建 SessionStack 時所遵循的一些經驗規則。SessionStack 是一個輕量級 JavaScript 應用,它協助使用者實時檢視和復現他們的 Web 應用缺陷,因此其自身不僅需要足夠健壯還要有不俗的效能表現。
如果你錯過了前面的文章,你可以在下面找到它們:
- 對引擎、執行時和呼叫棧的概述
- 深入 V8 引擎以及 5 個寫出更優程式碼的技巧
- 記憶體管理以及四種常見的記憶體洩漏的解決方法
- 事件迴圈和非同步程式設計的崛起以及 5 個如何更好的使用 async/await 編碼的技巧
- JavaScript 是如何工作的:深入剖析 WebSockets 和擁有 SSE 技術 的 HTTP/2,以及如何在二者中做出正確的選擇
- JavaScript 工作原理:與 WebAssembly 一較高下 + 為何 WebAssembly 在某些情況下比 JavaScript 更為適用
這一次我們將剖析 Web Worker:對它進行簡單概述後,我們將分別討論不同型別的 Worker 以及它們內部元件的運作方法,同時也會以場景為例說明它們各自的優缺點。在文章的最後,我們將講解最適合使用 Web Worker 的 5 個場景。
我們在 之前的文章 中已經詳盡地討論了 JavaScript 的單執行緒執行機制,對此你應當已經瞭然於胸。然而,JavaScript 是允許開發者在單執行緒模型上書寫非同步程式碼的。
非同步程式設計的 “天花板”
我們已經討論過了 非同步程式設計 的概念及其使用場景。
非同步程式設計通過把部分程式碼 “放置” 到事件迴圈較後的時間點執行,保證了 UI 渲染始終處於較高的優先順序,這樣你的 UI 就不會出現卡頓無響應的情況。
AJAX 請求是非同步程式設計的最佳實踐之一。通常網路請求不會在短時間內得到響應,因此非同步的網路請求能讓客戶端在等待響應結果的同時執行其他業務程式碼。
// 假設你使用了 jQuery
jQuery.ajax({
url: 'https://api.example.com/endpoint',
success: function(response) {
// 正確響應後需要執行的程式碼
}
});
複製程式碼
當然這裡有個問題,上例能夠進行非同步請求是依靠了瀏覽器提供的 API,其他程式碼又該如何實現非同步執行呢?例如,在上例 success 回撥函式中存在 CPU 密集型計算:
var result = performCPUIntensiveCalculation();
複製程式碼
假如 performCPUIntensiveCalculation
不是一個 HTTP 請求,而是一段可以阻塞執行緒的程式碼(例:一段巨型 for
迴圈程式碼)。這樣會使 event loop 不堪重負,瀏覽器 UI 也隨之阻塞 —— 使用者將面對卡頓無響應的網頁。
這就說明了使用非同步函式只能解決 JavaScript 單執行緒模型帶來的一小部分問題。
在一些因大量計算引起的 UI 阻塞問題中,使用 setTimeout
來解決阻塞的效果還不錯。例如,我們可以把一系列的複雜計算分批放到單獨的 setTimeout
中執行,這樣做等於是把連續的計算分散到了 event loop 中的不同位置,以此為 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) {
// 把下一次函式呼叫放入 event loop
setTimeout(function() {
sum += numbers[i];
calculateSumAsync(i + 1);
}, 0);
} else {
// 計算完陣列中所有元素後,呼叫回撥函式返回結果
callback(sum / len);
}
}
calculateSumAsync(0);
}
複製程式碼
通過使用 setTimeout
可以把每一步計算都放置到 event loop 較後的時間點執行。在每兩次的計算間隔,event loop 便會有足夠的時間執行其他計算,從而保證瀏覽器不會一 ”凍“ 不動。
拯救你於水火之中的 Web Worker
HTML5 已經提供了不少開箱即用的好東西,包括:
- SSE (在 上一篇文章 中已經談過它的特性並與 WebSocket 進行了對比)
- 地理資訊
- 應用快取
- LocalStorage
- 拖放手勢
- Web Worker
Web Worker 是內建在瀏覽器中的輕量級 執行緒,使用它執行 JavaScript 程式碼不會阻塞 event loop。
非常神奇吧,本來 JavaScript 中的所有範例都是基於單執行緒模型實現的,但這裡的 Web Worker 卻(在一定程度上)突破了這一限制。
從此開發者可以遠離 UI 阻塞的困擾,通過把一些執行時間長、計算密集型的任務放到後臺交由 Web Worker 完成,使他們的應用響應變得更加迅速。更重要的是,我們再也不需要對 event loop 施加任何的 setTimeout
黑魔法。
這裡有一個簡單的陣列排序 demo ,其中對比了使用 Web Worker 和不使用 Web Worker 時的區別。
Web Worker 概覽
Web Worker 允許你在執行大量計算密集型任務時,還不阻塞 UI 程式。事實上,二者互不不阻塞的原因就是它們是並行執行的,可以看出 Web Worker 是貨真價實的多執行緒。
你可能想說 — ”JavaScript 不是一個在單執行緒上執行的語言嗎?“。
你可能會驚訝 JavaScript 作為一門程式語言,卻沒有定義任何的執行緒模型。因此 Web Worker 並不屬於 JavaScript 語言的一部分,它僅僅是瀏覽器提供的一項特性,只是它可以被 JavaScript 訪問、呼叫罷了。過往的眾多瀏覽器都是單執行緒程式(以前的理所當然,現在也有了些許變化),並且瀏覽器一直以來也是 JavaScript 主要的執行環境。對比在 Node.JS 中就沒有 Web Worker 的相關實現 — 雖然 Web Worker 對應著 Node.JS 中的 “cluster” 或 “child_process” 概念,不過它們還是有所區別的。
值得注意的是,Web Worker 的 定義 中一共包含了 3 種型別的 Worker:
Dedicated Worker(專用 Worker)
Dedicated Worker 由主執行緒例項化且只能與它通訊。
Dedicated Worker 瀏覽器相容性一覽
Shared Worker(共享 Worker)
Shared Worker 可以被同一域(瀏覽器中不同的 tab、iframe 或其他 Shared Worker)下的所有執行緒訪問。
Shared Worker 瀏覽器相容一覽
Service Worker(服務 Worker)
Service Worker 是一個事件驅動型 Worker,它的初始化註冊需要網頁/站點的 origin 和路徑資訊。一個註冊好的 Service Worker 可以控制相關網頁/網站的導航、資源請求以及進行粒度化的資源快取操作,因此你可以極好地控制應用在特定環境下的表現(如:無網路可用時)。
Service Worker 瀏覽器相容一覽
在本文中,我們主要討論 Dedicated Worker,後文的 ”Web Worker“ 或 “Worker” 都預設指代它。
Web Worker 工作原理
最終實現 Web Worker 的是一堆 .js
檔案,網頁會通過非同步 HTTP 請求來載入它們。當然 Web Worker API 已經包辦了這一切,上述載入對使用者完全無感。
Worker 利用類似執行緒的訊息機制保持了與主執行緒的平行,它是提升你應用 UI 體驗的不二人選,使用 Worker 保證了 UI 渲染的實時性、高效能和快速響應。
Web Worker 是執行在瀏覽器內部的一條獨立執行緒,因此需要使用 Web Worker 執行的程式碼塊也必須存放在一個 獨立檔案 中。這一點需要牢記在心。
讓我們看看,如何建立一個基礎 Worker:
var worker = new Worker('task.js');
複製程式碼
如果此處的 “task.js” 存在且能被訪問,那麼瀏覽器會建立一個新的執行緒去非同步地下載原始碼檔案。一旦下載完成,程式碼將立刻執行,此時 Worker 也就開始了它的工作。 如果提供的程式碼檔案不存在返回 404,那麼 Worker 會靜默失敗並不丟擲異常。
為了啟動建立好的 Worker,你需要顯式地呼叫 postMessage
方法:
worker.postMessage();
複製程式碼
Web Worker 通訊
為了使建立好的 Worker 和建立它的頁面能夠通訊,你需要使用 postMessage
方法或 Broadcast Channel(廣播通道).
使用 postMessage 方法
在較新的瀏覽器中,postMessage 方法支援 JSON
物件作為函式的第一個入參,但是在舊版本瀏覽器中它還是隻支援 string
。
下面的 demo 會展示 Worker 是如何與建立它的頁面進行通訊的,同時我們將使用 JSON 物件作為通訊體好讓這個 demo 看起來稍微 “複雜” 一點。若改為傳遞字串,方法也不言而喻了。
讓我們看看下面的 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);
複製程式碼
當主頁面中的 button 被按下,觸發呼叫了 postMessage
方法。worker.postMessage
這行程式碼會傳遞一個 JSON
物件給 Worker,物件中包含了 cmd
和 data
兩個鍵以及它們對應的值。相應的,Worker 會通過定義的 message
響應方法拿到和處理上面傳遞過來的訊息內容。
當訊息到達 Worker 後,實際的計算便開始執行,這樣完全不會阻塞 event loop。在此過程中,Worker 只會檢查傳遞來的事件 e
,然後像往常執行 JavaScript 函式一樣繼續執行。當最終執行完成,執行結果會回傳回主頁面。
在 Worker 的執行上下文中,self
和 this
都指向 Worker 的全域性作用域。
有兩種停止 Worker 的方法:1、在主頁面中顯示地呼叫
worker.terminate()
;2、在指令碼中呼叫self.close()
讓 Worker 自行了斷。
Broadcast Channel(廣播通道)
Broadcast Channel 是更純粹地為通訊而生的 API。它允許我們在同域下的所有的上下文中傳送和接收訊息,包括瀏覽器 tab、iframe 和 Worker:
// 建立一個到 Broadcast Channel 的連線
var bc = new BroadcastChannel('test_channel');
// 傳送一段簡單的訊息
bc.postMessage('This is a test message.');
// 這是一個簡單的事件 handler
// 我們會在 handler 中接收並列印訊息到終端
bc.onmessage = function (e) {
console.log(e.data);
}
// 斷開與 Broadcast Channel 的連線
bc.close()
複製程式碼
下圖會幫助你理解 Broadcast Channel 的工作原理:
使用 Broadcast Channel 會有更嚴格的瀏覽器相容限制:
訊息的大小
一共有 2 種給 Web Worker 傳送訊息的方法:
- 拷貝訊息: 這種方法下訊息會被序列化、拷貝然後再傳送出去,接收方接收後則進行反序列化取得訊息。因此上例中的頁面和 Worker 不會共享同一個訊息例項,它們之間每傳送一次訊息就會多建立一個訊息副本。大多數瀏覽器都採用這樣的傳送方法,並且會在傳送和接收端自動進行 JSON 編碼/解碼。如你所預料的,這些資料處理會給訊息傳送帶來不小的負擔。傳送的訊息越大,時間開銷就越大。
- 傳遞訊息: 使用這種方法意味著訊息傳送者一旦成功傳送訊息後,就再也無法使用發出的訊息資料了。訊息的傳送幾乎不耗費任何時間,美中不足的是隻有 ArrayBuffer 支援以這種方式傳送。
Web Worker 中支援的 JavaScript 特性
因為 Web Worker 的多執行緒天性使然,它只能使用 一小撮 JavaScript 提供的特性,列表如下:
navigator
物件location
物件(只讀)XMLHttpRequest
setTimeout()/clearTimeout()
與setInterval()/clearInterval()
- 應用快取
- 使用
importScripts()
引入外部 script - 建立其他的 Web Worker
Web Worker 的侷限性
令人遺憾的是 Web Worker 無法訪問一些非常重要的 JavaScript 特性:
- DOM 元素(訪問不是執行緒安全的)
window
物件document
物件parent
物件
這意味著 Web Worker 不能做任何的 DOM 操作(也就是 UI 層面的工作)。剛開始這會顯得略微棘手,不過一旦你學會了如何正確使用 Web Worker。你就只會把 Web Worker 用作單獨的 ”計算機器“,而把所有的 UI 操作放到頁面程式碼中。你可以把所有的髒活累活都交給 Web Worker 完成,再將它勞作的結果傳到頁面並在那裡進行必要的 UI 操作。
異常處理
像對待任何 JavaScript 程式碼一樣,你希望處理 Web Worker 丟擲的任何錯誤。當 Worker 在執行時發生錯誤,它會觸發 ErrorEvent
事件。該介面包含 3 個有用的屬性,它們能幫助你定位程式碼出錯的原因:
- filename - 發生錯誤的 script 檔名
- 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
事件。
通過使用一個在作用域內未定義的變數 x
作乘法,我們在 Worker 內部(workerWithError.js
檔案內)故意製造了一個異常。這個異常會被傳遞到最初建立 Worker 的 scrpit 中,同時呼叫 onError
函式。
Web Worker 的最佳實踐
到此為止我們已經見識了 Web Worker 的強悍與不足,下面就一起來看看最適合使用它的場景有哪些:
-
光線追蹤(Ray Tracing)::光線追蹤屬於計算機圖形學中的 渲染(Rendering) 技術,它會追蹤並轉換光線 的軌跡為一個個畫素點,最終生成一張完整的圖片。為模擬光線的軌跡,光線追蹤需要 CPU 進行大量的數學計算。光線追蹤包括模擬光的反射、折射及物質效果等。以上所有的計算邏輯都可以交給 Web Worker 完成,從而不阻塞 UI 執行緒的執行。或者更好的方案是使用多個 Worker (以及多個 CPU)來完成圖片渲染。這有一個使用 Web Worker 進行光線追蹤的 demo — nerget.com/rayjs-mt/ra….
-
加密: 針對個人敏感資料的保護條例變得日益嚴格,端對端的資料加密也變得更為流行。當程式中需要經常加密大量資料時(如向伺服器傳送資料),加密成為了非常耗時的工作。Web Worker 可以非常好的切入此類場景,因為這裡不涉及任何的 DOM 操作,Worker 中僅僅執行一些專為加密的演算法。Worker 會勤懇地默默工作,絲毫不會打擾使用者,也絕不會影響使用者的體驗。
-
資料預獲取: 為優化你的網站或 web 應用的資料載入時長,你可以使用 Web Worker 預先獲取一些資料,儲存起來以備後續使用。Web Worker 在這裡發揮著重要作用,因為它絕不會影響應用的 UI 體驗,若不使用 Web Worker 情況會變得異常糟糕。
-
Progressive Web App: 當網路狀態不是很理想時,你仍需保證 PWA 有較快的載入速度。這就意味著 PWA 的資料需要被持久化到本地瀏覽器中。在此背景下,一些與 IndexDB 類似的 API 便應運而生了。從根本上來說,客戶端一側需要有資料儲存能力。為保證存取時不阻塞 UI 執行緒,這部分工作理應交給 Web Worker 完成。好吧,在 IndexDB 中你可以不使用 Web Worker,因為它提供的非同步 API 同樣不會阻塞 UI。但是在這之前,IndexDB 提供的是同步API(可能會被再次引入),這種情況使用 Web Worker 還是非常有必要的。
-
拼寫檢查: 進行拼寫檢查的基本流程如下 — 程式首先從詞典檔案中讀取一系列拼寫正確的單詞。整個詞典的單詞會被解析為一個搜尋樹用於實際的文字搜尋。當待測詞語被輸入後,程式會檢查已建立的搜尋樹中是否存在該詞。如果在搜尋樹中沒有匹配到待測詞語,程式會替換字元組成新的詞語,並測試新的詞語是否是使用者期待輸入的,如果是則會返回該詞語。整個檢測過程可以被輕鬆 “下放” 給 Web Worker 完成,Worker 會完成所有的詞語檢索和詞語聯想工作,這樣一來使用者的輸入就不會阻塞 UI 了。
對 SessionStack 來說,保持高效能和高可靠性是極其重要的. 持有這種理念的主要原因是,一旦你的應用整合 SessionStack 後,它會開始記錄從 DOM 變化、使用者互動行為到網路請求、未捕獲異常和 debug 資訊的所有資料。收集到的跟蹤資料會被 實時 傳送到後臺伺服器,以視訊的形式向你還原應用中出現的問題,幫助你從使用者的角度重現錯誤現場。這一切功能的實現需要足夠的快並且不能給你的應用帶來任何效能上的負擔。
這就是為什麼我們儘可能地把 SessionStack 中,值得優化的業務邏輯交給 Web Worker 完成。諸如在核心監控庫和播放器中,都包含了像 hash 資料完整性驗證、渲染等 CPU 密集型任務,這些都是值得使用 Web Worker 優化的地方。
Web 技術持續向前變更和發展,所以我們寧肯先行一步也要保證 SessionStack 是一個不會給使用者 app 帶來任何效能損耗的輕量級應用。
如果閣下願意試試 SessionStack ,這裡有一個免費的試用計劃。
參考資料
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。