【深入吧,HTML5】性能&集成——WebWorkers

NO IMAGE

博客 有更多精品文章喲。

修訂

  • 2019-01-16
    • 增加使用 importScripts 跨域時,使用相對路徑報錯的原因說明。

前言

JavaScript 採用的是單線程模型,也就是說,所有任務都要在一個線程上完成,一次只能執行一個任務。有時,我們需要處理大量的計算邏輯,這是比較耗費時間的,用戶界面很有可能會出現假死狀態,非常影響用戶體驗。這時,我們就可以使用 Web Workers 來處理這些計算。

Web Workers 是 HTML5 中定義的規範,它允許 JavaScript 腳本運行在主線程之外的後臺線程中。這就為 JavaScript 創造了 多線程 的環境,在主線程,我們可以創建 Worker 線程,並將一些任務分配給它。Worker 線程與主線程同時運行,兩者互不干擾。等到 Worker 線程完成任務,就把結果發送給主線程。

Web Workers 與其說創造了多線程環境,不如說是一種回調機制。畢竟 Worker 線程只能用於計算,不能執行更改 DOM 這些操作;它也不能共享內存,沒有 線程同步 的概念。

Web Workers 的優點是顯而易見的,它可以使主線程能夠騰出手來,更好的響應用戶的交互操作,而不必被一些計算密集或者高延遲的任務所阻塞。但是,Worker 線程也是比較耗費資源的,因為它一旦創建,就一直運行,不會被用戶的操作所中斷;所以當任務執行完畢,Worker 線程就應該關閉。

Web Workers API

一個 Worker 線程是由 new 命令調用 Worker() 構造函數創建的;構造函數的參數是:包含執行任務代碼的腳本文件,引入腳本文件的 URI 必須遵守 同源策略

Worker 線程與主線程不在同一個全局上下文中,因此會有一些需要注意的地方:

  • 兩者不能直接通信,必須通過消息機制來傳遞數據;並且,數據在這一過程中會被複制,而不是通過 Worker 創建的實例共享。詳細介紹可以查閱 worker中數據的接收與發送:詳細介紹
  • 不能使用 DOM、windowparent 這些對象,但是可以使用與主線程全局上下文無關的東西,例如 WebScoketindexedDBnavigator 這些對象,更多能夠使用的對象可以查看Web Workers可以使用的函數和類

工作流程

  1. 在構造函數中傳入腳本文件地址進行實例化的過程中,會通過異步的方式來加載這個文件,因此並不會阻塞後續代碼的運行。此時,如果腳本文件不存在,Worker 只會 靜默失敗,並不會拋出異常。
  2. 在主線程向 Worker 線程發送消息時,會通過 中轉對象 將消息添加到 Worker 線程對應 WorkerRunLoop 的消息隊列中;此時,如果 Worker 線程還未創建,那麼消息會先存放在臨時消息隊列,等待 Worker 線程創建後再轉移到 WorkerRunLoop 的消息隊列中;否則,直接將消息添加到 WorkerRunLoop 的消息隊列中。

Worker 線程向主線程發送的消息也會通過 中轉對象 進行傳遞;因此,總得來講 Worker 的工作機制就是通過 中轉對象 來實現消息的傳遞,再通過 message 事件來完成消息的處理。

使用方式

Web Workers 規範中定義了兩種不同類型的線程:

  • Dedicated Worker(專用線程),它的全局上下文是 DedicatedWorkerGlobalScope 對象,只能在一個頁面使用。
  • Shared Worker(共享線程),它的全局上下文是 SharedWorkerGlobalScope 對象,可以被多個頁面共享。

專用線程

下面代碼最重要的部分在於兩個線程之間怎麼發送和接收消息,它們都是使用 postMessage 方法發送消息,使用 onmessage 事件進行監聽。區別是:在主線程中,onmessage 事件和 postMessage 方法必須掛載在 Worker 的實例上;而在 Worker 線程,Worker 的實例方法本身就是掛載在全局上下文上的。

Demo

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Web Workers 專用線程</title>
</head>
<body>
<input type="text" name="" id="number1">
<span>+</span>
<input type="text" name="" id="number2">
<button id="button">確定</button>
<p id="result"></p>
<script src="./main.js"></script>
</body>
</html>
// main.js
const number1 = document.querySelector("#number1");
const number2 = document.querySelector("#number2");
const button = document.querySelector("#button");
const result = document.querySelector("#result");
// 1. 指定腳本文件,創建 Worker 的實例
const worker = new Worker("./worker.js");
button.addEventListener("click", () => {
// 2. 點擊按鈕,把兩個數字發送給 Worker 線程
worker.postMessage([number1.value, number2.value]);
});
// 5. 監聽 Worker 線程返回的消息
// 我們知道事件有兩種綁定方式,使用 addEventListener 方法和直接掛載到相應的實例
worker.addEventListener("message", e => {
result.textContent = e.data;
console.log("執行完畢");
})
// worker.js
// 3. 監聽主線程發送過來的消息
onmessage = e => {
console.log("開始後臺任務");
const result= +e.data[0]+ +e.data[1];
console.log("計算結束");
// 4. 返回計算結果到主線程
postMessage(result);
}

共享線程

共享線程雖然可以在多個頁面共享,但是必須遵守同源策略,也就是說只能在相同協議、主機和端口號的網頁使用。

示例基本上與專用線程的類似,區別是:

  • 創建實例的構造器不同。
  • 主線程與共享線程通信,必須通過一個確切打開的端口對象;在傳遞消息之前,兩者都需要通過 onmessage 事件或者顯式調用 start 方法打開端口連接。而在專用線程中這一部分是自動執行的。

端口對象會被上文所講的 中轉對象(WorkerMessagingProxy) 調用,由 中轉對象 來決定哪個發送者對應哪個接收者,具體的流程可以看 Web Worker在WebKit中的實現機制

Demo

// main.js
const number1 = document.querySelector("#number1");
const number2 = document.querySelector("#number2");
const button = document.querySelector("#button");
const result = document.querySelector("#result");
// 1. 創建共享實例
const worker = new SharedWorker("./worker.js");
// 2. 通過端口對象的 start 方法顯式打開端口連接,因為下文沒有使用 onmessage 事件
worker.port.start();
button.addEventListener("click", () => {
// 3. 通過端口對象發送消息
worker.port.postMessage([number1.value, number2.value]);
});
// 8. 監聽共享線程返回的結果
worker.port.addEventListener("message", e => {
result.textContent = e.data;
console.log("執行完畢");
});
// worker.js
// 4. 通過 onconnect 事件監聽端口連接
onconnect = function (e) {
// 5. 使用事件對象的 ports 屬性,獲取端口
const port = e.ports[0];
// 6. 通過端口對象的 onmessage 事件監聽主線程發送過來的消息,並隱式打開端口連接
port.onmessage = function (e) {
console.log("開始後臺任務");
const result= e.data[0] * e.data[1];
console.log("計算結束");
console.log(this);
// 7. 通過端口對象返回結果到主線程
port.postMessage(result);
}
}

終止 Worker

如果不需要 Worker 繼續運行,我們可以在主線程中調用 Worker 實例的 terminate 方法或者使用 Worker 線程的 close 方法來終止 Worker 線程。

Demo

// main.js
const number1 = document.querySelector('#number1');
const number2 = document.querySelector('#number2');
const button = document.querySelector('#button');
const terminate = document.querySelector('#terminate');
const close = document.querySelector('#close');
const result = document.querySelector('#result');
const worker = new Worker('./worker.js');
button.addEventListener('click', () => {
worker.postMessage([number1.value, number2.value]);
});
// 主線程中終止 Worker 線程
terminate.addEventListener('click', () => {
worker.terminate();
console.log('主線程中終止 Worker 線程');
});
// 發送消息讓 Worker 線程自己關閉
close.addEventListener('click', () => {
worker.postMessage('close');
console.log('Worker 線程自己關閉');
});
worker.addEventListener('message', e => {
result.textContent = e.data;
console.log('執行完畢');
});
// worker.js
onmessage = e => {
if (typeof e.data === 'string' && e.data === 'close') {
close();
return;
}
console.log('開始後臺任務');
const result= +e.data[0]+ +e.data[1];
console.log('計算結束');
postMessage(result);
};

處理錯誤

當 Worker 線程在運行過程中發生錯誤時,我們在主線程通過 Worker 實例的 error 事件可以接收到 Worker 線程拋出的錯誤;error 事件的回調函數會返回 ErrorEvent 對象,我們主要關心它的三個屬性:

  • filename,發生錯誤的腳本文件名。
  • lineno,發生錯誤時所在腳本文件的行號。
  • message,可讀性良好的錯誤消息。

Demo

// main.js
const button = document.querySelector('#button');
const worker = new Worker('./worker.js');
button.addEventListener('click', () => {
console.log('主線程發送消息,讓 Worker 線程觸發錯誤');
worker.postMessage('send');
});
worker.addEventListener('error', e => {
console.log('主線程接收錯誤,錯誤消息:');
console.log('filename:', e.filename);
console.log('lineno:', e.lineno);
console.log('message:', e.message);
});
// worker.js
onmessage = e => {
// 利用未聲明的變量觸發錯誤
console.log('Worker 線程利用未聲明的 x 變量觸發錯誤');
postMessage(x * 10);
};

生成 Sub Worker

Worker 線程本身也能創建 Worker,這樣的 Worker 線程被稱為 Sub Worker,它們必須與當前頁面同源。另外,在創建 Sub Worker 時傳入的地址是相對與當前 Worker 線程而不是頁面地址,因為這樣有助於記錄依賴關係。

Demo

// main.js
const button = document.querySelector('#button');
const worker = new Worker('./worker.js');
button.addEventListener('click', () => {
console.log('主線程發送消息給 Worker 線程');
worker.postMessage('send');
});
worker.addEventListener('message', e => {
console.log('主線程接收到 Worker 線程回覆的消息');
});
// worker.js
onmessage = e => {
console.log('Worker 線程接收到主線程發送的消息');
const subWorker = new Worker('./sub-worker.js');
console.log('Worker 線程發送消息給 Sub Worker 線程');
subWorker.postMessage('send');
subWorker.addEventListener('message', () => {
console.log('Worker 線程接收到 Sub Worker 線程回覆的消息');
console.log('Worker 線程回覆消息給主線程');
postMessage('reply');
})
};
// sub-worker.js
self.addEventListener('message', e => {
console.log('Sub Worker 線程接收到 Worker 線程的發送消息');
console.log('Sub Worker 線程回覆消息給 Worker 線程,並銷燬自身')
self.postMessage('reply');
self.close();
})

引入腳本

Worker 線程中提供了 importScripts 函數來引入腳本,該函數接收零個或者多個 URI;需要注意的是,無論引入的資源是何種類型的文件,importScripts 都會將這個文件的內容當作 JavaScript 進行解析。

importScripts 的加載過程和 <script> 標籤類似,因此使用這個函數引入腳本並 不存在跨域問題。在腳本下載時,它們的下載順序並不固定;但是,在執行時,腳本還是會按照書寫的順序執行;並且,這一系列過程都是 同步 進行的。加載成功後,每個腳本中的全局上下文都能夠在 Worker 線程中使用;另外,如果腳本無法加載,將會拋出錯誤,並且之後的代碼也無法執行了。

Demo

// main.js
const button = document.querySelector('#button');
const worker = new Worker('./worker.js');
button.addEventListener('click', () => {
worker.postMessage('send');
});
worker.addEventListener('message', e => {
console.log('接收到 Worker 線程發送的消息:');
console.log(e.data);
});
// worker.js
onmessage = e => {
console.log('Worker 線程接收到引入腳本指令');
// importScripts('import-script.js');
// importScripts('import-script2.js');
// importScripts('import-script3.js');
importScripts('import-script.js', 'import-script2.js', 'import-script3.js');
importScripts('import-script-text.txt');
// 跨域
importScripts('https://cdn.bootcss.com/moment.js/2.23.0/moment.min.js');
console.log(moment().format());
// 加載異常,後面的代碼也無法執行了
// importScripts('http://test.com/import-script-text.txt');
console.log(self);
console.log('在 Worker 中測試同步');
};
// import-script.js
console.log('在 import-script 中測試同步');
postMessage('我在 importScripts 引入的腳本中');
self.addProp = '在全局上下文中增加 addProp 屬性';

嵌入式 Web Workers

嵌入式 Web Workers 本質上就是把代碼當作字符串處理;如果是字符串我們可存放的地方就太多了,可以放在 JavaScript 的變量中、利用函數的 toString 方法能夠輸出本函數所有代碼的字符串的特性、放在 type 沒有被指定可運行的 mime-type<script> 標籤中等等。

但是,我們會發現一個問題,字符串怎麼當作一個地址傳入 Worker 的構造器呢?有什麼 API 能夠生成 URL 呢?URL.createObjectURL 方法可以,可是這個 API 能夠接收字符串嗎?查閱文檔,我們知道這個方法接收一個 Blob 對象,這個對象實例在創建時,第一個參數允許接收字符串,第二個參數接收一個配置對象,其中的 type 屬性能夠指定生成的對象實例的類型。現在,我們已經知道了嵌入式 Web Workers 的工作原理,接下來,我們通過 Demo 來看下代碼:

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>嵌入式 Web Workers</title>
</head>
<body>
<button id="button">發送消息</button>
<script type="text/javascript-worker">
self.addEventListener('message', e => {
postMessage('我在嵌入式的 Web Workers 中');
});
</script>
<script src="./main.js"></script>
</body>
</html>
// mian.js
const button = document.querySelector('#button');
const blob = new Blob(
Array.prototype.map.call(
document.querySelectorAll('script[type="text/javascript-worker"]'),
v => v.textContent,
),
{
type: 'text/javascript',
},
);
// 通過 URL.createObjectURL 方法創建的 URL 就在本域中,因此是同源的
const url = window.URL.createObjectURL(blob);
// blob:http://localhost:3000/6d0e9210-6b28-4b49-82da-44739109cd2a
console.log(url);
const worker = new Worker(url);
button.addEventListener('click', () => {
console.log('發送消息給嵌入式 Web Workers');
worker.postMessage('send');
});
worker.addEventListener('message', e => {
console.log('接收嵌入式 Web Workers 發送的消息:');
console.log(e.data);
});

數據通訊

Worker 線程和主線程進行通信,除了使用上面例子中 Worker 實例的 postMessage 方法之外,還可以使用 Broadcast Channel(廣播通道)

Broadcast Channel(廣播通道)

Broadcast Channel 允許我們在同源的所有上下文中發送和接收消息,包括瀏覽器標籤頁、iframe 和 Web Workers。需要注意的是這個 API 的兼容性並不好,在 caniuse 中我們可以查看瀏覽器的支持情況。另外,下圖能幫助我們更好的理解 Broadcast Channel 的通信過程:

【深入吧,HTML5】性能&集成——WebWorkers

這個 API 的使用方法與 Web Workers 類似,發送和接收也是通過實例的 postMessage 方法和 message 事件;不同在於構造器是 BroadcastChannel,並且它會接收一個頻道名稱字符串;有著相同頻道名稱的 Broadcast Channel 實例在同一個廣播通道中,因此,它們可以相互通信。

Demo

// main.js
const number1 = document.querySelector('#number1');
const number2 = document.querySelector('#number2');
const button = document.querySelector('#button');
const close = document.querySelector('#close');
const result = document.querySelector('#result');
const worker = new Worker('./worker.js');
const channel = new BroadcastChannel('channel');
button.addEventListener('click', () => {
channel.postMessage([number1.value, number2.value]);
});
// 銷燬 BroadcastChannel,之後再發送消息會拋出錯誤
close.addEventListener('click', () => {
console.log('銷燬 BroadcastChannel,之後再發送消息會拋出錯誤');
channel.close();
});
channel.addEventListener('message', e => {
result.textContent = e.data;
console.log('執行完畢');
});
// worker.js
const channel = new BroadcastChannel('channel');
channel.onmessage = e => {
console.log('開始後臺任務');
const result= +e.data[0]+ +e.data[1];
console.log('計算結束');
channel.postMessage(result);
};

消息機制

在 Web Workers 中根據不同的消息格式,有兩種發送消息的方式:

  • 拷貝消息(Copying the message):這種方式下消息會被序列化、拷貝然後再發送出去,接收方接收後則進行反序列化取得消息;這與我們使用 JSON.stringify 方法把 JSON 數據轉換成字符串,再通過 JSON.parse 方法進行解析是一樣的過程,只不過瀏覽器自動幫我們做了這些工作。經過編碼/解碼的過程後,我們知道主線程和 Worker 線程並不會共用一個消息實例,它們每次通信都會創建消息副本;這樣一來,傳遞的 消息越大時間開銷就越多。另外,不同的瀏覽器實現會有所差別,並且舊版本還有兼容問題,因此比較推薦 手動 編碼成 字符串 /解碼成序列化數據來傳遞複雜格式的消息。
  • 轉移消息(Transferring the message):這種方式傳遞的是 可轉讓對象,可轉讓對象從一個上下文轉移到另一個上下文並不會經過任何拷貝操作;因此,一旦對象轉讓,那麼它在原來上下文的那個版本將不復存在,該對象的所有權被轉讓到新的上下文內;這意味著消息發送者一旦發送消息,就再也無法使用發出的消息數據了。這樣的消息傳遞幾乎是瞬時的,在傳遞大數據時會獲得極大的性能提升。

我們通過 Demo 來觀察下兩者的時間差異:

【深入吧,HTML5】性能&集成——WebWorkers

10 次比較都使用了相同的數據(1024 * 1024 * 32),0 列表示拷貝消息,1 列表示轉移消息;可以發現轉移消息損失的時間基本可以忽略不計,而拷貝消息消耗的時間非常的大;因此,我們在傳遞消息時,如果數據比較小,可以直接使用拷貝消息,但是如果數據非常大,那最好使用可轉讓對象進行消息轉移。

跨域

Worker 在實例化時必須傳入同源腳本的地址,否則就會報跨域錯誤:

【深入吧,HTML5】性能&集成——WebWorkers

很多時候,我們都需要把腳本放在 CDN 上面,很容易出現跨域問題,有什麼辦法能避免跨域呢?

異步

我們看完上文後知道 嵌入式 Web Workers 的本質就是利用了字符串,那我們通過異步的方式先獲取到 JavaScript 文件的內容,然後再生成同源的 URL,這樣 Worker 的構造器自然就能順利運行了;因此,這種方案主要需要解決的問題是異步跨域;異步跨域最簡單的方式莫過於使用 CORS 了,我們來看下 Demo(本地的兩個 server*.js 都要通過 node 運行)。

// main.js
// localhost:3000
console.log('開始異步獲取 worker.js 的內容');
fetch('http://localhost:3001/worker.js')
.then(res => res.text())
.then(text => {
console.log('獲取 worker.js 的內容成功');
const worker = new Worker(
window.URL.createObjectURL(
new Blob(
[text],
{
type: 'text/javascript',
},
),
),
);
worker.postMessage('send');
worker.addEventListener('message', e => {
console.log(e.data);
console.log('成功跨域');
});
});
// worker.js
// localhost:3001
onmessage = e => {
postMessage('我在 Worker 中');
};

importScripts

這種方式實際上也是 嵌入式 Web Workers,不過利用了 importScripts 引入腳本沒有跨域問題這一特性;首先我們生成引入腳本的代碼字符串,然後創建同源的 URL,最後運行 Worker 線程;此時,嵌入式 Web Workers 執行 importScripts 引入了跨域的腳本,最終的執行效果就跟放在同源一樣了。

Demo

// main.js
// 代碼字符串
const proxyScript = `importScripts('http://localhost:3001/worker.js')`;
console.log('生成代碼字符串');
const proxyURL = window.URL.createObjectURL(
new Blob(
[proxyScript],
{
type: 'text/javascript',
},
),
);
// blob:http://localhost:3000/cb45199f-ca39-4800-8bfd-1c16b97c8910
console.log(proxyURL);
console.log('生成同源 URL');
const worker = new Worker(proxyURL);
worker.postMessage('send');
worker.addEventListener('message', e => {
console.log(e.data);
console.log('成功跨域');
});
// worker.js
onmessage = e => {
postMessage('我在 Worker 中');
};

相對路徑

另外,在使用這個方法跨域時,如果通過 importScripts 函數使用相對路徑的腳本,會有報錯,提示我們腳本沒有加載成功。

【深入吧,HTML5】性能&集成——WebWorkers

出現這個報錯的原因在於通過 window.URL.createObjectURL 生成的 blob 鏈接,指向的是內存中的數據,這些數據只為當前頁面提供服務,因此,在瀏覽器的地址欄中訪問 blob 鏈接,並不會找到實際的文件;同樣的,我們在 blob 鏈接指向的內存數據中訪問相對地址,肯定是找不到任何東西的。

所以,如果想要在這種場景中訪問文件,那我們必須向服務器發送 HTTP 請求來獲取數據。

總結

到此為止,我們已經對 Worker 有了深入的瞭解,知道了它的作用、使用方式和限制;在真實的場景中,我們也就能夠針對最適合的業務使用正確的方式進行使用和規避限制了。

最後,我們可以暢想一下 Web Workers 的使用場景:

還有好多應用場景,可以看參考資料中的文章進行了解。

參考資料

  1. 優化 JavaScript 執行 —— 降低複雜性或使用 Web Worker
  2. 使用 Web Workers
  3. 深入 HTML5 Web Worker 應用實踐:多線程編程
  4. JS與多線程
  5. 【轉向Javascript系列】深入理解Web Worker
  6. Web Worker在WebKit中的實現機制
  7. 廣播頻道-BroadcastChannel
  8. 聊聊 webworker
  9. [譯] JavaScript 工作原理:Web Worker 的內部構造以及 5 種你應當使用它的場景
  10. HTML5 Web Worker是利器還是擺設
  11. [譯文]web workers到底有多快?

相關文章

[webpack]中小型多頁面應用整合webpack終極方案

[web前端性能優化]性能優化只有三步,你瞭解嗎

React怎麼實現Vue的組件

【深入吧,HTML5】性能&集成——HistoryAPI