淺談 HTML5 Web Worker,性能優化利器?

多線程是現代軟件開發中用於增強應用的性能和響應能力的重要技術。然而,JavaScript 是一門單線程語言,它天生是不支持多線程的。爲了克服這一限制,引入了 Web Workers。本文就來探討 Web Workers 對 Web 多線程的重要性,以及使用它們的限制和注意事項。

Web Workers 概述

概念

Web Workers 是現代 Web 開發的一項強大功能,於 2009 年作爲 HTML5 規範的一部分引入。它們旨在提供一種在後臺執行 JavaScript 代碼的方法,與網頁的主執行線程分離,以提高性能和響應能力。

由於 JavaScript 是單線程的,當執行比較耗時的任務時,就會阻塞主線程並導致頁面無法響應,這就是 Web Workers 發揮作用的地方。它允許在一個單獨的線程(稱爲工作線程)中執行耗時的任務。這使得 JavaScript 代碼可以在後臺執行,而不會阻塞主線程並導致頁面無響應。

Web Worker 是一個作爲後臺線程運行的腳本,具有自己的引擎實例和事件循環。它與主執行線程並行運行,並且不會阻塞事件循環。

主線程(或工作線程本身)可以啓動任意數量的工作線程。生成 worker 腳本:

  1. 主線程(或另一個工作線程)向新工作線程發送一條消息,其中包含所有必要的數據。

  2. 工作線程中的事件處理程序執行並開始處理數據。

  3. 完成(或失敗)時,工作線程將一條帶有計算結果的消息發送回主線程。

  4. 主線程中的事件處理程序執行、解析傳入結果並運行必要的操作(例如顯示值)。

區別

那 Web Workers 與主線程有什麼區別呢?

Web Workers 和主線程之間的一個關鍵區別是 Web Workers 沒有訪問 DOM 或 UI 的權限。這意味着它不能直接操作頁面上的 HTML 元素或與用戶交互。實際上,Web Workers 被設計用於執行不需要直接訪問 UI 的任務,例如數據處理、圖像操作或計算。

另一個區別是,Web Workers 被設計爲在與主線程分離的沙箱環境中運行,這意味着它們對系統資源的訪問受到限制,並且不能訪問某些 API,如localStoragesessionStorage API。不過,它可以通過消息傳遞系統與主線程進行通信,從而允許兩個線程之間交換數據。

重要性

Web Workers 爲開發人員提供了在 Web 上實現多線程的方式,這對於構建高性能的 Web 應用至關重要。通過將耗時的任務在後臺獨立於主線程中執行,Web Workers 提高了網頁的整體響應性,並使用戶體驗更加流暢。以下是 Web Workers 在 Web 多線程中的重要性和好處:

通過資源利用率

通過允許耗時任務在後臺執行,Web Workers 更有效地利用系統資源,實現更快速和高效的數據處理,並提高整體性能。這對於涉及大量數據處理或圖像操作的 Web 應用尤爲重要,因爲 Web Workers 可以在不影響用戶界面的情況下執行這些任務。

增加穩定性和可靠性

通過將耗時任務隔離到單獨的 worker 線程中,Web Workers 幫助防止在主線程上執行大量代碼時發生崩潰和錯誤。這使得開發人員更容易編寫穩定可靠的 Web 應用,減少用戶的煩惱和數據丟失的可能性。

增強安全性

Web Workers 在與主線程分離的隔離環境中運行,這有助於提高 Web 應用的安全性。此隔離防止惡意代碼訪問或修改主線程或其他 Web Workers 中的數據,降低數據泄露或其他安全漏洞的風險。

更好的資源利用率

Web Workers 可以通過將耗時計算放到後臺,使主線程用於處理用戶輸入和其他任務來幫助提高資源利用率。這有助於提高系統的整體性能並減少崩潰或錯誤的可能性。此外,通過利用多個 CPU 核心,Web Workers 可以更有效地利用系統資源,實現更快速和高效的數據處理。

Web Workers 還能夠實現更好的負載平衡和擴展應用。通過允許任務在多個 worker 線程之間並行執行,Web Workers 可以幫助將工作負荷均勻分配到多個核心或處理器上,實現更快速和高效的數據處理。這對於經歷高流量或需求的應用尤爲重要,因爲 Web Workers 可以幫助確保應用可以處理增加的負載而不影響性能。

Web Workers 客戶端使用

使用 JavaScript 創建 Web Worker 的步驟如下:

  1. 創建一個新的 JavaScript 文件,其中包含要在工作線程中運行的代碼(耗時任務)。該文件不應包含對 DOM 的引用,因爲在工作線程中無法訪問 DOM。

  2. 在主 JavaScript 文件中,使用 Worker 構造函數創建一個新的worker對象。此構造函數接收一個參數,即在步驟 1 中創建的 JavaScript 文件的 URL。

const worker = new Worker('worker.js');
  1. worker對象添加事件偵聽器以處理主線程和工作線程之間發送的消息。onmessage 用於處理從工作線程發送來的消息,postMessage 用於向工作線程發送消息。
worker.onmessage = function(event) {

  console.log('Worker: ' + event.data);

};



worker.postMessage('Hello, worker!');
  1. 在 Web Worker 的 JavaScript 文件中,使用self對象的onmessage屬性添加一個事件監聽器來處理從主線程發出的消息。可以使用event.data屬性訪問發送的消息數據。
self.onmessage = function(event) {

  console.log('Main: ' + event.data);

  self.postMessage('Hello, Main!');

};

接下來就運行應用並測試 Worker。可以在控制檯看到以下信息,表示主線程和 Worker 線程之間發送和接收了消息。

Main:Hello worker!

Worker:Hello Main!

我們可以使用terminate()函數來終止一個工作線程,或者通過調用self上的close()函數使其自行終止。

// 從應用中終止一個工作線程

worker.terminate();

// 讓一個工作線程自行終止

self.close();

可以使用importScripts()函數將庫或文件導入到工作線程中,該函數可以接受多個文件。以下示例將script1.jsscript2.js加載到工作線程 worker.js 中:

importScripts('script1.js','script2');

可以使用 onerror函數來處理工作線程拋出的錯誤:

worker.onerror = function(err) {

    console.log("遇到錯誤")

}

Web Workers 服務端應用

服務器端 JavaScript 運行時也支持 Web Worker:

基本使用

要在 Node.js 中使用 Web Worker,主腳本必須定義一個 Worker 對象,其中包含相對於項目根目錄的 Web Worker 腳本的名稱。第二個參數定義了一個對象,其中包含一個workerData屬性,該屬性包含要發送的數據:

const worker = new Worker('./worker.js', {

  workerData: { a: 1, b: 2, c: 3 }

});

與瀏覽器中的 Web Worker 不同, 它在啓動時無需運行worker.postMessage()。如果需要的話,可以調用該方法並稍後發送更多數據,它會觸發parentPort.on('message')事件處理程序:

parentPort.on('message', e => {

  console.log(e);

});

一旦工作線程完成處理,它會使用以下方法將結果數據發送回主線程:

parentPort.postMessage(result);

這將在在主腳本中觸發 message 事件,主線程接收到 worker 返回的結果:

worker.on('message', result => {

  console.log( result );

});

在發送完消息後,worker 就會終止。這也會觸發一個exit事件,如果希望運行清理或其他函數,可以利用這個事件:

worker.on('exit', code => {

  //...

});

除此之外,還支持其他事件處理:

在服務端,一個單獨的 Node.js 腳本文件可以同時包含主線程和工作線程的代碼。腳本必須使用isMainThread檢查自身是否在主線程上運行,然後將自身作爲工作線程進行調用(可以在 ES 模塊中使用import.meta.url作爲文件引用,或者在 CommonJS 中使用__filename)。

import { Worker, isMainThread, workerData, parentPort } from "node:worker_threads";



if (isMainThread) {

  // 主線程

  const worker = new Worker(import.meta.url, {

    workerData: { a: 1, b: 2, c: 3 }

  });



  worker.on('message', msg => {});

  worker.on('exit', code => {});

}

else {

  // 工作線程

  const result = runSomeProcess( workerData );

  parentPort.postMessage(result);

}

這種方式更快,並且對於小型、自包含的單腳本項目來說是一個選擇。如果是大型項目,將 worker 腳本文件分開會更容易維護。

數據通信

主線程和工作線程之間的通信涉及到了數據序列化。可以使用表示固定長度原始二進制數據的SharedArrayBuffer對象在線程之間共享數據。以下是一個示例,主線程定義了從 0 到 99 的 100 個數字元素,並將其發送給工作線程:

// main.js

import { Worker } from "node:worker_threads";



const

  buffer = new SharedArrayBuffer(100 * Int32Array.BYTES_PER_ELEMENT),

  value = new Int32Array(buffer);



value.forEach((v,i) => value[i] = i);



const worker = new Worker('./worker.js');



worker.postMessage({ value });

工作線程可以接收 value對象:

// worker.js

import { parentPort } from 'node:worker_threads';



parentPort.on('message', value => {

  value[0] = 100;

});

主線程或工作線程都可以更改值數組中的元素,數據將在兩個線程之間保持一致。這可能會提高性能,但有一些缺點:

Node.js 子進程

在 Node.js 中,除了使用工作線程外,還可以使用子進程來實現類似的功能。子進程用於啓動其他應用、傳遞數據並接收結果。它們與工作線程類似,但通常效率較低,進程開銷較大。

子進程和工作線程的選擇取決於具體的應用場景。如果只需要在 Node.js 中執行其他任務或命令,子進程是一種更好的選擇。但如果需要在 Node.js 中進行復雜的計算或處理任務,Web Worker 可能更適合。

Web Workers 應用場景

Web Workers 在實際應用中有許多常見且有用的應用場景。

處理 CPU 密集型任務

假設有一個應用需要執行大量的 CPU 密集型計算。如果在主線程中執行這些計算,用戶界面可能會變得無響應,用戶體驗將受到影響。爲了避免這種情況,可以使用 Web Worker 在後臺執行這些計算。

在主線程中:

// 創建一個新的 Web Worker

const worker = new Worker('worker.js');



// 定義一個函數來處理來自Web Worker的消息

worker.onmessage = function(event) {

  const result = event.data;

  console.log(result);

};



// 向Web Worker發送一個消息,以啓動計算

worker.postMessage({ num: 1000000 });

在 worker.js 中:

// 定義一個函數來執行計算

function compute(num) {

  let sum = 0;

  for (let i = 0; i < num; i++) {

    sum += i;

  }

  return sum;

}



// 定義一個函數來處理來自主線程的消息

onmessage = function(event) {

  const num = event.data.num;

  const result = compute(num);

  postMessage(result);

};

在這個例子中,創建了一個新的 Web Worker,並定義了一個函數來處理來自 Web Worker 的消息。然後,向 Web Worker 發送一條消息,並提供一個參數(num),指定要執行計算的迭代次數。Web Worker 接收到這條消息後,在後臺執行計算。當計算完成後,Web Worker 向主線程發送一條包含結果的消息。主線程收到這個消息後,將結果記錄到控制檯中。

在上面的例子中,向 Web Worker 的compute()函數傳遞了數字 1000000。這意味着compute函數將需要將從 0 到一百萬的所有數字相加。這涉及大量的額外操作,可能需要很長時間才能完成,特別是如果代碼在較慢的計算機上運行或在瀏覽器標籤中同時處理其他任務。

通過將這個任務分配給 Web Worker,應用的主線程可以繼續平穩運行,而不會被計算密集型的任務阻塞。這使得用戶界面保持響應,並確保其他任務(如用戶輸入或動畫)可以在沒有延遲的情況下處理。

處理網絡請求

假設有一個應用需要發起大量的網絡請求。如果在主線程中執行這些請求,可能會導致用戶界面無響應,用戶體驗差。爲了避免這個問題,可以利用 Web Worker 在後臺處理這些請求。通過這樣做,主線程可以同時執行其他任務,而 Web Worker 負責處理網絡請求,從而提高性能和改善用戶體驗。

在主線程中:

// 創建一個新的 Web Worker

const worker = new Worker('worker.js');



// 定義一個函數來處理來自Web Worker的消息

worker.onmessage = function(event) {

  const response = event.data;

  console.log(response);

};



// 向Web Worker發送一個消息,以啓動計算

worker.postMessage({ urls: ['https://api.example.com/foo', 'https://api.example.com/bar'] });

在 worker.js 中:

// 定義一個函數來執行網絡請求

function request(url) {

  return fetch(url).then(response => response.json());

}



// 定義一個函數來處理來自主線程的消息

onmessage = async function(event) {

  const urls = event.data.urls;

  const results = await Promise.all(urls.map(request));

  postMessage(results);

};

在這個例子中,創建一個新的 Web Worker 並定義一個函數來處理來自該 Worker 的消息。然後,向 Worker 發送一個包含一組 URL 請求的消息。Worker 接收到這個消息後,在後臺使用 fetch API 執行請求。當所有請求完成後,Worker 向主線程發送包含結果的消息。主線程接收到這個消息後,將結果記錄到控制檯中。

並行處理

假設應用需要執行大量獨立計算。 如果在主線程中依次執行這些計算,用戶界面將變得無響應,用戶體驗將受到影響。 爲了避免這種情況,可以實例化多個 Web Worker 來並行執行計算。

在主線程中:

// 創建三個新的 Web Worker

const worker1 = new Worker('worker.js');

const worker2 = new Worker('worker.js');

const worker3 = new Worker('worker.js');





// 定義三個處理來自 worker 的消息的函數

worker1.onmessage = handleWorkerMessage;

worker2.onmessage = handleWorkerMessage;

worker3.onmessage = handleWorkerMessage;



function handleWorkerMessage(event) {

  const result = event.data;

  console.log(result);

}



// 將任務分配給不同的 worker 對象,併發送消息啓動計算

worker1.postMessage({ num: 1000000 });

worker2.postMessage({ num: 2000000 });

worker3.postMessage({ num: 3000000 });

在 worker.js 中:

// 定義一個函數來執行單個計算

function compute(num) {

  let sum = 0;

  for (let i = 0; i < num; i++) {

    sum += i;

}

  return sum;

}



// 定義一個函數來處理來自主線程的消息

onmessage = function(event) {

  const result = compute(event.data.num);

  postMessage(result);

};

在這個例子中,創建三個新的 Web Worker 並定義一個函數來處理來自該 Worker 的消息。然後,向三個 Worker 分別發送一個要計算的數字消息。Worker 接收到這個消息後執行計算。當計算完成後,Worker 向主線程發送包含結果的消息。主線程接收到這個消息後,將結果記錄到控制檯中。

Web Workers 使用限制

Web Worker 是一個提高 Web 應用性能和響應能力的強大工具,但它們也有一些限制和注意事項。

瀏覽器支持

目前所有主流瀏覽器、Node.js、Deno 和 Bun 都支持 Web Workers。

對 DOM 的訪問受到限制

Web Worker 在單獨的線程中運行,無法直接訪問主線程中的 DOM 或其他全局對象。這意味着不能直接從 Web Worker 中操作 DOM,也不能訪問像windowdocument這樣的全局對象。

爲了解決這個限制,可以使用postMessage方法與主線程進行通信,間接地更新 DOM 或訪問全局對象。例如,使用postMessage將數據發送到主線程,然後根據接收到的消息來更新 DOM 或全局對象。

另外,還有一些庫可以幫助解決這個問題。例如,WorkerDOM[1] 庫允許在 Web Worker 中運行 DOM,從而加快頁面的渲染速度並提高性能。

現代桌面瀏覽器支持共享工作線程,即在不同窗口、iframes 或工作線程中可被多個腳本訪問的單個腳本,它們通過獨立的端口進行通信。但是,大多數移動瀏覽器不支持共享工作線程,所以對於大多數 Web 項目來說,它們並不實用。

通信開銷大

Web Worker 使用postMessage方法與主線程進行通信,這可能會引入通信開銷。通信開銷指的是在兩個或多個計算系統之間建立和維護通信所需的時間和資源量,比如在 Web 應用中,Web Worker 與主線程之間的通信。這可能導致消息處理延遲,潛在地減慢應用程序的速度。爲了最小化這種開銷,應該只在線程之間發送必要的數據,避免發送大量數據或頻繁發送消息。

調試工具有限

與在主線程中調試代碼相比,調試 Web Worker 可能更具挑戰性,因爲可用的調試工具較少。爲了簡化調試過程,可以使用控制檯 API 在 Worker 線程中記錄消息,並使用瀏覽器開發者工具檢查線程之間發送的消息。

代碼複雜度

使用 Web Worker 可能會增加代碼的複雜性,因爲需要管理線程之間的通信,並確保數據正確傳遞。這可能會使編寫、調試和維護代碼更加困難,因此應該仔細考慮是否有必要在應用中使用 Web Worker。

Web Workers 最佳實踐

上面提到了在使用 Web Workers 時,可能會出現的一些潛在問題。下面就來看看如何緩解這些問題。

worker.on('message', result => {

  console.log( result );

});

消息批處理

消息批處理涉及將多個消息組合成一個批處理消息,這比單獨發送個別消息更有效。這種方法減少了主線程和 Web Worker 之間往返的數量,它有助於最小化通信開銷並提高應用的整體性能。

爲了實現消息批量處理,可以使用隊列來累積消息,並在隊列達到一定閾值或經過設定時間後將消息批量發送。下面來在 Web Worker 中簡單實現消息的批處理:

// 創建一個消息隊列累積消息

const messageQueue = [];



// 創建一個將消息添加到隊列的函數

function addToQueue(message) {

  messageQueue.push(message);



  // 檢查隊列是否達到閾值大小

  if (messageQueue.length >= 10) {

    // 如果是,請將批處理消息發送到主線程

    postMessage(messageQueue);



    // 清除消息隊列

    messageQueue.length = 0;

  }

}



// 將消息添加到隊列中

addToQueue({type: 'log', message: 'Hello, world!'});



// 再添加另一條消息到隊列中

addToQueue({type: 'error', message: 'An error occurred.'});

在這個例子中, 創建了一個消息隊列,用於累積需要發送到主線程的消息。每當使用addToQueue函數將消息添加到隊列時,檢查隊列是否已達到閾值大小(10)。如果是,則使用postMessage方法將批處理消息發送到主線程。然後,清除消息隊列,以準備進行下一次批處理。

通過以這種方式批處理消息,可以減少主線程和 Web Worker 之間發送的消息總數,從而提高應用性能。

避免同步方法

同步方法是阻塞其他代碼執行的 JavaScript 函數或操作。同步方法可以阻塞主線程,導致應變得無響應。爲了避免這種情況,應儘量避免在 Web Worker 中使用同步方法。而應該使用setTimeout()setInterval()等異步方法來執行長時間運行的計算。

// 在Web Worker中

self.addEventListener('message', (event) => {

  if (event.data.action === 'start') {

    // 使用setTimeout來異步執行計算

    setTimeout(() => {

      const result = doSomeComputation(event.data.data);



      // 將結果發送回主線程

      self.postMessage({ action: 'result', data: result });

    }, 0);

  }

});

注意內存使用情況

Web Workers 有自己的內存空間,這個空間根據用戶的設備和瀏覽器設置可能是有限的。爲了避免內存問題,應該注意你的 Web Worker 代碼使用的內存量,並避免不必要地創建大對象。例如:

// 在Web Worker中

self.addEventListener('message', (event) => {

  if (event.data.action === 'start') {

    // 使用for循環處理一個數據數組

    const data = event.data.data;

    const result = [];



    for (let i = 0; i < data.length; i++) {

      // 處理數組中的每個項,並將結果添加到結果數組中

      const itemResult = processItem(data[i]);

      result.push(itemResult);

    }



    // 將結果發送回主線程

    self.postMessage({ action: 'result', data: result });

  }

});

在這段代碼中,Web Worker 處理一個數據數組,並使用postMessage方法將結果發送回主線程。然而,用於處理數據的 for 循環可能耗時較長。

導致這個問題的原因是代碼一次性處理了整個數據數組,這意味着所有的數據都必須同時加載到內存中。如果數據集非常大,這可能導致 Web Worker 消耗大量的內存,甚至超過瀏覽器爲 Web Worker 分配的內存限制。

爲了緩解這個問題,可以考慮使用內置的 JavaScript 方法,如forEachreduce,它們可以逐項處理數據,避免一次性加載整個數組到內存中。

瀏覽器兼容性

大多數現代瀏覽器都支持 Web Worker,但某些較舊的瀏覽器可能不支持它們。 爲了確保與各種瀏覽器的兼容性,應該在不同的瀏覽器和版本中測試 Web Worker 代碼。 還可以使用功能檢測來檢查 Web Worker 是否受支持,然後再在代碼中使用它們,如下所示:

if (typeof Worker !== 'undefined') {

  const worker = new Worker('worker.js');

} else {

  console.log('Web Workers are not supported in this browser.');

}

這段代碼會檢查當前瀏覽器是否支持 Web Workers,並在支持時創建一個新的 Web Worker。如果 Web Workers 不受支持,則該代碼記錄一條消息到控制檯,表示該瀏覽器不支持 Web Workers。

總結

隨着 Web 應用變得越來越複雜和要求越來越高,有效的多線程技術(如 Web Workers)的重要性可能會增加。Web Workers 是現代 Web 開發的一個基本特性,它允許開發人員將 CPU 密集型任務放到單獨的線程中執行,從而提高應用的性能和響應能力。然而,在處理 Web Workers 時需要記住一些重要的限制和注意事項,例如無法訪問 DOM 和數據類型之間傳遞的限制等。爲了避免這些潛在問題,可以採用上面提到的策略,如使用異步方法並注意卸載的任務的複雜性。在未來,使用 Web Workers 進行多線程似乎仍然是提高 Web 應用程序性能和響應能力的重要技術。

此外,許多庫和工具可幫助開發人員使用 Web Workers。例如,Comlink[2] 和 Workerize[3] 提供了與 Web Workers 通信的簡化 API。這些庫抽象了一些管理 Web Workers 的複雜性,使利用它們變得更容易。

[1]WorkerDOM: https://github.com/ampproject/worker-dom

[2]Comlink: https://github.com/GoogleChromeLabs/comlink

[3]Workerize: https://github.com/developit/workerize

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/s8RPNCj4_lYs6guEHLLrEA