開始以前,代碼在這裏。歡迎各位大神指導。javascript
在 Web Worker 以前,解析 CSS,生成佈局,繪製界面以及運行 javascript 腳本都運行在瀏覽器的一個線程裏。若是一個 Web App 運行的 js 腳本一次運行時間過長,就會出現界面卡頓。這樣的用戶體驗是無法用及格來評價的。html
在多核普及的當下,瀏覽器也大多支持了 Web Worker。這讓前端開發有了更多的選擇,使用 web worker 來實現真正的多線程。全部阻礙、延遲用戶反饋的操做均可以移入一個後臺運行的線程中。前端
筆者主要使用 React 開發,因此首先聊一下 React 中如何發現性能出現問題的地方。在 React16.9 中新增了 Profiler API,使用起來也很是簡單,具體能夠查看文檔((https://reactjs.org/docs/prof...。java
更通用一點的可使用 chrome 的lighthouse。能夠安裝插件 lighthouse 插件,或者也能夠直接打開開發面板的audits點擊run audits,就能看到報表了。console.time
和console.timeEnd
組合。react
可是,以上只適用於開發模式下使用。在生產上使用多多少少會給產品自己帶來額外的資源消耗。通常來講,app 都會有埋點,在埋點點時候如何順道達成性能消耗點記錄就須要具體問題具體分析了。webpack
使用 Web Worke 讓阻塞代碼在後臺運行,天然不會阻塞 UI 線程(main thread)。在例子中所用到的是typescript
版本的代碼,全部後面若是有必要會給出在 typescript 實現的代碼和相應的說明。配置一類的文件請直接移步到代碼目錄查看,這裏就很少說了。c++
在 Worker 的部分使用了webpack
+ worker-loader
的方式。worker-loader
的具體內容能夠參考這裏。git
建立一個 Worker 很是的簡單,只須要把一段命名腳本傳給Worker
構造函數就能夠。好比 MDN 的一段:github
這是 Worker 腳本:web
// worker.js self.onmessage = event => { console.log("Message received", event.data); self.postMessage("Worker done"); };
在 typescript 裏,首先須要處理 Worker 的上下文的問題,不然tsc
編譯不過。
const ctx: DedicatedWorkerGlobalScope = self as any; ctx.onmessage = (event: MessageEvent) => { //... ctx.postMessage("done"); // Close the worker when jobs done ctx.close(); }; ctx.onerror = (event: ErrorEvent): any => { console.error("Error in worker", event.message); ctx.close(); }; export default null as any;
注意:這裏須要使用DedicatedWorkerGlobalScope
不能直接食慾哦那個Worker
,由於Worker
的定義裏面沒有close
方法。這是由於close
方法deprecated?
TS2339: Property 'close' does not exist on type 'Worker'.
還有在建立 Worker 的最後,須要一個export
語句:
export default null as any;
建立 Worker:
// Main thread var myWorker = new Worker("worker.js"); myWorker.postMessage([first.value, second.value]); myWorker.onmessage = function(e) { result.textContent = e.data; console.log("Message received from worker"); };
Typescript:
import SimpleWorker from "./simple.worker"; const worker = new SimpleWorker();
兩個線程之間(上例是 UI thread 和一個 worker)能夠經過postMessage
和onmessage
或者(addEventListener('message', () => {}
)的方式來傳遞消息。
線程之間的通訊是基於事件的。那麼錯誤的處理也是一樣道理,例如:
// UI thread var myWorker = new Worker("worker.js"); myWorker.onerror = function() { console.log("There is an error with your worker!"); };
// Inside worker self.onerror = err => { console.error("Error in worker", err); };
importScripts(); /* imports nothing */ importScripts("foo.js"); /* imports just "foo.js" */ importScripts("foo.js", "bar.js"); /* imports two scripts */ importScripts( "//example.com/hello.js" ); /* You can import scripts from other origins */
注意:下載順序能夠是任意順序,可是執行的順序是按照腳本在importScripts
方法裏出現的順序。
由於使用了worker-loader
,在引入外部代碼的時候,和通常的import
差很少:
import { ab2str, str2ab } from "./lib/utils"; // 引入內部依賴 import * as _ from "lodash"; // 引入外部依賴 ctx.onmessage = (event: MessageEvent) => { // ... const dataStr = ab2str(dataBuff); // 使用內部依賴 // ... const target = JSON.parse(dataStr || '[]'); const v = _.get(target, 'a.b', 'N/A'); // 使用外部依賴 // ...
Worker 也佔用和消耗資源,因此在不用的時候就要關閉它。
關閉一個 Worker 有兩種方法:一種是直接在 UI thread 裏面使用terminate
方法,一種是在 Worker 的內部調用close
方法。
// In main thread const worker = new Worker("myworker.js"); // If it's the time to terminate a worker worker.terminate();
在調用了terminate
方法以後,Worker 會被馬上終止,即便是還在運行中的也是同樣。可是通常狀況下仍是但願在 Worker 執行完成以後纔去關閉。這個時候就要用到 Worker 的close
方法。
// In a worker self.onmessage = event => { self.close(); };
如上文所說,close
方法就要被廢棄了,如今是在deprecated的狀態。具體看 MDN 的這裏
要被廢棄是由於,在一個 worker 出了做用域以後就會被回收。因此有沒有close
這個方法並無太大的必要。
Worker 的建立須要獲得腳本的 URL 地址。通常狀況下,這段腳本是放在 server 上的。這就須要網絡的傳輸。若是隻是一個簡單的須要放到後臺執行的腳本,若是能夠打包到一塊兒直接發佈到客戶瀏覽器會節省不少的時間。這個時候就須要 inline Worker。
它的建立也很簡單,並無什麼特別的地方。只是在得到 URL 的時候使用了Blob
這個工具,如:
// URL.createObjectURL window.URL = window.URL || window.webkitURL; // "Server response", used in all examples var response = "self.onmessage=function(e){postMessage('Worker: '+e.data);}"; var blob; try { blob = new Blob([response], { type: "application/javascript" }); } catch (e) { // Backwards-compatibility window.BlobBuilder = window.BlobBuilder || window.WebKitBlobBuilder || window.MozBlobBuilder; blob = new BlobBuilder(); blob.append(response); blob = blob.getBlob(); } var worker = new Worker(URL.createObjectURL(blob)); // Test, used in all examples: worker.onmessage = function(e) { alert("Response: " + e.data); }; worker.postMessage("Test");
在 react hook 和 Worker 結合的一個 npm 包裏就有過使用這種方法的代碼。簡單的把用戶的 task(一個方法)轉成字符串,以後經過Blob
獲得一個 URL 來建立出一個 Worker。其餘使用 react hook 的工做機制通知 task 執行的結果。很是的簡單有效。代碼在這裏。
節選部分代碼,以饗讀者:
const createWorker = func => { if (func instanceof Worker) return func; if (typeof func === "string" && func.endsWith(".js")) return new Worker(func); const code = [ `self.func = ${func.toString()};`, "self.onmessage = async (e) => {", " const r = self.func(e.data);", " if (r[Symbol.asyncIterator]) {", " for await (const i of r) self.postMessage(i)", " } else if (r[Symbol.iterator]){", " for (const i of r) self.postMessage(i)", " } else {", " self.postMessage(await r)", " }", "};" ]; const blob = new Blob(code, { type: "text/javascript" }); const url = URL.createObjectURL(blob); return new Worker(url); };
如今這部分代碼都交給 webpack 都插件來作了。
首先 Web Worker 不能訪問 UI thread 的 UI,也就是 DOM。
若是一個 Web Worker 能夠訪問 DOM,那加上 UI thread 就是兩個或者兩個以上的 Worker 能夠訪問 DOM 了,那就會出現很是麻煩的多線程特有的問題,並且調試困難。因此 DOM 確定是不能訪問的。
其餘的還有不少限制能夠參考這裏
可是,仍是能夠發出網絡請求,能夠setTimeout
, setInterval
,仍是可使用Cache
和IndexedDB
等等一些功能等。
Worker 雖好,也不能開的太多。Worker 是真正系統級的線程,要運行起來就須要有支撐的資源。在 Worker 之間傳輸的數據不能太大。爲了不多個 Thread 共享內存而致使的多線程問題,WeW Worker 傳輸數據的時候使用了兩個方式:
Transferable Object
。這種類型的數據在傳輸的時候基本不存在複製的動做,能夠認爲是 c++裏的引用傳遞。不一樣的是 Worker 的Transferable Object
在傳遞出去以後就當前上下文裏即不可訪問。// Create a 32MB "file" and fill it. var uInt8Array = new Uint8Array(1024 * 1024 * 32); // 32MB for (var i = 0; i < uInt8Array.length; ++i) { uInt8Array[i] = i; } worker.postMessage(uInt8Array.buffer, [uInt8Array.buffer]);
咱們來把一個字符串反轉屢次來模擬 CPU 「繁重」的任務。這個栗子分爲三部分一個是運行在 UI thread 上看看會有多卡,一個是運行在Promise
裏,看看會有什麼不一樣的結果。數據所有都是基於咱們的栗子來獲得,對於讀者來講因爲有些網絡、硬件等狀況不一樣或者不徹底可控會有不一樣,定量分析不會那麼準確,定性分析有必定的表明性。
同時,這個試驗和樣本的數量關係十分密切。在樣本足夠打的時候,試驗只會收到異常。
const ITERATE_COUNT = 1000; const STR_LEN = 3; let queue: TaskQueue | null = null; function prepareData(count: number = 1000, length: number = 10) { const data: Array<DataType> = []; for (let i = 0; i < count; i++) { const item = RandomString.generate(length); data.push({ key: `Key - ${i}`, val: item }); } return data; } const rawData = prepareData(ITERATE_COUNT, STR_LEN); (window as any).rawData = rawData;
上面的方法生成了 1000 個長度是 10 的字符串。在後面的例子裏會把這些字符串所有反轉。以此來模擬某種業務場景下繁重的 CPU 任務。
代碼:
// Demo 1: execute reverse string in ui thread function execTaskSync() { console.time("sync task in ui thread"); const target = rawData; for (let el of target) { const { val } = el; reverseString(val); } console.timeEnd("sync task in ui thread"); } (window as any).execTaskSync = execTaskSync;
這個任務量其實不夠大,只會產生一個和後面例子對比的效果。先運行一下看看結果:
運行結果看起來很快,若是須要更慢一些只須要把字符串數量或者字符串的長度調大就能夠。運行的結果基本都在 0.xx ms 的範圍內,只有一個是 2.27 ms。這也許只是一個現象,也許就很值得深究了。
是時候讓這個功能在 worker 裏面運行一次了:
ctx.onmessage = (event: MessageEvent) => { console.time("worker timer"); const { target } = event.data as { target: DataType[] }; for (let el of target) { const { val } = el; reverseString(val); } console.timeEnd("worker timer"); ctx.postMessage("done"); // Close the worker when jobs done self.close(); };
數據所有傳過來以後,在 worker 連運行。結果是這樣的:
看起來是一個 queue,不過是一個個 Promise 接連運行的。在本例中只有一個 Promise 運行。
Queue 是什麼樣的 Queue:
class Queue { private _startExec() { const task = this._queue.shift(); if (task) task.run(); } next() { if (this._queue.length === 0) { return; } this._startExec(); } async addTask( fun: (param: any) => any, data: any, resolve: (val: any) => void, reject: (err: any) => void ) { const run = async () => { try { const ret = await fun(data); resolve(ret); } catch (e) { reject(e); } this.next(); }; this._queue.push({ run } as Task); this._startExec(); } }
這個是在 Queue 裏添加 task 的方法,在添加的時候就會在 task 運行完成以後調用 Queue 的 next 方法來開始下一個 task。
在數據量一樣的狀況下運行的結果:
看起來和在主線程的運行結果至關的接近了。咱們來把數據量加大看看會有什麼結果。
const ITERATE_COUNT = 100000; const STR_LEN = 300;
先把數量級提高到這個程度。
屢次運行以後,主線程和放在 Promise 裏的方式差異依然不大,只是在按鈕點擊以後明顯的增長了等待的時間。在 Worker 裏運行的花費時間比之主線程依然更多,可是按鈕點擊以後的等待時間並無相應的更多等待。
這就體現出 Worker 存在的意義了。相應用戶點擊的速度必定會快不少。這個時候就須要Buffer
出場了。咱們來測試一下使用了 Buffer 的 Worker 會出現什麼樣的驚喜。
明顯在第一次消耗了不少時間以後,每次的調用都消耗了比直接調用 Worker 的postMessage
更少的時間。使用 Buffer 來實現不一樣 Worker 之間傳輸數據就像是 C/C++的引用傳遞同樣,這裏不會涉及到數據的拷貝操做。因此節省了時間。
可是,在代碼裏:
const dataStr = JSON.stringify(data); const dataBuff = str2ab(dataStr); const worker = new CachedWorker(); worker.postMessage(dataBuff, [dataBuff]);
其實包含了數據->字符串(json)->buffer 的轉化過程。第一次花費的時間不少是在這些轉化的過程當中消耗的。可是後面,筆者認爲是瀏覽器作了優化,還要繼續查一下資料,因此花費的時間只有直接傳輸 buffer 花費的時間,因此大量減小。
注意:使用 Buffer 傳輸數據能夠很大,好比在 Google 的某個例子中是 30M 多。可是,上文的例子中,傳輸的數據的大小受到了很大的限制。主要是在把 Buffer 的數據轉化爲 Object 的時候會出現異常。有興趣的各位能夠把數據的大小繼續往大調這個異常就會出現。_因此,如何使用須要看具體的場景,好比,上例能夠改成在 Worker 裏請求獲得二進制數據再作處理。_
傳遞 Buffer 的時候是按照 Transferable Object 傳遞的。這種數據是實現了Transferable
接口的數據。這個接口就是一個標記的做用,代表實現了這個接口的數據能夠如引用通常傳遞。
可是,此處的引用和 C/C++的引用是兩回事。Transferable object 在完成不一樣的執行上下文(execution context)傳輸以後就再也不可用了。H5 委員會爲了 Worker 能夠普及,默默的解決了多少使用多線程可能會出現的問題。
屢次執行就不用說了,只執行一次的代碼緩存起來也存粹是浪費空間。緩衝的命中率是說緩存的結果會被用到。若是緩存不會再被屢次執行的某個功能用到,那麼也是沒有意義的。
在本例中,緩存的做用基本上大打折扣。字符串是隨機生成的。用隨機字符串爲 Key 緩存的結果,基本上備用到的機率很小,並且隨機字符串的數量比較大(這裏是 1000)。那麼在查找緩存字符串的時候也要便利 map 的大部分 Key。反而形成了沒必要要的多餘計算。
因此,緩存須要根據代碼的執行邏輯和緩存的命中率來判斷是否須要。
使用 Worker 或者不使用 Worker 都是要看具體的某個場景。新技術的產生必定是解決某個特定的問題的。在使用這項新技術以前至少要儘可能真實的模擬須要解決的場景,來驗證這個新的技術是否可行。好比,在本文使用的例子就是爲了模擬筆者想要解決的問題的場景設立的。遇到的最大的問題是若是數據量達到某個臨界值的時候,在 Worker 內部反序列化並組成 Object 的時候就會出現異常。而混存,由於 Key 值極大的多是重複的,因此混存的使用就很是的有必要。在以上各類場景的模擬以後可使用的各類技術的結合必然是緩存和使用Buffer
傳輸數據。可是,數據量須要控制,不能出現反序列化的問題。
或者,直接從 Worker 裏請求獲得 JSON 的二進制串,好比發送和接收二進制數據。
因此,各類技術都有在特定場合下使用的優劣。這就須要咱們具體結合場景具體分析。