衆所周知,JavaScript引擎是單線程的,這意味着全部的操做都會在主線程當中發生。html
儘管瀏覽器內核是多線程的,可是負責頁面渲染的UI線程老是會在JS引擎線程空閒時(執行完一個macro task)纔會執行。vue
JavaScript的事件隊列模型,做者是Lydia Hallieweb
這意味着若是頁面當中包含某些計算密集的代碼時,由於JS引擎是單線程的,會阻塞整個事件隊列,進而致使整個頁面卡住。vuex
而Web Worker就是爲了解決這個問題而生的。瀏覽器
這裏引用一段阮一峯老師的定義微信
Web Worker 的做用,就是爲 JavaScript 創造多線程環境,容許主線程建立 Worker 線程,將一些任務分配給後者運行。在主線程運行的同時,Worker 線程在後臺運行,二者互不干擾。等到 Worker 線程完成計算任務,再把結果返回給主線程。這樣的好處是,一些計算密集型或高延遲的任務,被 Worker 線程負擔了,主線程(一般負責 UI 交互)就會很流暢,不會被阻塞或拖慢。markdown
我這裏寫了一個簡單示例,來展現將複雜的計算邏輯移出主線程能帶來多大的提高。多線程
這裏咱們假設頁面存在一個很複雜的計算操做,須要耗費好幾秒才能完成。因爲JS引擎是單線程的,若是在主線程裏執行這個計算邏輯,咱們將看到頁面將在好幾秒內是沒法響應的。從用戶視角來看,就是整個頁面「卡住了」。毫無疑問,這是很是使人挫敗的體驗。 app
而後咱們看一下另外一個作法,把這個計算邏輯放到worker線程當中去計算,計算完畢後再將結果傳回主線程。 框架
能夠看到,複雜的計算操做一點也沒有影響UI線程的運行,頁面一直在流暢的更新,而且一點都不阻塞操做。
從上面這個簡單的例子能夠看出,僅僅是將計算邏輯轉移到worker線程,就可以帶來多大的變化。
不得不提的兼容性
web worker的兼容性很是好。 一個小缺點
web worker提出的時間很是早,這是它兼容性好的緣由。可是也是問題所在,web worker原生的API設計得很是古老,是基於事件訂閱的,不是特別好用。
引入項目當中的成本仍是很高的。
//in main.js
first.onchange = function() {
myWorker.postMessage([first.value,second.value]);
console.log('Message posted to worker');
}
//in worker.js
onmessage = 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);
}
複製代碼
除了古老的API設計之外,不少開發者對於web worker還有個顧慮就是,據說postMessage很慢。
畢竟將數據做爲參數傳遞給postMessage的時候,實際上會先將數據序列化爲字符串,而後當worker接收到傳遞過來的數據(序列化後的字符串)時,還須要將字符串數據反序列化一次才能使用。
而worker再回返數據給主線程的時候,一樣也要先經歷一次序列化,而後字符串數據到了主線程之後,還須要再反序列化一次。
這中間涉及到四次數據的變換,從直覺上來看,開發者理所固然的會擔心性能層面的問題。
不過咱們先把這個問題拆解一下。
數據變換的負擔
首先,只有發生在主線程代碼當中的數據轉換,纔會對主線程形成負擔。這意味着只有兩種狀況下,主線程纔會分配計算資源。
發生在woker當中的數據轉換是由worker線程負擔的,對主線程是毫無影響的。
序列化和反序列化的性能問題
接下來是第二個問題,序列化和反序列化對性能的影響有多大?
Google的Surma作了一個關於postMessage性能的詳細測試,這裏我只放出他得出的結論。
若是想要詳細瞭解相關狀況,能夠點擊下面的連接閱讀他的詳細文章。
兩個關鍵數字
序列化後的數據大小 | 傳輸耗時 | 典型場景 |
---|---|---|
100kb | 100ms | 用戶可感知到的最短期(若是超過這個時間,用戶會開始感受到卡頓) |
10kb | 16ms | 流暢動畫(60 FPS)的一幀 |
正如以前談到的,webWorker其實是很是有用的,只是它的API稍微古老了一點,它是基於事件訂閱的,不是特別好用。稍微時髦一點的說法就是,給開發者帶來的心智負擔相對來講比較大。
上文當中提到的Surma設計了一套更加現代化的API,將postMessage的細節封裝了起來,使得在向worker線程傳遞數據的時候,更加像是將變量的訪問權共享給了其餘線程。
下面咱們簡要看一下Comlink的官方給出的一個示例,一個簡單的計數器。
// main.js
import * 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.js
import * as Comlink from "https://unpkg.com/comlink?module";
const state = {
currentCount: 0,
inc() {
this.currentCount++;
}
}
Comlink.expose(state);
複製代碼
實際上看完這個計數器的例子,你就已經徹底搞懂Comlink該如何使用了,就這麼簡單。
Comlink精妙的地方,我我的認爲在於將數據傳遞的操做變成了一個異步的操做,這樣咱們就能很好的利用ES6所提供的async/await語法糖,將數據的傳遞與接收邏輯寫得很是簡潔優雅。開發者不須要再去考慮事件訂閱所帶來的各類複雜度。
和現有框架結合
Comlink雖然只是一個簡單的工具庫,可是將它引入到現有的頁面邏輯裏,實際上是很是簡單的。而且代碼侵入性是很是小的,咱們並不須要大規模改造現有的代碼,就能享受到webWorker帶來的便利性。
下面我將給出兩個簡單的示例,展現如何讓Comlink和Vue以及Vuex和諧的運轉在一塊兒。(React和Redux其實也是相同的道理,這裏我就不贅述了)。
Comlink + Vue
Dom部分很是簡單,就是一個普通的計數器
<div id="app">
<div class="counter">
Counter is {{ counter }}
<button @click="addCounter">Add</button>
</div>
</div>
複製代碼
Vue部分,實際上建立Worker以後,使用wrap方法將這個Worker變爲一個proxy對象(ES6特性),就可以訪問woker當中暴露的對象的任何屬性了。惟一須要留心的就是,這是個異步的操做。
// main
var 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.js
const obj = {
inc(count) {
return count+1;
},
};
Comlink.expose(obj);
複製代碼
Comlink + Vue + Vuex
和Vuex的結合其實也很簡單。從worker線程當中獲取值是一個異步操做,只要咱們將它封裝成一個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.js
const obj = {
changeCounter(count, value) {
return count + value;
},
};
Comlink.expose(obj);
複製代碼
將複雜的計算操做從主線程轉移到其餘線程是一個簡單卻又收益巨大的改進,我很是推薦你試一試。
咱們可能並不須要Comlink
看到這裏,確定有一些讀者心中還有疑慮,由於實際上Comlink還提供了其餘能力,爲何我卻沒有說起呢?
由於咱們實際上須要的只是將postMessage的數據傳遞包裝成一個異步的操做,而且暴露出一個proxy對象供主線程便利的操做Worker線程的數據。
這意味着實際上咱們並不必定須要使用Comlink。若是有興趣的話,也能夠本身用Promise和Proxy封裝一個更加輕量級的版本。
好比Comlink也提供一個方法,可以將回調函數傳給Worker線程,而後Worker線程計算完畢再後將結果傳回來。
可是我我的並不建議去使用這種特性,由於這會讓主線程的代碼太過於複雜了,若是編寫得不夠好,不少地方會變得難以理解,就像是「黑魔法」同樣。
兩個建議
在此我給兩個建議,約束對webWorker的使用,避免代碼過於複雜化。
沒有必要把全部的計算邏輯都從主線程剝離,那樣worker.js就過重了。
最好將worker.js做爲外掛插件,只容納包含複雜計算的邏輯,這樣對現有代碼的侵入性和改造量也比較小。
理想的worker.js應該只暴露一個所有是計算函數的對象。
儘可能不要在worker線程當中再額外維持一份數據狀態了,不然線程間的狀態同步是大問題
更多精彩內容,盡請關注騰訊VTeam技術團隊微信公衆號和視頻號
原做者:Sihan Hu
未經贊成,禁止轉載!