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線上程間傳遞資料時,有兩種方法:
- 結構化克隆:預設的做法,clone一份資料給接受資料的執行緒,而不是共享例項。因此如果資料量很大,clone的成本也會隨之增高。
- 移交:傳遞實現了Transferable介面的資料時, 可以使用這種方式。資料會被移交到目標執行緒的上下文中,不存在複製,因此效能會得到比較明顯的提高。
具體可以參見谷歌的文件
目前實現了Transferable介面的資料型別包括: ArrayBuffer
, MessagePort
, ImageBitmap
, OffscreenCanvas
, 因此對於我們當前傳遞JSON結構資料這一場景, 使用ArrayBuffer是最好的選擇。
因此我們需要實現一對encode
,decode
方法來把資料結構轉換為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,需要的話直接使用即可。
關注【IVWEB社群】公眾號檢視最新技術週刊,今天的你比昨天更優秀!