動態建立 Web Worker

sea_ljf發表於2017-12-30

hello~親愛的看官老爺們大家好~最近入職新公司後,接手視覺化平臺的開發工作。經過收集意見後,發現使用者普遍吐槽渲染時太卡。檢視介面後發現,後端直接返回查詢資料庫後的原始結果,需要頁端傻乎乎地迴圈處理各種資料。當資料量大的時候,使用者就只能傻看著瀏覽器卡死。

優化方案相信大家都有想法,在無法改後端資料體的情況下,最低成本的當然是引入 Web Worker,將處理邏輯丟給它就好。但由於各種原因,建一個 Web Worker 檔案再求後端爸爸部署,暫時是不太可行的。那麼,腦洞大開想一想,能不能頁端動態建立一個呢?

於是,提出瞭如下問題:

  • 是否能動態建立 Web Worker
  • 如能建立的話,希望 API 呼叫是物件導向風格,而且支援 Pormise 呼叫。
  • 為節省資源起見,這個動態建立的 Web Worker 希望是可複用的。

帶著這些問題,開始瞎折騰之旅吧!

動態建立 Web Worker

Web Worker 是什麼,相信大家都瞭然於胸,一般的引用方法是請求一個 JS 檔案,即:

const worker = new Wokrer(worker's url);
複製程式碼

因而,問題可以轉化為,我們如何將一個函式,轉換成瀏覽器可識別並能重新解析為該函式的 url 呢?之前看 iframe和HTML5 blob實現JS,CSS,HTML直接當前頁預覽這篇文章時,接觸到 URL.createObjectURL(),文中對此的描述是:“使用 URL.createObjectURL() 方法將 Blob 物件轉換為 URL 物件並賦予我們建立的 iframe元素的src屬性。”那麼,是否能將其用於建立 Web Workerurl 呢?查閱 MDN 文件後發現了這麼一句:

Note: 此特性在 Web Worker 中可用。

喜大普奔,計劃通!該 API 接受 File 物件或者 Blob 物件作為引數,此處我們使用 Blob 物件作為引數並指定型別為 text/javascript,測試 demo 如下:

function demo() {
  setTimeout(() => {
    postMessage('success!');
  }, 1000)
}

const blob = new Blob([demo.toString() + ' demo()'], { type: 'text/javascript' });
const worker = new Worker(URL.createObjectURL(blob));

worker.addEventListener('message', function(res) {
  console.log(res);
})
複製程式碼

開啟 Chrome,在控制檯中輸入上面的程式碼,隔一秒之後便能列印出:MessageEvent {isTrusted: true, data: "success!", origin: "", lastEventId: "", source: null, …}。實驗通過,可以確認 Web Worker 能被動態建立。

封裝 API

既然能動態建立,作為程式設計師,肯定是不希望每次都手動建立,因而封裝一下以便於使用。由於可能會多處使用,因而封裝成一個“類”,有助於程式碼複用與節省記憶體。建構函式接受一個函式作為引數即可,這個“類”應有一個 send 方法讓我們再想傳送資料時呼叫。那麼基礎的架子應該是:

class DynamicWorker {
  constructor(cb) {
    //根據 cb 建立 Web Worker
  }

  send(data) {
    //傳入引數後傳送資料給 Web Worker,Web Worker處理後返回
  }
}
複製程式碼

鑑於創造的 Web Worker 不是馬上就處理資料,而是在 DynamicWorker 例項呼叫 send 方法時才開始幹活,因而不能像上面的 demo 一樣直接寫死 postMessage,而應該多構建一個 onmessage 函式,因而 constructor 函式構建如下:

constructor(cb) {
    const _fn = `const _fn = ${cb.toString()};`;

    const _handle = ` onmessage =  function ({ data }) {
      postMessage(_fn(data));
    }`;

    const blob = new Blob([_fn + _handle], { type: 'text/javascript' });
    this.worker = new Worker(URL.createObjectURL(blob));
    //釋放被引用的 url 物件,經過測試,就算釋放了也能重複訪問建立的 Web Worker的
    URL.revokeObjectURL(blob);
}
複製程式碼

稍微需要解釋下的是為何需要在 Web Worker 中創造一個 _fn 變數,這是由於生成 Blob 物件時,接受的引數是字串陣列,如果只是 cb.toString() 的話,是拿不到函式名的,那麼在 Web Worker 中執行該函式更是無從談起,因而賦值一個變數,在 message 事件觸發後使用 postMessage 返回函式處理後的結果。

之後就是 send 方法了,該方法返回一個 Promise,當接收到 Web Worker 傳回的資料後,改變 Promise 的狀態。根據這個思路,有這樣的設計:

send(data) {
    const worker = this.worker;
    let resolve = null;

    function _handleResult({ data }) {
      resolve(data);
    }

    worker.addEventListener('message', _handleResult);
    worker.postMessage(data);

    return new Promise((res) => {
      resolve = res;
    })
}
複製程式碼

根據思路看程式碼,其實是很好理解的。複製 DynamicWorker 的程式碼丟去控制檯,執行下面的程式碼:

const test = new DynamicWorker(function(data) {
  return data;
})
test.send(123).then(res => console.log(res));
複製程式碼

就能愉快地看到瀏覽器列印出 123了。

複用與優化

上述的程式碼基本能達到我們的預期,但存在若干問題,如果短時間內呼叫多次 send 方法,那麼後呼叫的方法會得出前面前面的結果。這是由於 worker.addEventListener('message', _handleResult) 區分不出每次呼叫,因而收到訊息後一律執行 resolve,大家應該都知道 Promise 的狀態決議後就無法再修改的,因而導致呼叫錯誤。此處可以通過新增一個標誌位解決。另一個問題是潛藏的,為 worker 新增太多的事件監聽器了,其實這大可不必,一個 DynamicWorker 例項一個事件監聽器就足夠了。結合這兩點,修改一下對應的程式碼,首先是 constructor

...
const _handleResult = ({ data: { data, flag } }) => {
    const _res = this._map[flag];
    if (_res) {
        _res(data);
        this._map[flag] = null;
    }
}
this._map = {};
this.worker.addEventListener('message', _handleResult);
...
複製程式碼

為其添上這幾行程式碼即可,通過 _map 快取不同的標誌,而根據不同的標誌可以區分出不同的 Promise,根據標誌位對應的值呼叫即可。同理,send 方法也需要作出修改,為其新增標誌的生成,完整的 DynamicWorker 程式碼如下:

class DynamicWorker {
  constructor(cb) {
    const _fn = `const _fn = ${cb.toString()};`;

    const _handle = ` onmessage =  function ({ data: { data, flag } }) {
      postMessage({
        data: _fn(data),
        flag
      });
    }`;

    const _handleResult = ({ data: { data, flag } }) => {
      const _res = this._map[flag];
      if (_res) {
        _res(data);
        this._map[flag] = null;
      }
    }
    
    const blob = new Blob([_fn + _handle], { type: 'text/javascript' });
    this.worker = new Worker(URL.createObjectURL(blob));
    this._map = {};
    this.worker.addEventListener('message', _handleResult);
    URL.revokeObjectURL(blob);
  }

  send(data) {
    const worker = this.worker;
    const flag = Math.random();
    worker.postMessage({
      data,
      flag,
    });

    return new Promise((res) => {
      this._map[flag] = res;
    })
  }
}
複製程式碼

還是挺簡單易懂的對不對?呼叫 API 是沒有變化的,優化的只是內部的邏輯,有興趣的同學可以複製進去控制檯,隨意寫個需要運算很久的函式丟進去,嘗試下瀏覽器是否不會卡住哦~

小結

這個 DynamicWorker 還只是未成品,缺少錯誤處理,非同步處理等功能。而且應該有同學可能會懷疑這樣的東西到底有何用途,相容性上也有不少問題,當時我寫完程式碼後也有這個疑問(因為下個月就會接入 node.js,可以自己部署檔案)。思考後認為,對比正常使用 Web Worker,動態建立最大的優點是在於動態,不依賴於伺服器部署檔案,相對靈活易用。對於某些需要依賴運算大量資料,但又無法控制伺服器的場景來說,不失為一個可行的解決思路。

最後,有個問題想請教一下,在需要處理大量資料的場景下,node.js 也不是一個好的選擇,只是因為頁端效能差與只是內部系統的關係,將處理邏輯上移到 node.js,那麼正確的處理方式或者架構是怎樣的呢?還望有相關經驗的同學不吝賜教。

感謝各位看官大人看到這裡,知易行難,希望本文對你有所幫助~謝謝!

相關文章