給 JavaScript 插上多線程的翅膀 —— Web Worker 的 Promise 化實踐
Web Worker 介紹
衆所周知,JavaScript 這門語言的一大特點就是單線程,即同一時間只能同步處理一件事情,這也是這門語言衍生出的 nodeJS 被各後端大佬詬病的很重要的一點。
然而,JavaScript 在設計之初,其實是故意被設計成單線程語言的,這是由於它當時的主要用途決定的。
JavaScript 最初的設計初衷是完成頁面與用戶的交互,操作 DOM 或者 BOM 元素,此時如果一味地追求效率使用多線程的話,會帶來資源搶佔,數據同步等等問題,因此必須規定,同一時間只有一個線程能直接操作頁面元素,以保證系統的穩定性以及安全性。
儘管如此,但是 JavaScript 並不是只能線性處理任務。JS 擁有消息隊列和事件循環機制,通過異步處理消息的能力來實現併發。在高 I/O 型併發事務處理的過程中,由於不需要手動生成與銷燬線程以及佔用額外管理線程的空間,性能表現及爲優異。因此,nodeJS 作爲 JavaScript 在服務端的探索者,在處理高併發網絡請求的優勢極爲明顯。
儘管 JavaScript 通過異步機制完美解決了高 I/O 性能的問題,但 JavaScript 單線程執行的本質還是沒有變的。因此缺點就顯而易見了,那就是處理 CPU 密集型的事務時沒有辦法充分調動現代多核心多線程機器的運算資源。
在現代大型前端項目中,隨着代碼的複雜程度越來越高,本地的計算型事務也在變得繁重,而運行在單線程下 JS 項目必定會忙於處理計算而無暇顧及用戶接下來的頻繁操作,造成卡頓等不太好的用戶體驗,更嚴重的情況是,當計算型事務過多時還有可能因爲資源被佔滿帶來網頁無響應的卡死現象。因此,Web 項目的本地多線程運算能力勢在必行,由此,Web Worker 應運而生了。
Web Worker 是 HTML5 中推出的標準,官方是這樣定義它的:
Web Workers makes it possible to run a script operation in a background thread separate from the main execution thread of a web application.
它允許 JavaScript 腳本創建多個線程,從而充分利用 CPU 的多核計算能力,不會阻塞主線程 (一般指 UI 渲染線程) 的運行。
Web Worker 雖然是 HTML5 標準,但其實早在 2009 年 W3C 就已經提出了草案,因此它的兼容性良好,基本覆蓋了所有主流瀏覽器。
Web Worker 的侷限
需要注意的是,Web Worker 本質上並沒有突破 JavaScript 的單線程的性質。
事實上,Web Worker 腳本中的代碼並不能直接操作 DOM 節點,並且不能使用絕大多數 BOM API。它的全局環境是 DedicatedWorkerGlobalScope 而並不是 Window。運行 Worker 的實際上是一個沙箱,跑的是與主線程完全獨立 JavaScript 文件。
Worker 做的這些限制,實際上也是爲了避免文章開頭說過的搶佔問題。它更多的使用場景是作爲主線程的附屬,完成高 CPU 計算型的數據處理,再通過線程間通信將執行結果傳回給主線程。在整個過程中,主線程仍然能正常地相應用戶操作,從而很好地避免頁面的卡頓現象。
Web Worker 的使用
新建
目前 Web Worker 的瀏覽器支持已經較爲完善,基本上直接傳入 Worker 腳本的 URI 並實例化即可使用。
/* main.js */ const worker = new Worker("./worker.js")
通信
Worker 與主線程之間的通信只需要各有兩個 API:onmessage/addEventListener 與 postMessage 即可完成收發消息的交互。
/* main.js */ const worker = new Worker("./worker.js"); // 主線程發送消息 worker.postMessage({ data: 'mainthread send data' }); // 主線程接收消息 worker.onmessage = (e) => { const { data } = e; if (!data) return; console.log(data); }
/* worker.js */ // worker線程接收消息 self.addEventListener('message', (e) => { const { data } = e; if (!data) return; // worker線程發送消息 self.postMessage({data: 'worker received data'}) });
注:Worker 中,this.xx, self.xx 與直接使用 xx,其作用域都指向 worker 的全局變量 DedicatedWorkerGlobalScope ,可以互換。
銷燬
Worker 的銷燬方式有兩種,既能在內部主動銷燬,也能夠被主線程通知銷燬。
/* main.js */ worker.terminate();
/* worker.js */ self.close();
進階:讓通信方式 Promise 化
根據上一節,我們已經能夠簡單地使用 Worker 的 API 來獲取瀏覽器多線程計算的能力,但是它離工程化的應用還缺少了一些易用性,比如我們多數時候需要使用到的異步相應。接下來我們就來做這件事情。
首先我們需要一個異步回調集合 actionHandlerMap,用於存放等待 Worker 響應的 Promise resolve 方法,其 key 值可以用通信中的某一 id 指定(保證其唯一性即可)。接着我們需要封裝一下原生的 postMessage 與 onmessage 方法。
我們在原生的 postMessage 發送的信息中加入 id,並將當前的 Promise 的 resolve 方法放入 actionHandlerMap,等待 Worker 返回結果後觸發。
對於 onmessage 的監聽,在接收到 Worker 發送過來的響應之後,匹配響應的 Promise 並執行 .then() 方法,完成後刪除集合中的 Promise resolve 函數。
/* main.js */ let fakeId = 0; class MainThreadController { constructor(options) { this.worker = new Worker(options.workerUrl, { name: options.workerName }); // 等待異步回調集合 this.actionHandlerMap = {}; this.worker.onmessage = this.onmessage.bind(this); } onmessage(e) { const { id, response } = e.data; if(!this.actionHandlerMap[id]) return; // 執行相應的 Promise resolve this.actionHandlerMap[id].call(this, response); delete this.actionHandlerMap[id]; } postMessage(action) { // 實際使用中,可以指定或生成一個業務 id 作爲 key 值 const id = fakeId++; return new Promise((resolve, reject) => { const message = { id, ...action, }; this.worker.postMessage(message); this.actionHandlerMap[id] = (response) => { resolve(response); }; }); } } const mainThreadController = new MainThreadController({ workerUrl: './worker.js', workerName: 'test-worker' }); mainThreadController .postMessage({ actionType: 'asyncCalc', payload: { msg: 'send messages to worker', params: 1 }, }) .then((response) => console.log('message received from worker: ', response.msg));
對於 worker 部分的處理就簡單得多,計算處理完畢後,在響應回覆中帶上請求的 id 即可。
/* worker.js */ class WorkerThreadController { constructor() { this.worker = self; // 等待異步回調集合 this.actionHandlerMap = {}; this.worker.onmessage = this.onmessage.bind(this); } async onmessage(e) { const { id, actionType, payload } = e.data; switch (actionType) { case 'print': console.log(payload.msg); self.postMessage({ id, response: { msg: 'msg has been print.' } }); break; case 'asyncCalc': // 構造一個異步處理情形 const result = await new Promise((resolve) => setTimeout(() => resolve(payload.params * 2), 1000)); self.postMessage({ id, response: { msg: `the caculated answer is ${result}.` } }); break; default: break; } } } const workerThreadController = new WorkerThreadController();
當然,worker 這邊的改造還能夠更進一步。我們發現,當 Worker 需要接收的計算種類增多,使用 switch 方式包裹的 onmessage 函數就會變得冗長,使用字符串判斷也不夠可靠,我們可以用策略模式簡單地封裝一下 Worker 中的邏輯。
/* worker.js */ // 可以單獨抽成一個文件,然後 import 進來 const api = { print(payload) { console.log(payload.msg); return { msg: 'msg has been print.' }; }, async asyncCalc(payload) { const result = await new Promise((resolve) => setTimeout(() => resolve(payload.params * 2), 1000)); return { msg: `the caculated answer is ${result}.` }; }, }; class WorkerThreadController { constructor() { this.worker = self; // 等待異步回調集合 this.actionHandlerMap = {}; this.worker.onmessage = this.onmessage.bind(this); } async onmessage(e) { const { id, actionType, payload } = e.data; const result = await api[actionType].call(this, payload); self.postMessage({ id, payload: result }); } } const workerThreadController = new WorkerThreadController();
至此,一個簡單好用的 Promise Worker 就建立完成了。
當然,爲了增加框架的魯棒性,我們還應該加入類似於錯誤處理,報錯及監控數據上報等等能力。由於不屬於本文探討的範圍,這裏就先按住不表,有興趣的讀者可以參看 AlloyTeam 最新開源的 alloy-worker 項目,其中對上述存在的問題進行了全面的補足,是一個較爲完善的高可用的 Worker 通信框架。
總結
本文對 Web Worker 進行了簡要的介紹,包括其能力以及侷限性,讓讀者對 Worker 的使用場景有一個全面的瞭解。提出了一種封裝 Worker 原生 API 使之能被 Promise 化調用的解決方案,並在最後推薦了團隊內正在使用的功能完善的成熟解決方案,希望能幫助到近期有興趣進行 Worker 改造的前端開發者們。