像Event Emitter一樣使用Web Worker

騰訊IVWEB團隊發表於2019-12-23

本文轉載自 saul-mirone.github.io/2019/12/17/…

Web Worker可以在瀏覽器中新增可以和主執行緒通訊的獨立執行的執行緒。通過將可能阻塞主執行緒的大量計算移入web worker,我們可以保證主執行緒的流暢性,但是web worker預設的呼叫使用較為繁瑣,因此我們可以按照自己的需求進行一些封裝,本文探討一種基於事件的封裝模式。

原始呼叫

在開始之前,讓我們看看直接使用web worker中的API大概是什麼樣的。 假設我們現在使用json來傳遞資料。

  • 在worker中:
self.addEventListener('message', (e) => {
  const { data } = e;
  if (!data) return;
  dataHandler(data);
});
複製程式碼
  • 在主執行緒中:
const worker = new Worker('path/to/worker');
worker.postMessage({ data: 'some data' });
複製程式碼

看起來很清晰明瞭,似乎不需要什麼封裝。

但是當傳遞的資料量增多,頻率上升的時候,會出現明顯的效能下降。

傳遞引用

Web worker線上程間傳遞資料時,有兩種方法:

  1. 結構化克隆:預設的做法,clone一份資料給接受資料的執行緒,而不是共享例項。因此如果資料量很大,clone的成本也會隨之增高。
  2. 移交:傳遞實現了Transferable介面的資料時, 可以使用這種方式。資料會被移交到目標執行緒的上下文中,不存在複製,因此效能會得到比較明顯的提高。

具體可以參見谷歌的文件

目前實現了Transferable介面的資料型別包括: ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, 因此對於我們當前傳遞JSON結構資料這一場景, 使用ArrayBuffer是最好的選擇。

因此我們需要實現一對encodedecode方法來把資料結構轉換為ArrayBuffer

  • encode:
function encode<T>(data: T): Uint16Array {
  const str = JSON.stringify(data);
  const buf = new ArrayBuffer(str.length * 2);
  const bufView = new Uint16Array(buf);
  bufView.set(str.split("").map((_, i) => str.charCodeAt(i)));
  return bufView;
}
複製程式碼
  • decode:
function decode<T = unknown>(buf: ArrayBufferLike): T {
  return JSON.parse(
    String.fromCharCode.apply(
      null,
      (new Uint16Array(buf) as unknown) as number[]
    )
  );
}
複製程式碼

於是我們的程式碼就變成了:

  • 在worker中:
self.addEventListener('message', (e) => {
  const { data } = e;
  if (!data) return;
  dataHandler(decode(data));
});
複製程式碼
  • 在主執行緒中:
const worker = new Worker('path/to/worker');
const arrayBuffer = encode(data);
worker.postMessage(arrayBuffer.buffer, [arrayBuffer.buffer]);
複製程式碼

為每次呼叫提供響應

在web worker中,主執行緒向worker執行緒中傳送了訊息之後,就無法再追蹤這條訊息的狀態了, 只能通過子執行緒主動呼叫postMessage將狀態告知主執行緒。

假設我們現在想在子執行緒呼叫結束後讓主執行緒得到通知,我們可以給每條訊息追加一個id,通過這條id來追蹤一次呼叫的狀態:

  • 在worker中:
self.addEventListener('message', (e) => {
  const { data } = e;
  if (!data) return;
  const returnMessage = dataHandler(decode(data.message));
  const arrayBuffer = encode({ id: data.id, message: returnMessage });
  self.postMessage(arrayBuffer.buffer, [arrayBuffer.buffer]);
});
複製程式碼
  • 在主執行緒中:
const worker = new Worker('path/to/worker');
const id: string = uuid();
const arrayBuffer = encode({ id, message: data });
worker.postMessage(arrayBuffer.buffer, [arrayBuffer.buffer]);

const id2: string = uuid();
const arrayBuffer2 = encode({ id: id2, message: data });
worker.postMessage(arrayBuffer2.buffer, [arrayBuffer2.buffer]);
worker.onmessage = (e) => {
  const { data } = e;
  if (!data) return;
  const returnData = decode(data);
  if (id === returnData.id) {
    callback1(returnData.message);
  }
  
  if (id2 === message.id) {
    callback2(returnData.message);
  }
}
複製程式碼

可以看到,我們需要手動管理每次訊息傳遞的編解碼和回撥對映,寫起來十分繁瑣。 下面我們對通用的處理進行一些封裝。

基於Promise的worker呼叫

觀察程式碼,當我們每次傳送訊息給worker的時候,總是需要構造訊息的id,並新增對應的處理回撥。 和Promise完美契合。

// 主執行緒呼叫的構造類
class PromiseWorker {
  private messageMap: Map<string, Function> = new Map();
  constructor(private readonly worker: Worker) {
    worker.onmessage = e => {
      const { data } = e;
      if (!data) return;
      const { id, message } = decode(data);
      const res = this.messageMap.get(id);
      if (!res) return;
      res(message);
      this.messageMap.delete(id);
    }
  }

  emit<T, U>(message: T): Promise<U> {
    return new Promise(resolve => {
      const id = uuid();
      const data = encode({ id, message });
      this.messageMap.set(id, resolve);
      this.worker.postMessage(data.buffer, [data.buffer]);
    });
  }
}
// 子執行緒呼叫的註冊方法
function register(handler: Function) {
  const post = (message) => {
    const data = encode(message);
    self.postMessage(data.buffer, [data.buffer]);
  }
  self.onmessage = async (e: MessageEvent) => {
    const { data } = e;
    if (!data) return;

    const { id, message } = decode(data);

    const result = (await mapping[type](message)) || "done";
    post({ id, message: result });
  };
}
複製程式碼

使用時:

  • 在worker中:
register(async (message) => {
  const data = await someFetch(message);
  return someHandler(data);
})
複製程式碼
  • 在主執行緒中:
const worker = new Worker('path/to/worker');
const promiseWorker = new PromiseWorker(worker);
promiseWorker.emit(data).then(result => console.log(result));
複製程式碼

非常方便。

實現事件風格的呼叫方式

PromiseWorker已經十分好用了,但是當我們需要給傳送的訊息進行分類,並按不同型別響應的時候,難免有一些模板程式碼:

  • 在worker中:
register(async (message) => {
  switch (message.type) {
    case 'ACTION_A':
      return handler1(message.data);
    case 'ACTION_B':
      return handler2(message.data);
    default:
      return handler3(message.data);
  }
})
複製程式碼
  • 在主執行緒中:
const worker = new Worker('path/to/worker');
const promiseWorker = new PromiseWorker(worker);
promiseWorker.emit({ type: 'ACTION_A', data: dataA }).then(result => console.log(result));
promiseWorker.emit({ type: 'ACTION_B', data: dataB }).then(result => console.log(result));
複製程式碼

因此我們可以用事件模型來對訊息進行分類,按類別進行響應,其實只需要給訊息模型中新增type欄位就好了。

class WorkerEmitter {
  private messageMap: Map<
    string,
    { callback: Function; type: string | number }
  > = new Map();
  constructor(private readonly worker: Worker) {
    worker.onmessage = e => {
      const { data } = e;
      if (!data) return;

      const { id, message } = decode(data);
      const ret = this.messageMap.get(id);
      if (!ret) return;

      const { callback } = ret;

      callback(message);
      this.messageMap.delete(id);
    };
  }
  emit<T, U>(type: string | number, message: T): Promise<U> {
    return new Promise(resolve => {
      const id = uuid();
      const data = encode({
        id,
        type,
        message
      });
      this.messageMap.set(id, {
        type,
        callback: (x: U) => {
          resolve(x);
        }
      });
      this.worker.postMessage(data.buffer, [data.buffer]);
    });
  }
  terminate() {
    this.worker.terminate();
  }
}
type WorkerInstance = {
  on(type: string, handler: Function): void;
};
function register(): WorkerInstance {
  const mapping: Record<string, Function> = {};
  const post = (message: Data): void => {
    const data = encode(message);
    self.postMessage(data.buffer, [data.buffer]);
  };
  self.onmessage = async (e: MessageEvent) => {
    const { data } = e;
    if (!data) return;

    const { type, id, message } = decode(data);

    const result = (await mapping[type](message)) || "done";
    post({ id, type, message: result });
  };

  return {
    on: (type, handler) => {
      mapping[type] = handler;
    }
  };
}
複製程式碼

呼叫時:

  • 在worker中:
const worker = register();

worker.on('ACTION_A', handler1);
worker.on('ACTION_B', handler2);
複製程式碼
  • 在主執行緒中:
const worker = new Worker('path/to/worker');
const workerEmitter = new WorkerEmitter(worker);
workerEmitter.emit('ACTION_A', dataA).then(result => console.log(result));
workerEmitter.emit('ACTION_B', dataB).then(result => console.log(result));
複製程式碼

從此,使用web worker就可以像觸發事件一樣輕鬆了,以上原始碼可在Github上查閱。


我將這個封裝釋出在了npm上:worker-emitter,需要的話直接使用即可。


像Event Emitter一樣使用Web Worker

關注【IVWEB社群】公眾號檢視最新技術週刊,今天的你比昨天更優秀!


相關文章