網路工作人員的基本概念

問題: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,例如 FileBlobArrayBuffer 和 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 的結構定義中,selfthis 都會參照工作站的全域範圍。因此,上一個範例也可以寫成:

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.jsscript2.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 FileBlob 物件中的資料。例如:

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 有關的用途,請參閱工作站總覽

試聽帶

參考資料