解密Angular WebWorker Renderer (一)

先来做个对比

开发框架版本:Angular 4.x

项目地址:Charway/angular-webworker-renderer-demo

运行结果:传统的UI线程渲染效果(上图),使用WebWorker线程渲染(下图)



从动图中很明显可以看出,使用了WebWorker Renderer渲染的页面运行流畅,没有卡顿。

简单介绍下Web WokerWeb Workers是一种机制,通过它可以使一个脚本操作在与Web应用程序的主执行线程分离的后台线程中运行。这样做的优点是可以在单独的线程中执行繁琐的处理,让主(通常是UI)线程运行而不被阻塞/减慢。 —— Web Workers API from MDN

简单来说,在出现WebWoker之前,Web开发人员无法手动在浏览器中创建线程,而出现WebWoker之后,Web开发人员可以进入多线程开发Web项目了。

Web Worker的优势

下面根据AngularConf的YouTube视频(见参考)中的内容总结了下使用WebWorker的优势:

  • 运行过程中不会阻碍主线程(UI渲染线程)的运行,特别适合执行计算密集型的程序
  • WebWorker线程可跨窗口或frames(使用SharedWorker)
  • 使用WebWorker后能更优雅地执行测试过程(一些脱离可DOM操作的测试)
  • 兼容性(IE 10+)
  • 更高效地利用电量

对于最后一点的解释,应该先转化为另外一个问题,一些计算密集型的程序为什么不在服务端执行完毕后返回给前端?这在视频中也给出了解释,作者总结了一句话:It costs more to transmit a byte than to compute it,意思是传输一个byte比计算出一个byte的消耗更大。为什么呢?自己想吧

Web Worker可能的使用场景

那么真的有这么多应用场景吗?以下列举了几个场景:

  • 解析一个庞大的JSON结构
  • 图片/音频处理
  • 大规模数据可视化

仔细想一想,这样的场景还是很特殊的,可能在实际的应用中并不多见。那么,在目前的主流前端框架是否有利用到WebWorker的特性来帮助其提升性能呢?经过调研,调研的部分都还在探索阶段,比如在React框架中的探索,Parashuram在2016年发布了文章《Using Webworkers to make React faster》,文章是关于如何利用Webworker提升React的渲染速度,主要是把Virtual DOM的相关计算过程(如diff算法)放入WebWorker线程,从结果可以看出,在Benchmark的对比下,使用WebWorker的一方帧率有所提高,感兴趣的同学可以查看其演示示例和项目地址。这里再忍不住要引用作者的一张图(如下图所示,纵轴是帧率,横轴是节点的个数),简要展示下React项目在使用WebWorker的情况下,性能的提升效果。


(图片来源:Using Webworkers to make React faster

那么WebWorker已经面世这么久了,各大浏览器支持也跟上了,为何其应用场景或者与主流框架的结合并没有很多见?我想可能与以下几点WebWorker的缺点相关:

  • 在Webworker线程中无法访问DOM节点
  • 无法与UI线程共享内存
  • 与UI线程通讯的信息需要序列化
  • 线程间通讯不可避免的并发问题

虽然如此,Angular背后的Google团队已经开始尝试打破这些限制,并已经在Angular 2.x中得进行了尝试(WebWorker Renderer),虽然到了目前的Angular 4.x在源码中仍标识为@experimental,但相信其在将来会成为Angular框架的标配。接下来的文章内容,会分析到在Angular框架中Webworker Renderer是如何工作的,包括如下三个要点:

  • 通讯信息如何序列化与反序列化?内存数据如何共享?
  • 如何打破Webworker线程不能操作DOM节点的局限?
  • 如何处理并发?

希望你能带着这三个问题阅读完以下的篇幅。

先感受一下


图中显示的基本是整个UI线程与WebWorker线程通讯的过程,给你来个初步的影响,可以帮助你在阅读后续内容时有个整体观的把控,图中涉及的类、方法以及过程,在接下来的文章中会一一介绍到。

介绍几个基本的类


先来看看这个RenderStroe类,在Angular是被标识为@Injectable()的可注入类,其中nextIndex是一个自增的索引号,通过allocateId函数递增分配。store和remove函数是对lookupById和_lookupByObject两个Map类型的容器进行新增和删除操作,其中的传入的id参数作为唯一的索引号(通过allocateId函数分配而来)。最后deserialize和serialize方法分别是根据id取出内容和根据内容取出id。这意味中在RenderStore中序列化就是将对象转换成一个唯一数字,而相对应的反序列化就是将数字转换为一个对象。

这样一来一个被普遍使用的RenderStore类就介绍完毕了,它承担了线程间数据信息通讯消息存储和序列化/反序列化的重要工作。总的来说,就是将需要传输的内容对象与一个索引号对应起来,实现序列化和反序列化的过程。这个类会穿梭于整个工作流程,经常会注入到其他关键类中,是UI线程与WebWorker线程公用的类,两端共同维护相同的一个副本,间接到达线程间数据共享的目的。


通过这个RenderStore类,我们已经可以解决之前提出第一个问题,放张动图大家先消化消化。聪明的你可能会有以下几个疑问:

  • Object对象里存的到底是什么东西?
  • 难倒只能由WebWorker线程向UI线程单向地发送同步RenderStore数据的指令?

不慌,我们接下去讲。


这个Serializer类主要用于WebWoker线程与UI主线程之间通讯的时候,提供消息信息序列化和反序列化的操作,其实还是主要依赖于RenderStore提供的方法。

该类定义了序列化的类型,对于string,number,boolean类型,即PRIMITIVE类型,是不需要序列化/反序列化的。通过代码枚举得知,操作支持如下几种类型:

enum SerializerTypes {    // RendererType2    RENDERER_TYPE_2,    // Primitive types,such as string,number,boolean    PRIMITIVE,    // An object stored in a RenderStore    RENDER_STORE_OBJECT,}

对各个具体的类型是如何序列化的,做了如下说明:

  • PRIMITIVE类型(原始类型),serializer方法不做任何处理,直接返回;
  • Array类型,使用map方法对数组中的每一项再serializer,然后返回;
  • RENDER_STORE_OBJECT类型,通过RenderStore类中的serialize方法序列化后返回;
  • RENDERER_TYPE_2类型,通过调用_serializeRendererType2方法处理后返回;
  • RenderComponentType类型,通过调用_serializeRenderComponentType方法处理后返回;
  • LocationType类型,通过调用_serializeLocation方法处理后返回;

其中,RenderComponentType, RendererType2类型是@angular/core中定义的,两者都是Angular编译器中对DOM节点进行渲染处理时定义的类型,这里不多做阐述。LocationType类型是针对浏览器的路由操作(windows.locaion.*)进行的包装,包含href,protocol,host,hostname,port等,容易理解。

此外,serializeRendererType2和serializeRenderComponentType方法体中也是根据序列化对象的结构再进行拆分对待,并继续调用serialize方法处理。比如_serializeRendererType2方法中是这样的:

private _serializeRendererType2(type: RendererType2): {[key: string]: any} {    return {      'id': type.id,      'encapsulation': this.serialize(type.encapsulation),      'styles': this.serialize(type.styles),      'data': this.serialize(type.data),    };}

从代码中可以看出,通讯信息的序列化/反序列化过程其实就是主要针对string,number,boolean类型(PRIMITIVE类型)和RENDER_STORE_OBJECT类型在作处理,前者不需要序列化/反序列化,后者通过RenderStore提供的方法进行处理。

是时候回答下之前提出的问题:RenderStore中存的Object对象到底是哪些?RENDER_STORE_OBJECT类型是指哪些类型呢?

  • WebWorkerRenderer2类型,继承自Renderer2类(该类是Angular的核心类,用于操作DOM相关,这里就不啰嗦了)
  • WebWorkerRenderNode类型,该类有且只有一个类型为NamedEventEmitter的成员变量events

于是,这里就不得不提到NamedEventEmitter类,这个类维护了一个Map类型的容器_listener,存储了事件名称和对应的方法,并提供新增(listen)、删除(unliten)以及触发事件的方法(dispatchEvent)。


由此可见,事件的定义、维护和触发在整个线程间通讯中至关重要。

再说说与通讯相关的类


根据官方远源码介绍,MessageBus类是一个低级别的API,是一个抽象类,主要用于UI主线程与WebWorker线程的通信相关。而双方的通信是基于通道(channel),通道的两端分别是MessageBusSink(信息流出)和MessageBusSource(信息流入),后续会细说到。类中提及的Zone是Angular的魔法,由于对与本文内容的理解不受影响,因此不做过多阐述,如感兴趣请自行查看。

首先是来列举下Angular中定义的三种通道的类型,三种通道负责不同的工作,分为渲染、事件和路由。

// DOM渲染通道export declare const RENDERER_2_CHANNEL = "v2.ng-Renderer";// DOM事件通道export declare const EVENT_2_CHANNEL = "v2.ng-Events";// 路由通道export declare const ROUTER_CHANNEL = "ng-Router";

接下来具体讲下PostMessageBus类,作为MessageBus抽象类的一个实现,类结构如下图所示。


该类的两个公共成员变量分别是source(PostMessageBusSource类型,是MessageBusSource类的一实现类)和sink(PostMessageBusSink类型,是MessageBusSink的实现类),可以解释为水源和水槽。可以这么理解,信息好比是水,可以通过水槽流出,也可以流入到水源中

类中的initChannel方法对这通道进行初始化,其中有2个的关键点:1)每个通道的实例最多只能有三个不同的通道类型;2)Channel通道信息初始化时候包含了一个EventEmitter类的实例对象,在Sink通道初始化的时候还会对其进行了订阅操作,触发后会执行相应的sendMessage操作,这个发送信息的方法的实现主要是通过该类的构造函数中传入,后面会有所介绍。

另外需要介绍一下PostMessageBusSource类,该类在构造函数中会对Worker对象通过addEventListener方法监听message事件,这个过程能监听信息接收的事件,并且做相应的信息处理的操作,即通过EventEmitter类的emit方法来触发相应的订阅事件。


首先介绍一下WebWorkerRendererFactory2类,从类名中可以解释为WebWorker渲染工厂,在Angular中也被标为@Injectable()类型,其构造函数中依赖ClientMessageBrokerFactoryMessageBusSerializer, RenderStore类的注入,并对其初始化,如下:

this._messageBroker = messageBrokerFactory.createMessageBroker(RENDERER_2_CHANNEL);bus.initChannel(EVENT_2_CHANNEL);const source = bus.from(EVENT_2_CHANNEL);source.subscribe({next: (message: any) => this._dispatchEvent(message)});

从构造函数中能了解到,主要依赖注入类的作用。首先通过ClientMessageBrokerFactory创建了通道为RENDERER_2_CHANNEL的代理人,虽然还未具体解释ClientMessageBroker类的作用,但从类命名中就可以了解到它的作用就是作为与UI线程通讯的中间代理人,在该类中负责向UI线程传输DOM节点渲染的工作,这个会在后续会详细介绍。另外,通过自身的MessageBus创建了EVENT_2_CHANNEL通道,并且对信息源做了subscribe的订阅操作,即当UI线程DOM事件触发时,该MessageBus的Source会接收到信息,并触发相应的_dispatchEvent函数操作,在WebWorker层中做相应的处理。