如何無痛的為你的前端項目引入多線程
導(dǎo)語 本文旨在介紹一種簡單,優(yōu)雅,代碼侵入性小的web worker引入方式。能夠在不影響現(xiàn)有業(yè)務(wù)邏輯的情況下,快速為你的前端項目引入多線程能力。
一、談?wù)刉eb Worker
眾所周知,JavaScript引擎是單線程的,這意味著所有的操作都會在主線程當(dāng)中發(fā)生。
盡管瀏覽器內(nèi)核是多線程的,但是負(fù)責(zé)頁面渲染的UI線程總是會在JS引擎線程空閑時(執(zhí)行完一個macro task)才會執(zhí)行。

JavaScript的事件隊列模型
這意味著如果頁面當(dāng)中包含某些計算密集的代碼時,因為JS引擎是單線程的,會阻塞整個事件隊列,進(jìn)而導(dǎo)致整個頁面卡住。
而Web Worker就是為了解決這個問題而生的。
這里引用一段阮一峰老師的定義
Web Worker 的作用,就是為 JavaScript 創(chuàng)造多線程環(huán)境,允許主線程創(chuàng)建 Worker 線程,將一些任務(wù)分配給后者運行。在主線程運行的同時,Worker 線程在后臺運行,兩者互不干擾。等到 Worker 線程完成計算任務(wù),再把結(jié)果返回給主線程。這樣的好處是,一些計算密集型或高延遲的任務(wù),被 Worker 線程負(fù)擔(dān)了,主線程(通常負(fù)責(zé) UI 交互)就會很流暢,不會被阻塞或拖慢。
二、多線程的力量
我這里寫了一個簡單示例,來展示將復(fù)雜的計算邏輯移出主線程能帶來多大的提升。
這里我們假設(shè)頁面存在一個很復(fù)雜的計算操作,需要耗費好幾秒才能完成。由于JS引擎是單線程的,如果在主線程里執(zhí)行這個計算邏輯,我們將看到頁面將在好幾秒內(nèi)是無法響應(yīng)的。從用戶視角來看,就是整個頁面“卡住了”。毫無疑問,這是非常令人挫敗的體驗。

然后我們看一下另一個做法,把這個計算邏輯放到worker線程當(dāng)中去計算,計算完畢后再將結(jié)果傳回主線程。

可以看到,復(fù)雜的計算操作一點也沒有影響UI線程的運行,頁面一直在流暢的更新,并且一點都不阻塞操作。
從上面這個簡單的例子可以看出,僅僅是將計算邏輯轉(zhuǎn)移到worker線程,就能夠帶來多大的變化。
2.1 不得不提的兼容性
web worker的兼容性非常好。

一個小缺點
web worker提出的時間非常早,這是它兼容性好的原因。但是也是問題所在,web worker原生的API設(shè)計得非常古老,是基于事件訂閱的,不是特別好用。引入項目當(dāng)中的成本還是很高的。
//in main.jsfirst.onchange = function() {myWorker.postMessage([first.value,second.value]);console.log('Message posted to worker');}//in worker.jsonmessage = function(e) {console.log('Message received from main script');var workerResult = 'Result: ' + (e.data[0] * e.data[1]);console.log('Posting message back to main script');postMessage(workerResult);}
2.2 一個顧慮,postMessage真的很慢么?
除了古老的API設(shè)計以外,很多開發(fā)者對于web worker還有個顧慮就是,聽說postMessage很慢。
畢竟將數(shù)據(jù)作為參數(shù)傳遞給postMessage的時候,實際上會先將數(shù)據(jù)序列化為字符串,然后當(dāng)worker接收到傳遞過來的數(shù)據(jù)(序列化后的字符串)時,還需要將字符串?dāng)?shù)據(jù)反序列化一次才能使用。
而worker再回返數(shù)據(jù)給主線程的時候,同樣也要先經(jīng)歷一次序列化,然后字符串?dāng)?shù)據(jù)到了主線程以后,還需要再反序列化一次。
這中間涉及到四次數(shù)據(jù)的變換,從直覺上來看,開發(fā)者理所當(dāng)然的會擔(dān)憂性能層面的問題。
不過我們先把這個問題拆解一下。
三、數(shù)據(jù)變換的負(fù)擔(dān)
首先,只有發(fā)生在主線程代碼當(dāng)中的數(shù)據(jù)轉(zhuǎn)換,才會對主線程造成負(fù)擔(dān)。這意味著只有兩種情況下,主線程才會分配計算資源。
傳輸數(shù)據(jù)(序列化)
收到數(shù)據(jù)(反序列化)
發(fā)生在woker當(dāng)中的數(shù)據(jù)轉(zhuǎn)換是由worker線程負(fù)擔(dān)的,對主線程是毫無影響的。
3.1 序列化和反序列化的性能問題
接下來是第二個問題,序列化和反序列化對性能的影響有多大?
Google的Surma做了一個關(guān)于postMessage性能的詳細(xì)測試,這里我只放出他得出的結(jié)論。
如果想要詳細(xì)了解相關(guān)情況,可以點擊下面的鏈接閱讀他的詳細(xì)文章。
Is postMessage slow?
3.2 兩個關(guān)鍵數(shù)字
| 序列化后的數(shù)據(jù)大小 | 傳輸耗時 | 典型場景 |
|---|---|---|
| 100kb | 100ms | 用戶可感知到的最短時間(如果超過這個時間,用戶會開始感覺到卡頓) |
| 10kb | 16ms | 流暢動畫(60 FPS)的一幀 |
四、Comlink——新瓶裝舊酒
正如之前談到的,webWorker實際上是非常有用的,只是它的API稍微古老了一點,它是基于事件訂閱的,不是特別好用。稍微時髦一點的說法就是,給開發(fā)者帶來的心智負(fù)擔(dān)相對來說比較大。
上文當(dāng)中提到的Surma設(shè)計了一套更加現(xiàn)代化的API,將postMessage的細(xì)節(jié)封裝了起來,使得在向worker線程傳遞數(shù)據(jù)的時候,更加像是將變量的訪問權(quán)共享給了其他線程。
下面我們簡要看一下Comlink的官方給出的一個示例,一個簡單的計數(shù)器。
// main.jsimport * as Comlink from "https://unpkg.com/comlink?module";const worker = new Worker("worker.js");// This `state` variable actually lives in the worker!const state = await Comlink.wrap(worker);await state.inc();console.log(await state.currentCount);
// worker.jsimport * as Comlink from "https://unpkg.com/comlink?module";const state = {currentCount: 0,inc() {this.currentCount++;}}Comlink.expose(state);
實際上看完這個計數(shù)器的例子,你就已經(jīng)完全搞懂Comlink該如何使用了,就這么簡單。
Comlink精妙的地方,我個人認(rèn)為在于將數(shù)據(jù)傳遞的操作變成了一個異步的操作,這樣我們就能很好的利用ES6所提供的async/await語法糖,將數(shù)據(jù)的傳遞與接收邏輯寫得非常簡潔優(yōu)雅。開發(fā)者不需要再去考慮事件訂閱所帶來的各種復(fù)雜度。
4.1 和現(xiàn)有框架結(jié)合
Comlink雖然只是一個簡單的工具庫,但是將它引入到現(xiàn)有的頁面邏輯里,其實是非常簡單的。并且代碼侵入性是非常小的,我們并不需要大規(guī)模改造現(xiàn)有的代碼,就能享受到webWorker帶來的便利性。
下面我將給出兩個簡單的示例,展示如何讓Comlink和Vue以及Vuex和諧的運轉(zhuǎn)在一起。(React和Redux其實也是相同的道理,這里我就不贅述了)。
完整示例代碼可以從這里找到
https://git.code.oa.com/sihanhu/web-worker-demo
4.2 Comlink + Vue
Dom部分非常簡單,就是一個普通的計數(shù)器
<div id="app"><div class="counter">Counter is {{ counter }}<button @click="addCounter">Add</button></div></div>
Vue部分,實際上創(chuàng)建Worker之后,使用wrap方法將這個Worker變?yōu)橐粋€proxy對象(ES6特性),就能夠訪問woker當(dāng)中暴露的對象的任何屬性了。唯一需要留心的就是,這是個異步的操作。
// mainvar app = new Vue({el: '#app',data: {counter: 0,remoteState: {},},methods:{async initWorker() {const worker = new Worker("./worker.js");this.remoteState = Comlink.wrap(worker);},async addCounter() {const count = this.counter;this.counter = await this.remoteWorker.inc(count);}},mounted(){this.initWorker();}})// worker.jsconst obj = {inc(count) {return count+1;},};Comlink.expose(obj);
4.3 Comlink + Vue + Vuex
和Vuex的結(jié)合其實也很簡單。從worker線程當(dāng)中獲取值是一個異步操作,只要我們將它封裝成一個Action就可以了,非常自然。
const worker = new Worker("vuexWorker.js");const counterState = Comlink.wrap(worker);const store = new Vuex.Store({state: {count: 0},mutations: {setCount: (state, value) => state.count = value,},actions:{async changeCount ({ commit }, value) {const count = await counterState.changeCounter(value)commit('setCount', count)},}})var app = new Vue({el: '#app',computed: {count () {return store.state.count}},methods:{async addCounter() {store.dispatch('changeCount', 1)},async minusCounter() {store.dispatch('changeCount', -1)}},})// worker.jsconst obj = {changeCounter(count, value) {return count + value;},};Comlink.expose(obj);
五、總結(jié)
將復(fù)雜的計算操作從主線程轉(zhuǎn)移到其他線程是一個簡單卻又收益巨大的改進(jìn),我非常推薦你試一試。
5.1 我們可能并不需要Comlink
看到這里,肯定有一些讀者心中還有疑慮,因為實際上Comlink還提供了其他能力,為什么我卻沒有提及呢?
因為我們實際上需要的只是將postMessage的數(shù)據(jù)傳遞包裝成一個異步的操作,并且暴露出一個proxy對象供主線程便利的操作Worker線程的數(shù)據(jù)。
這意味著實際上我們并不一定需要使用Comlink。如果有興趣的話,也可以自己用Promise和Proxy封裝一個更加輕量級的版本。
比如Comlink也提供一個方法,能夠?qū)⒒卣{(diào)函數(shù)傳給Worker線程,然后Worker線程計算完畢再后將結(jié)果傳回來。
但是我個人并不建議去使用這種特性,因為這會讓主線程的代碼太過于復(fù)雜了,如果編寫得不夠好,很多地方會變得難以理解,就像是“黑魔法”一樣。
5.2 兩個建議
在此我給兩個建議,約束對webWorker的使用,避免代碼過于復(fù)雜化。
只將包含復(fù)雜計算的操作轉(zhuǎn)移到worker線程當(dāng)中
沒有必要把所有的計算邏輯都從主線程剝離,那樣worker.js就太重了。
最好將worker.js作為外掛插件,只容納包含復(fù)雜計算的邏輯,這樣對現(xiàn)有代碼的侵入性和改造量也比較小。
只在worker.js當(dāng)中執(zhí)行計算邏輯
理想的worker.js應(yīng)該只暴露一個全部是計算函數(shù)的對象。
盡量不要在worker線程當(dāng)中再額外維持一份數(shù)據(jù)狀態(tài)了,否則線程間的狀態(tài)同步是大問題
最后
歡迎加我微信(winty230),拉你進(jìn)技術(shù)群,長期交流學(xué)習(xí)...
歡迎關(guān)注「前端Q」,認(rèn)真學(xué)前端,做個專業(yè)的技術(shù)人...


