Angular provides APIs that allow developers to write code that's decoupled from the underlying platform; ie. The DOM and web APIs provided by the browser.
This allows you to write code which could run on any number of platforms. eg. You can write your application as an SPA for the browser, and then run it separately on a NodeJS server to generate static HTML for SEO.
The well known built-in platforms are platform-browser
and platform-server
. But there's another experimental platform that's been mostly forgotten about: platform-webworker
What is "platform-webworker"?
In a nutshell: platform-webworker
runs all (read: most of) your Angular code inside a Worker thread, which then communicates with the UI thread solely to render your web page through the DOM.
The idea is if you've successfully isolated yourself from using the DOM, why run any of your code in the same thread?
platform-webworker
creates two pieces of your application: UI and Worker. The UI piece will get bootstrapped on the DOM thread like normal, then it will spin up a new Worker with your application code.
This should produce a smoother UI, free of "jank", as most of your application's computations will occur separate from the UI.
Building for platform-webworker
Unfortunately there's currently no Angular CLI support for building platform-webworker
apps.
Thankfully Enrique oriol has done the hard work and written up instructions on how to build it: Angular with Web Workers: Step by Step
Note: These instructions won't work with Angular CLI v6 because
ng eject
is currently disabled. Once it's reenabled, I will make sure to update this post.
In the meantime, you can use Angular v6 with Angular CLI v1.7.4
I've also created a github repo setup to build platform-browser
, platform-server
, and platform-webworker
altogether: github.com/rolaveric/angular-webworker-playground
How to work with the UI and DOM thread
Angular's template syntax and APIs let you do a lot without ever touching the DOM directly. But if you're writing your own UI widgets, chances are you will need direct DOM access. How does that work with platform-webworker
?
Lets say I want to access document.activeElement
. I want an ActiveElement
service with a get()
method which returns document.activeElement
.
import { DOCUMENT } from '@angular/common';
import { Injectable, Inject } from '@angular/core';
@Injectable()
export class ActiveElement {
constructor(@Inject(DOCUMENT) private document: any) {}
get() {
return this.document.activeElement;
}
}
platform-webworker
works in two pieces: UI and Worker. So we need our ActiveElement
service to run on the UI, but be accessible from the Worker.
Step 1: Create a ServiceMessageBroker
A ServiceMessageBroker
accepts RPC messages. To create one you'll need to inject ServiceMessageBrokerFactory
, provided by platform-webworker
, and call createMessageBroker(CHANNEL)
where CHANNEL
is a string that's shared by both the service on the UI and the client on the Worker.
import { ServiceMessageBrokerFactory, ServiceMessageBroker } from '@angular/platform-webworker';
@Injectable()
export class UiActiveElement {
messageBroker: ServiceMessageBroker;
constructor(
@Inject(DOCUMENT) private document: any,
smbf: ServiceMessageBrokerFactory
) {
this.messageBroker = smbf.createMessageBroker('ACTIVE_ELEMENT_CHANNEL');
}
}
Step 2: Expose methods with registerMethod()
To expose methods to RPC messages, we need to call ServiceMessageBroker.registerMethod()
, and specify what type of objects we expect as the arguments and result.
Argument types need to come from the SerializerTypes
enum, but the only two you need to worry about are PRIMITIVE
and RENDER_STORE_OBJECT
.
Primitives include anything that can be natively passed to Workers, which does not include DOM elements. Instead platform-webworker
allows you to pass around references to DOM elements called Render Store Objects. When you pass one from the Worker to the UI, platform-webworker
will map it to the DOM element it represents. And in reverse, if you pass an element back to the Worker, it will map it to a Render Store Object.
import { SerializerTypes } from '@angular/platform-webworker';
@Injectable()
export class UiActiveElement {
start() {
this.messageBroker.registerMethod(
// Method name
'get',
// Argument types
[],
// Function to call
this.get.bind(this),
// Return type
SerializerTypes.RENDER_STORE_OBJECT
);
}
}
We put our registerMethod()
calls into a separate method which we're calling start()
. There's nothing significant about the name start()
; It could just as easily be init()
or register()
. It just needs to be a method that can be called by our next step-
Step 3: Create a PLATFORM_INITIALIZER provider
Normally when we run services in Angular, we do so from a component - either directly or indirectly. However, we don't have any components on the UI - they all live on the Worker. So how do we get our service to start listening for RPC messages?
Enter PLATFORM_INITIALIZER
: A DI token which accepts multi: true
providers for functions which will be called once all the platform services are ready. We can use this to call our start()
method ASAP.
import { FactoryProvider, Injector, NgZone, PLATFORM_INITIALIZER } from '@angular/core';
// The factory gets injected very early, so we can only rely on Injector being available
function platformInitFnFactory(injector: Injector) {
return () => {
// By the time our function is called, NgZone should be available
const zone: NgZone = injector.get(NgZone);
zone.runGuarded(() => injector.get(UiActiveElement).start());
};
}
// We register our factory as a `PLATFORM_INITIALIZER` multi provider
export const platformInitActiveElementProvider: FactoryProvider = {
provide: PLATFORM_INITIALIZER,
useFactory: platformInitFnFactory,
multi: true,
deps: [Injector]
};
Step 4: Pass UI providers to bootstrapWorkerUi()
If you followed the instructions from Enrique's blog post, you'll have a src/main.webworker-ui.ts
file that calls bootstrapWorkerUi()
from platform-webworker
.
bootstrapWorkerUi()
takes two arguments: The path to the Worker code bundle, and an array of DI providers. We want to pass in a provider for the UiActiveElement
class, and our PLATFORM_INITIALIZER
provider.
import { bootstrapWorkerUi, ServiceMessageBrokerFactory } from `@angular/platform-webworker`;
import { DOCUMENT } from '@angular/common';
import { UiActiveElement, platformInitActiveElementProvider } from './ui/active-element.service';
bootstrapWorkerUi(
'webworker.bundle.js',
[
// All the providers must match the `StaticProvider` interface
// That's why we can't just pass in `UiActiveElement` as is
{provide: UiActiveElement, useClass: UiActiveElement, deps: [DOCUMENT, ServiceMessageBrokerFactory]},
platformInitActiveElementProvider
]
)
Step 5: Create a ClientMessageBroker on the Worker
A ClientMessageBroker
is the client equivalent of ServiceMessageBroker
- making RPC messages to send to the service on the UI. To create one you'll need to inject ClientMessageBrokerFactory
, provided by platform-webworker
, and call createMessageBroker(CHANNEL)
where CHANNEL
is the same as from the UI service.
import { ClientMessageBrokerFactory, ClientMessageBroker } from '@angular/platform-webworker';
@Injectable()
export class WorkerActiveElement {
messageBroker: ClientMessageBroker;
constructor(
cmbf: ClientMessageBrokerFactory
) {
this.messageBroker = cmbf.createMessageBroker('ACTIVE_ELEMENT_CHANNEL');
}
}
We can now call the methods on the UI service using runOnService()
, which need a UiArguments
object to wrap the method name and arguments.
import { UiArguments, SerializerTypes } from '@angular/platform-webworker';
@Injectable()
export class WorkerActiveElement {
get(): Promise<any> {
const uiArgs = new UiArguments('get', []);
return this.messageBroker.runOnService(uiArgs, SerializerTypes.RENDER_STORE_OBJECT);
}
}
Now our get()
method will return a promise which resolves to an object that can be passed to things like renderer.addClass(nativeElement, className)
.
@Component({...})
export class MyComponent {
constructor(
renderer: Renderer2,
activeElement: WorkerActiveElement
) {
activeElement.get()
.then((element) => {
renderer.addClass(element, 'active');
});
}
}
Working with multiple platforms
The example above works fine if you're writing entirely for platform-webworker
, but it won't work with platform-browser
or platform-server
. What you need is a common interface and platform specific implementations.
Our common interface will be a class with a get(): Observable<any>
method, where any
is actually something which can be passed to Renderer2.addClass()
for that platform. eg. A DOM element on platform-browser
and platform-server
, and a Render Store Object on platform-webworker
.
ServiceMessageBroker
and ClientMessageBroker
normally deal with promises, not observables. But if we're going to support multiple platforms, we're better off using observables for the common API. That way if a platform's implementation is totally synchronous, it doesn't need to wait till next tick.
export class ActiveElement {
get(): Observable<any> {
throw new Error('ActiveElement.get() No implementation available for this platform');
}
}
We can now create a sub-class for platform-browser
and platform-server
to use.
export class BrowserActiveElement extends ActiveElement {
constructor(@Inject(DOCUMENT) private document: any) {}
get(): Observable<any> {
return of(this.document.activeElement);
}
}
// This provider will pass an instance of `BrowserActiveElement` whenever something requests `ActiveElement` via DI
export const browserActiveElementProvider = {
provide: ActiveElement,
useClass: BrowserActiveElement,
deps: [DOCUMENT]
};
The good thing about this pattern is we can reuse the platform-browser
service in the UI part of our platform-webworker
code, so UiActiveElement
only needs to worry about creating and wiring up the message broker.
import { ServiceMessageBrokerFactory, ServiceMessageBroker } from '@angular/platform-webworker';
@Injectable()
export class UiActiveElement {
messageBroker: ServiceMessageBroker;
constructor(
private activeElement: ActiveElement,
smbf: ServiceMessageBrokerFactory
) {
this.messageBroker = smbf.createMessageBroker('ACTIVE_ELEMENT_CHANNEL');
}
start() {
this.messageBroker.registerMethod(
// Method name
'get',
// Argument types
[],
// Function to call
() => from(this.activeElement.get()),
// Return type
SerializerTypes.RENDER_STORE_OBJECT
);
}
}
We can register providers for both BrowserActiveElement
and UiActiveElement
with bootstrapWorkerUi()
, since they're not reusing the same DI token.
bootstrapWorkerUi(
'webworker.bundle.js',
[
browserActiveElementProvider,
{provide: UiActiveElement, useClass: UiActiveElement, deps: [ActiveElement, ServiceMessageBrokerFactory]},
platformInitActiveElementProvider
]
)
Problems with platform-webworker
As you've no doubt picked up, there are some issues with platform-webworker
. But I do believe they're all solvable:
-
No Angular CLI support
The lack of Angular CLI support is a killer for anyone (like me) who has become dependant on it as a build tool, without needing to know the inner workings with Webpack.
Solution: I'm hoping CLI v6's support for schematics will allow the community to provide a solution. -
No access to
$event.target
A common use case for DOM access is to test what element an event was triggered on. eg. A dropdown should close when a click occurs outside it's menu.
Unfortunately the event details which are passed across to the Worker don't include$event.target
- even if you specify it as an input with@HostListener()
.
Solution: The simplest solution would be for Angular to always resolve$event.target
to a Render Store Object for any event. A better solution might be to only return specifically requested properties, and return them as Render Store Objects if they're HTML elements.
But that would only work for@HostListener()
, not for event bindings in templates (eg.<a (click)="doThing($event.target)">
) unless the compiler was also updated.
Till then, the only workaround is to setup a custom service on the UI that will listen to events for you. -
Message Brokers could be easier to setup
As you saw before, there's a lot of boilerplate involved in getting Message Brokers setup.
Solution: The best pattern I've found is to write a service forplatform-browser
and then expose it's methods using Message Brokers. If you could simply generate those Message Brokers, either at compile time or using CLI generators, that would make things much easier.
A good starting point would be an Angular CLI v6 schematic. -
Message Brokers should return Observables
CurrentlyServiceMessageBroker.registerMethod()
expects a function that returns a Promise, andClientMessageBroker.runOnService()
returns a Promise.
Trouble is, everything else you're doing with Angular is based around Observables. Sticking to Observables for everything keeps everyone on the same page. -
UI Library support
None of the UI widget library that I've tried have kept the DOM access sufficiently isolated, so I can't use them withplatform-webworker
without forking the code and modifying them.
Solution: Simplest solution is to submit PRs to these libraries to be moreplatform-webworker
friendly.
What would also help are standard APIs for accessing DOM elements in the same ways these libraries are doing. Otherwise you'll end up with 3 different services that all doelement.focus()
orelement.contains(targetElement)
.
But till then, I don't think there's much harm in a little duplication. -
Documentation!
Unless you go hunting through the code, you may not even realise that Angular'splatform-webworker
exists. And the only way to understand how it works is to reverse engineer the existing code.
Solution: More documentation! More blog posts!
I'm hoping some people will read this, give it a try, and record their experiences with more online articles.
Conclusion
platform-webworker
is a forgotten feature of Angular that mostly works, but it's lacking some tooling and ecosystem support. But with some promotion and TLC, I think it could be really useful.
I'm going to do what I can with the time permitted to me to promote and improve platform-webworker
, and I'd appreciate any support being offered. Even reports on your own experiences (what's hard, what's impossible, etc.)
Cheers,
Jason Stone