【深入吧,HTML 5】 效能 & 整合 —— Web Workers

晨風明悟發表於2019-01-06

部落格 有更多精品文章喲。

修訂

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

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到底有多快?

相關文章