【深刻吧,HTML 5】 性能 & 集成 —— Web Workers

博客 有更多精品文章喲。javascript

修訂

  • 2019-01-16
    • 增長使用 importScripts 跨域時,使用相對路徑報錯的緣由說明。

前言

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

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

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

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

Web Workers API

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

Worker 線程與主線程不在同一個全局上下文中,所以會有一些須要注意的地方:git

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

工做流程

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

Worker 線程向主線程發送的消息也會經過 中轉對象 進行傳遞;所以,總得來說 Worker 的工做機制就是經過 中轉對象 來實現消息的傳遞,再經過 message 事件來完成消息的處理。github

使用方式

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

  • 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 的通訊過程:

Broadcast Channel Communication process

這個 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 來觀察下二者的時間差別:

Transferable performance

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

跨域

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

Cross domain error

不少時候,咱們都須要把腳本放在 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 函數使用相對路徑的腳本,會有報錯,提示咱們腳本沒有加載成功。

Cross domain error

出現這個報錯的緣由在於經過 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到底有多快?
相關文章
相關標籤/搜索