解密Angular WebWorker Renderer (二)

美團點評點餐發表於2017-08-09

解密Angular WebWorker Renderer (二)

很開心再次遇見你,接著上回分解。

先把與通訊相關的類介紹完畢。

解密Angular WebWorker Renderer (二)

WebWorkerRendererFactory2類對應的就是WebWorkerRenderer2類,該類從類結構中就可以看出包含了各種對DOM節點的操作函式,基本覆蓋原生JS的DOM操作函式。特別注意,該類裡面的操作函式並不是真正地操作DOM節點,而是在WebWorker執行緒中的模擬,最後還是以訊息的形式傳送給UI主執行緒中呼叫Renderer2進行操作,後續會講到。舉例說一下其中的createElement函式:

createElement(name: string, namespace?: string): any {
  const node = this._rendererFactory.allocateNode();
  this.callUIWithRenderer('createElement', [
    new FnArg(name),
    new FnArg(namespace),
    new FnArg(node, SerializerTypes.RENDER_STORE_OBJECT),
    ]);
  return node;
}
複製程式碼

其函式體中的_rendererFactory成員變數是一個WebWorkerRendererFactory2類的例項(在建構函式中傳入),呼叫了allocateNode方法,建立一個包含事件處理的類,再通過RenderStore生成唯一Id,並儲存。然後呼叫callUIWithRenderer函式,如下:

private callUIWithRenderer(fnName: string, fnArgs: FnArg[] = []) {
  // always pass the renderer as the first arg
  this._rendererFactory.callUI(fnName, [this.asFnArg, ...fnArgs]);
}
複製程式碼

其函式體中的_rendererFactory成員變數是一個WebWorkerRendererFactory2類,呼叫了callUI方法處理DOM相關的操作函式,包含fnName(方法名)和fnArgns(引數),其中asFnArg是一個預設引數(FnArg類的一個例項),並指定了序列化的型別,因此會被存入RenderStore,定義如下:

private asFnArg = new FnArg(this, SerializerTypes.RENDER_STORE_OBJECT);
複製程式碼

那麼callUI方法是怎麼處理的呢?我們來看一下:

callUI(fnName: string, fnArgs: FnArg[]) {
  const args = new UiArguments(fnName, fnArgs);
  this._messageBroker.runOnService(args, null);
}
複製程式碼

從函式體中,可以看出呼叫了ClientMessageBroker的runOnService方法,通過該方法向UI執行緒傳送渲染指令(通過Sink的emit方法)並處理反饋資訊,這裡先做個簡單的介紹,後續會詳細介紹。

小小地總結下,WebWorkerRenderer2類定義了在WebWorker執行緒中模擬操作DOM節點的方法,並且發出指令向UI執行緒傳送資訊。

解密Angular WebWorker Renderer (二)

WebWorkerRenderer2類對應的是MessageBasedRenderer2類,前者在WebWorker執行緒中工作,後者在UI執行緒中工作。同樣的,是MessageBasedRenderer2類中也定義了豐富的DOM操作方法(與WebWorkerRenderer2類對應),這些方法才是真正意義上操作DOM的方法,通過呼叫Renderer2中的相關方法。同時,DOM的事件觸發後會通過MessageBus的Sink傳送給WebWorker執行緒中的WebWorkerRendererFactory2類做處理。

WebWorkerRenderer2類既然有個ClientMessageBroker類來作中間代理人,負責傳遞資訊,那麼,MessageBasedRenderer2類也需要一個代理人來接頭,這就是ServiceMessageBrokerFactory類。它負責註冊WebWorkerRenderer2類中DOM操作函式,並接收從ClientMessageBroker傳過來的渲染指令,然後觸發對應的方法,執行結束後,在反饋給ClientMessageBroker代理人(成功還是失敗)。

枯燥無聊的基本概念都介紹到了,接下來該幹正事了。

統觀全域性

我們看圖說話。

解密Angular WebWorker Renderer (二)

圖中只介紹了兩個通道的(RENDERER_2_CHANNEL通道和EVENT_2_CHANNEL通道)的通訊流程,還差一個ROUTER_CHANNEL通道沒有提及,其通過過程和RENDERER_2_CHANNEL通道類似,這裡就不作進一步介紹了,有興趣的請閱讀原始碼。

先來介紹一下初始化的部分(圖中的藍色部分),從左到右來介紹。首先左側MessageBasedRenderer2類初始化的時候建立ServiceMessageBroker型別作為自己的通訊代理人,同時初始化兩條通道,建立PostMessageBusSink和Source為通訊做準備(其中事件通道中只用到了Sink),並對Source訂閱事件。右側的WebWorker執行緒中的初始化操作類似,用到的類不同而已。

在啟動時(圖中的黃色部分),MessageBasedRenderer2會在start函式中呼叫registerMethod方法去向ServiceMessageBroker註冊DOM操作相關的方法,儲存在_methods中,等待觸發。

接下來,就是正式執行渲染環節了,避免錯亂,更新下圖,如下:

解密Angular WebWorker Renderer (二)

圖中顯示了3種顏色,代表了3個執行緒間通訊的過程,從綠色的開始說。

在WebWorker執行緒中,Angular引擎首先會根據頁面佈局拆分為細化的DOM節點操作,並執行渲染操作。通過callUI呼叫Broker中的runOnService方法,並儲存在_pending容器(存的是什麼東西?)中,同時使用Sink向UI執行緒的Source傳送訊息,包含id、方法名和引數。UI執行緒的代理人接收到資訊後,觸發相應的訂閱事件_handleMessage方法,該方法就去_mehtods中找MessageBassedRenderer2在啟動時候註冊的對應方法並執行,具體過程就是呼叫Renderer2中相應的DOM操作方法。

等DOM操作結束,就進入了藍色標示的過程,其實是個反饋的過程。通過Sink向WebWorker執行緒傳送訊息,訊息內容如下:

{
 'type': 'result',
 'value': this._serializer.serialize(result, type),
 'id': id,
}
複製程式碼

其中type型別是‘result’(其實還可能是'error',本文未指出),WebWorker執行緒收到訊息後,會觸發在初始化時候定義的訂閱事件,執行_handleMessage方法,操作_pengding容器,根據id獲取對應條目執行(執行的是什麼?)並且刪除,這樣這個藍色過程就結束了。

那麼_pending容器裡面存的是什麼?執行的又是什麼?從字面理解是應該是用於儲存'待解決'的事務,這也回答了,怎麼處理執行緒間併發這個問題?在此解答一下,先上相關程式碼:

interface PromiseCompleter {
  resolve: (result: any) => void;
  reject: (err: any) => void;
}
// ...
let completer: PromiseCompleter = undefined !;
let promise = new Promise((resolve, reject) => { completer = {resolve, reject}; });
let id = this._generateMessageId(args.method);
// 儲存
this._pending.set(id, completer); 
// catch和then
promise.catch((err) => {...});
promise = promise.then((v: any) => this._serializer ? this._serializer.deserialize(v, returnType) : v); 
// ... 反饋時
if (message.type === 'result') {
    this._pending.get(id) !.resolve(message.value);
} else {
    this._pending.get(id) !.reject(message.value);
}
this._pending.delete(id);
// ...
複製程式碼

_pending容器裡面存了id和與之對應的Promise物件的 {resolve, reject},並且預先定義好then方法(這裡是做了反序列化操作,並沒有其餘操作),當WebWorker執行緒接收到type為'result' or 'error'的訊息時,並對應執行resolve或者reject方法,以此釋放Promise。相信你已經明白了,執行緒間併發問題就是用過Promise方法來完成同步。

最後來到紫色的過程,UI執行緒中DOM節點繫結的事件觸發後,通過Sink通過事件通道向WebWorker執行緒的Source傳送訊息,WebWorker執行緒收到訊息後,觸發相應的訂閱方法,這裡不像渲染通道一樣,有反饋過程。可能的原因時,與事件相關的方法(大部分是在對DOM節點操作),還是通過渲染通道(通過綠色和藍色的過程)通知給UI執行緒。

這樣整個WebWoker Renderer的執行緒間通訊的部分就介紹完畢了。

回顧下一開始提出的三個問題:

  • 通訊資訊如何序列化與反序列化?記憶體資料如何共享? 答:通過RenderStore類。
  • 如何打破Webworker執行緒不能操作DOM節點的侷限? 答:通過RENDERER_2_CHANNEL通道。
  • 如何處理併發?答:通過操作反饋與Promise機制。

還沒完

我猜你一定想知道Angular中是怎麼啟動WebWorker執行緒並執行渲染操作?如何開啟UI執行緒中的start方法?來來來,慢慢絮叨。

我們從如何將傳統的Angular專案(基於platformBrowserDynamic的JIT專案或者基於platformBrowser的AOT專案)轉換成基於platformWorkerAppDynamic的WebWorker專案,可以參考本文一開始提供的DEMO專案或者參考文章《Angular with Web Workers: Step by step》。基於現有的WebWorker專案,我們來講解一下,啟動過程。

首先會呼叫@angular/platform-webworker中的bootstrapWorkerUi方法啟動一個WebWorkerLoader,傳入的是一個WebPack打包輸出的webworker.bundle.js(可在webpack.config.js輸出),基於的檔案就是一個platformWorkerAppDynamic的啟動檔案,其餘的配置與傳統項無異,由此可見改動成本還是比較小的。

主要還是來看一下bootstrapWorkerUi方法做了些什麼?

export function bootstrapWorkerUi(
    workerScriptUri: string, customProviders: Provider[] = []): Promise<PlatformRef> {
  // For now, just creates the worker ui platform...
  const platform = platformWorkerUi([
    {provide: WORKER_SCRIPT, useValue: workerScriptUri},
    ...customProviders,
  ]);
  return Promise.resolve(platform);
}
複製程式碼

從程式碼中看出主要是建立一個platformWorkerUi物件,講loader檔案地址傳入。

這麼關鍵的一個platformWorkerUi我們簡單來將想,該物件通過createPlatformFactory(Angular/core中的方法)建立,並傳入一組Provider,包括MessageBasedRenderer2SerializerRenderStoreMessageBus等之前介紹過的注入類,

還有一個關鍵的WebWorkerInstance,申明如下:

@Injectable()
export class WebWorkerInstance {
  public worker: Worker;
  public bus: MessageBus;
  /** @internal */
  public init(worker: Worker, bus: MessageBus) {
    this.worker = worker;
    this.bus = bus;
  }
}
複製程式碼

作為WebWorker的一個例項,包含Woker物件的例項,和MessageBus的例項,那麼什麼時候呼叫的init方法初始化呢?接著往下看,

Provider中還有一個關鍵init時需要的注入類,描述如下:

{
    provide: PLATFORM_INITIALIZER,
    useFactory: initWebWorkerRenderPlatform,
    multi: true,
    deps: [Injector]
}
複製程式碼

裡面使用了initWebWorkerRenderPlatform方法,提取梳理出關鍵的步驟:

const webWorker: Worker = new Worker(url);
const sink = new PostMessageBusSink(webWorker);
const source = new PostMessageBusSource(webWorker);
const bus = new PostMessageBus(sink, source);
WebWorkerInstance.init(webWorker, bus);

// initialize message services after the bus has been created
const services = injector.get(WORKER_UI_STARTABLE_MESSAGING_SERVICE);
// 這裡的 WORKER_UI_STARTABLE_MESSAGING_SERVICE 在應用中歸根到底其實就是呼叫的MessageBasedRenderer2類
zone.runGuarded(() => { services.forEach((svc: any) => { svc.start(); }); });
複製程式碼

根據url路徑建立Worder物件,該對應用於PostMessageBusSinkPostMessageBusSource物件初始化,比如在PostMessageBusSource初始化中會對Worker物件addEventListener監聽'message'事件。然後使用sink和source例項化PostMessageBus類,再呼叫WebWorkerInstance物件的init方法。最後,將注入的MessageBasedRenderer2類自動呼叫start方法。

總結

說一下自己的感受,不要為了用WebWorker執行緒而用,還是要集合多方面因素來考慮,比如執行緒間通訊時間,開啟執行緒的效能消耗等,畢竟WebWorker提出的初衷是為了那些計算密集型的操作,被Angular框架使用到渲染中,是一個有突破的創新,但目前並不能支援所有專案的轉換,還不穩定(@experimental),請謹慎使用。但相信隨著瀏覽器的發展,為了極致化使用者體驗,WebWorker的渲染勢必會被各大主流前端框架考慮在內。

至此,Angular WebWorker Renderer的前前後後的原始碼解析就解密完了,肯定有諸多解析不到位的地方,歡迎留言吐槽。

最後的最後,歡迎加入我們的隊伍charway@qq.com。

參考

Angular Platform-Webworker 原始碼

Angular Web Worker - Building Super Responsive UI

Using Web Workers for more responsive apps

Angular with Web Workers: Step by step


相關文章