問題:JavaScript 並行
有許多瓶頸會阻止將感興趣的應用程式移植到用戶端 JavaScript (例如需要大量伺服器的實作)。包括瀏覽器相容性、靜態輸入、無障礙功能和效能。幸好,瀏覽器廠商很快就能加快 JavaScript 引擎的執行速度,因此這個方法很快就成為過去式。
不過還有一項還是阻礙 JavaScript 的障礙,那就是語言本身。JavaScript 是單一執行緒環境,代表多個指令碼無法同時執行。例如,假設網站需要處理 UI 事件、查詢和處理大量 API 資料,以及操控 DOM。很常見,對吧?很抱歉,由於瀏覽器的 JavaScript 執行階段限制,上述所有操作無法同時進行。指令碼會在單一執行緒中執行。
開發人員使用 setTimeout()
、setInterval()
、XMLHttpRequest
和事件處理常式等技術模仿「並行」。是的,上述所有功能都是非同步執行,但非封鎖功能不一定代表並行。系統產生目前執行的指令碼後,才會處理非同步事件。好消息是 HTML5 能為我們帶來比這些駭客更棒的東西!
網路工作處理序簡介:將執行緒導入 JavaScript
網路工作站規格定義了可在網頁應用程式中產生背景指令碼的 API。網路工作站可讓您執行長時間執行的指令碼來處理大量運算工作,但不會封鎖 UI 或其他指令碼來處理使用者互動。觀眾將能理解我們大家會喜歡的「無回應指令碼」對話方塊:
工作站會使用類似執行緒的訊息傳遞,達成平行處理。這些 API 很適合用來保持使用者介面重新整理、高效能及提供回應。
網路工作程式類型
值得注意的是,規格討論了兩種網路工作站:專屬工作站和共用工作站。本文僅說明專屬工作站。我稱之為「網路工作者」或「工作站」。
開始使用
Web Worker 會在獨立的執行緒中執行。因此,這些程式碼執行的程式碼必須納入個別檔案中。不過,在此之前,請先在主頁面中建立新的 Worker
物件。建構函式會採用工作站指令碼的名稱:
var worker = new Worker('task.js');
如果指定的檔案存在,瀏覽器就會產生新的背景工作執行緒,並以非同步方式下載。檔案完成下載並執行後,工作站才會啟動。如果工作站的路徑傳回 404,則工作站會失敗,而且不會顯示相關通知。
建立 worker 後,請呼叫 postMessage()
方法來啟動 worker:
worker.postMessage(); // Start the worker.
透過訊息傳遞的方式與工作站通訊
工作與上層頁面之間的通訊會使用事件模型和 postMessage()
方法。視您的瀏覽器/版本而定,postMessage()
可接受字串或 JSON 物件做為單一引數。
最新版本的新版瀏覽器支援傳送 JSON 物件。
以下範例說明如何使用字串將「Hello World」傳送至 doWork.js 中的 worker。worker 只會傳回要傳遞的訊息。
主指令碼:
var worker = new Worker('doWork.js');
worker.addEventListener('message', function(e) {
console.log('Worker said: ', e.data);
}, false);
worker.postMessage('Hello World'); // Send data to our worker.
doWork.js (工作站):
self.addEventListener('message', function(e) {
self.postMessage(e.data);
}, false);
從主頁面呼叫 postMessage()
時,工作站會為 message
事件定義 onmessage
處理常式來處理這則訊息。您可以在 Event.data
中存取訊息酬載 (在本案例中為「Hello World」)。雖然這個特定範例不太令人興奮,但也證明瞭 postMessage()
也是將資料傳回主執行緒的方式。便利!
在主頁面和 worker 之間傳送的訊息會複製下來,但不會共用。例如,在下一個範例中,這兩個位置皆可存取 JSON 訊息的 'msg' 屬性。顯然物件是在獨立的專屬空間中執行,卻會直接傳遞至工作站。事實上,這到底是在把物件交給工作站的過程中進行序列化,然後又在另一端還原序列化。頁面和 worker 不會共用相同的執行個體,因此最終結果就是每次傳遞都會建立重複項目。多數瀏覽器在導入這項功能時,會自動在任一端自動編碼/解碼該值。
以下是使用 JSON 物件傳遞訊息的複雜範例。
主指令碼:
<button onclick="sayHI()">Say HI</button>
<button onclick="unknownCmd()">Send unknown command</button>
<button onclick="stop()">Stop worker</button>
<output id="result"></output>
<script>
function sayHI() {
worker.postMessage({'cmd': 'start', 'msg': 'Hi'});
}
function stop() {
// worker.terminate() from this script would also stop the worker.
worker.postMessage({'cmd': 'stop', 'msg': 'Bye'});
}
function unknownCmd() {
worker.postMessage({'cmd': 'foobard', 'msg': '???'});
}
var worker = new Worker('doWork2.js');
worker.addEventListener('message', function(e) {
document.getElementById('result').textContent = e.data;
}, false);
</script>
doWork2.js:
self.addEventListener('message', function(e) {
var data = e.data;
switch (data.cmd) {
case 'start':
self.postMessage('WORKER STARTED: ' + data.msg);
break;
case 'stop':
self.postMessage('WORKER STOPPED: ' + data.msg +
'. (buttons will no longer work)');
self.close(); // Terminates the worker.
break;
default:
self.postMessage('Unknown command: ' + data.msg);
};
}, false);
可轉移物件
大多數瀏覽器都採用結構化複製演算法,您可以將更複雜的類型傳入或傳出 worker,例如 File
、Blob
、ArrayBuffer
和 JSON 物件。不過,使用 postMessage()
傳遞這類型的資料時,仍會建立副本。因此,如果您傳送了一個大型的 50 MB 檔案,在背景工作執行緒和主要執行緒之間取得該檔案會產生明顯的負擔。
結構化複製是很好的做法,但複製作業可能需要數百毫秒的時間。如要對抗效能命中,您可以使用可轉移的物件。
利用可移轉的物件,資料會在內容之間轉移。它為零副本,可大幅提升傳送資料給 worker 的效能。如果您是來自 C/C++ 世界,可以將這視為傳遞參考。然而,與即時參照功能不同的是,將呼叫結構定義中的「版本」轉移至新結構定義後,就不再提供使用。例如,將 ArrayBuffer 從主要應用程式轉移至 Worker 時,原始 ArrayBuffer
會遭到清除,且無法再使用。其內容會 (靜止) 轉移至 Worker 結構定義。
如要使用可轉移的物件,請使用稍微不同的 postMessage()
簽名:
worker.postMessage(arrayBuffer, [arrayBuffer]);
window.postMessage(arrayBuffer, targetOrigin, [arrayBuffer]);
工作站案例的第一個引數是資料,第二個引數是應轉移項目的清單。順帶一提,第一個引數不一定要是 ArrayBuffer
。例如,它可以是 JSON 物件:
worker.postMessage({data: int8View, moreData: anotherBuffer},
[int8View.buffer, anotherBuffer]);
重點在於:第二個引數必須是 ArrayBuffer
的陣列。這是你的可轉移物品清單。
如要進一步瞭解可轉移資源,請參閱 developer.chrome.com 貼文。
工作站環境
工作站範圍
在 worker 的結構定義中,self
和 this
都會參照工作站的全域範圍。因此,上一個範例也可以寫成:
addEventListener('message', function(e) {
var data = e.data;
switch (data.cmd) {
case 'start':
postMessage('WORKER STARTED: ' + data.msg);
break;
case 'stop':
...
}, false);
您也可以直接設定 onmessage
事件處理常式 (不過 JavaScript ninja 一律建議使用 addEventListener
)。
onmessage = function(e) {
var data = e.data;
...
};
工作站可用的功能
基於多執行緒的行為,網路工作處理程序只能使用 JavaScript 的部分功能:
navigator
物件location
物件 (唯讀)XMLHttpRequest
setTimeout()/clearTimeout()
和setInterval()/clearInterval()
- 應用程式快取
- 使用
importScripts()
方法匯入外部指令碼 - 啟動其他網路工作站
工作站「無法」存取:
- DOM (不安全執行緒)
window
物件document
物件parent
物件
正在載入外部指令碼
您可以使用 importScripts()
函式,將外部指令碼檔案或程式庫載入工作站。此方法會使用零或多個字串,代表待匯入資源的檔案名稱。
以下範例會將 script1.js
和 script2.js
載入工作站:
worker.js:
importScripts('script1.js');
importScripts('script2.js');
它也可以寫成單一匯入陳述式:
importScripts('script1.js', 'script2.js');
子工作站
員工能夠產生子工作站。這很適合用於在執行階段進一步分割大型工作。不過,子工作人員有以下幾點注意事項:
- 子工作站所在的來源必須與上層網頁相同。
- 子工作站中的 URI 會根據其母工作站的位置 (而非主頁面) 進行解析。
請注意,大部分的瀏覽器會為每個工作站產生個別的程序。開始製造工作站工廠前,請留意擁擠的使用者系統資源是否過多。會發生這種情況的其中一個原因是,在主頁面與 worker 之間傳送的訊息是複製而來,而非共用。請參閱透過訊息傳遞功能與員工溝通。
如需如何產生子工作站的範例,請參閱規格中的範例。
內嵌工作站
如果想要即時建立工作站指令碼,或想建立獨立頁面,但又不想建立獨立的工作站檔案,該怎麼做?透過 Blob()
,您只要建立一個工作站程式碼的網址控制代碼做為字串,即可在與主要邏輯相同的 HTML 檔案中「內嵌」 worker:
var blob = new Blob([
"onmessage = function(e) { postMessage('msg from worker'); }"]);
// Obtain a blob URL reference to our worker 'file'.
var blobURL = window.URL.createObjectURL(blob);
var worker = new Worker(blobURL);
worker.onmessage = function(e) {
// e.data == 'msg from worker'
};
worker.postMessage(); // Start the worker.
Blob 網址
呼叫 window.URL.createObjectURL()
可以發揮強大功能。這個方法會建立簡單的網址字串,用來參照儲存在 DOM File
或 Blob
物件中的資料。例如:
blob:http://localhost/c745ef73-ece9-46da-8f66-ebes574789b1
Blob 網址在應用程式的生命週期內均不重複,而且在應用程式的生命週期內 (例如直到 document
卸載為止)。如果您要建立多個 Blob 網址,建議您發布不再需要的參照。您可以將 Blob 網址傳送至 window.URL.revokeObjectURL()
,藉此明確釋出網址:
window.URL.revokeObjectURL(blobURL);
Chrome 中的是可檢視所有已建立 blob 網址的正確頁面:chrome://blob-internals/
。
完整範例
我們再來一點,就能更清楚地瞭解 worker 的 JS 程式碼在網頁中內嵌的方式。此技巧使用 <script>
標記來定義工作站:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<div id="log"></div>
<script id="worker1" type="javascript/worker">
// This script won't be parsed by JS engines
// because its type is javascript/worker.
self.onmessage = function(e) {
self.postMessage('msg from worker');
};
// Rest of your worker code goes here.
</script>
<script>
function log(msg) {
// Use a fragment: browser will only render/reflow once.
var fragment = document.createDocumentFragment();
fragment.appendChild(document.createTextNode(msg));
fragment.appendChild(document.createElement('br'));
document.querySelector("#log").appendChild(fragment);
}
var blob = new Blob([document.querySelector('#worker1').textContent]);
var worker = new Worker(window.URL.createObjectURL(blob));
worker.onmessage = function(e) {
log("Received: " + e.data);
}
worker.postMessage(); // Start the worker.
</script>
</body>
</html>
我認為這個新做法比較簡單,也更加清楚易懂。
該定義使用 id="worker1"
和 type='javascript/worker'
定義指令碼標記 (因此瀏覽器不會剖析 JS)。系統會使用 document.querySelector('#worker1').textContent
將程式碼擷取為字串,並傳遞至 Blob()
以建立檔案。
正在載入外部指令碼
使用這些技術內嵌您的工作站程式碼時,您必須提供絕對 URI,importScripts()
才能運作。如果您嘗試傳送相對 URI,瀏覽器將會發生安全性錯誤。原因:工作站 (現在從 blob 網址建立) 將以 blob:
前置字串解析,而您的應用程式則會透過其他 (假設為 http://
) 配置執行。因此失敗原因是跨來源限制。
如要在內嵌工作站中使用 importScripts()
,其中一種方法是將主指令碼目前的網址傳遞至內嵌工作站,並手動建構絕對網址,藉此「插入」主指令碼目前的網址。這可確保外部指令碼是從相同來源匯入。假設主要應用程式是透過 http://example.com/index.html
執行:
...
<script id="worker2" type="javascript/worker">
self.onmessage = function(e) {
var data = e.data;
if (data.url) {
var url = data.url.href;
var index = url.indexOf('index.html');
if (index != -1) {
url = url.substring(0, index);
}
importScripts(url + 'engine.js');
}
...
};
</script>
<script>
var worker = new Worker(window.URL.createObjectURL(bb.getBlob()));
worker.postMessage(<b>{url: document.location}</b>);
</script>
處理錯誤
就像處理任何 JavaScript 邏輯一樣,您會需要處理網路工作站中擲回的任何錯誤。如果 worker 執行期間發生錯誤,系統會觸發 ErrorEvent
。介麵包含三個實用屬性,可協助找出問題所在:filename
- 造成錯誤的工作站指令碼名稱,lineno
- 發生錯誤的行號,以及 message
- 是有意義的錯誤說明。以下範例說明如何設定 onerror
事件處理常式來輸出錯誤屬性:
<output id="error" style="color: red;"></output>
<output id="result"></output>
<script>
function onError(e) {
document.getElementById('error').textContent = [
'ERROR: Line ', e.lineno, ' in ', e.filename, ': ', e.message
].join('');
}
function onMsg(e) {
document.getElementById('result').textContent = e.data;
}
var worker = new Worker('workerWithError.js');
worker.addEventListener('message', onMsg, false);
worker.addEventListener('error', onError, false);
worker.postMessage(); // Start worker without a message.
</script>
範例:workerWithError.js 會嘗試執行 1/x,其中 x 未定義。
// 待辦事項:DevSite - 因使用內嵌事件處理常式而移除的程式碼範例
workerWithError.js:
self.addEventListener('message', function(e) {
postMessage(1/x); // Intentional error.
};
安全性宣言
本機存取權的限制
Google Chrome 的安全性限制,因此工作站無法在最新版本的瀏覽器本機上執行 (例如來自 file://
)。而是在不通知的情況下失敗!如要從 file://
配置執行應用程式,請在設定 --allow-file-access-from-files
旗標的情況下執行 Chrome。
其他瀏覽器沒有相同的限制。
同源考量
工作站指令碼必須是外部檔案,採用與呼叫頁面相同的配置。因此,您無法從 data:
網址或 javascript:
網址載入指令碼,而且 https:
頁面無法啟動以 http:
網址開頭的工作站指令碼。
用途
那麼,哪種應用程式會使用網路工作程式?以下提供幾個有助於腦力激盪的秘訣:
- 預先擷取及/或快取資料以供日後使用。
- 程式碼語法醒目顯示或其他即時文字格式設定。
- 拼字檢查。
- 分析影片或音訊資料。
- 網路服務的背景 I/O 或輪詢。
- 處理大型陣列或粗俗的 JSON 回應。
<canvas>
中的圖片篩選功能。- 更新本機網路資料庫的許多資料列。
如要進一步瞭解與 Web Workers API 有關的用途,請參閱工作站總覽。
試聽帶
參考資料
- Web Workers 規格
- 「Using WebWorker」一節。
- Dev.Opera 的「網路工作人員大上升!」