{ "feature_image_attr": "", "community_link": "https://community.indepth.dev/t/building-and-bundling-web-components-and-things-to-watch/473" }

Building and consuming Angular Elements as Web Components

In this article we'll explore building and bundling Web Components in Angular, approaches to consume Elements that has been enabled by Ivy and some limitations.

Building and consuming Angular Elements as Web Components

In the previous article we looked at Web Components in general and explored where Angular Elements comes into play. But creating and using Web Components is only one part of the story. Another key aspect is building and consuming them.  

The web can be very sensitive to bundle size, with every byte leading to longer load times and the potential loss of users.  As such we need to make sure we are building/bundling Web Components so they are as small as possible.

This article will look at how we can get the bundle size as small as possible; exploring both the current state of Angular and the prospects that Ivy has brought to the table. Here's the structure of the article:

  • Building and Bundling Web Components in Angular.
  • Other approaches available to us using Ivy.
  • Some limitations and possible solutions..
  • Some key tools.

Let's get started.

Building Angular elements

When it comes to building and bundling web components we have a few options. We can either build all lib components together with Angular runtime and ship it as a single bundle or we can build runtime and lib components as standalone packages. In this case the runtime and other dependencies will be built as so-called externals.

Each approach has its advantages and drawbacks. Let's explore them.

Packaging runtime with lib components

Currently @angular/elements takes your Component and creates a Custom Element for you. This is great but, even with --prod build, you will notice that the bundle size is rather large. This is because it bundles in the runtime and all the libraries it needs.

This isn't really great because the bundle size and time to interactive is pretty big. On the other hand, this approach enables tree shaking as Angular knows during the build which components and the framework features are not being used.

Building runtime as externals

The idea is we split the shared libraries from the Web Components bundle, leaving only the runtime code that is required for the component to run. Applications that use the Web Components are responsible for providing these shared dependencies.

In webpack this has the name “externals” and can be configured via the webpack config file:

The externals configuration option provides a way of excluding dependencies from the output bundles. Instead, the created bundle relies on that dependency to be present in the consumer's (any end-user application) environment. This feature is typically most useful to library developers, however there are a variety of applications for it.

If you're using Angular CLI, the best option is to use Manfred's
ngx-build-plus library to add externals to Webpack config.

If shared dependencies are built and loaded to the browser window as a standalone package you can then lazy load individual components on demand. Shipping externals separately from the Web Component also gives the added benefit that runtime can be cached on the server/cdn or by a browser.

Using externals can greatly reduce the bundle size. It does however, require the application consuming these Web Components to import the shared dependencies. This makes the Web Components deviate from the plug and play idea. This may be deemed acceptable depending on the use case.

Consuming Angular Elements

Ivy has officially landed! It is an exciting time to think about the possibilities it brings in regards to Web Components and Custom Elements.

Angular has had @angular/elements for a while. There are some problems however. A big one is the bundle size. This is where some of the private APIs from Angular’s can really help out. Let's explore them and see what we need to keep our eyes on.

What goes next is basically my discoveries and learning of Angular under the hood. It is to highlight some challenges and more importantly start to understand what the future holds in this space. A lot of what is explored here are the private APIs and are therefore subject to change. This is not a how to guide; nothing expressed herein is recommended for production builds.

All code can be found in this Angular workspace on github.

OK, so let’s take a look at the two ways we can consume and create a Web Component with Angular Ivy and some problems that arise.

The first approach is to manually create a Custom Element and use the private Angular API function ɵrenderComponent to attach an Angular Component to the DOM. As discussed in the previous article we will have to manually set up things like custom events, attributes, properties and the reflection of properties to attributes.

import { ɵdetectChanges, ɵrenderComponent } from '@angular/core';
import { EventManager, ɵDomEventsPlugin, ɵDomSharedStylesHost, ɵDomRendererFactory2 } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import { CustomDomRendererFactory2 } from './custom/customDomRendererFactory2';

export class HelloWorldElement extends HTMLElement {
  static get observedAttributes() {
    return ['title'] as Array<keyof AppComponent>;
  }

  private component: AppComponent;

  constructor() {
    super();   
    this.component = ɵrenderComponent(AppComponent, {
      host: this,
      rendererFactory: new CustomDomRendererFactory2(
        new EventManager([new ɵDomEventsPlugin(document)], null),
        new ɵDomSharedStylesHost(document),
        null
      ) 
    });
  }

  attributeChangedCallback(name: keyof AppComponent, oldValue: any, newValue: any) {
    switch (name) {
      case 'title':
        this.component.title = newValue;
        break;
    }
  }

  get title(): string {
    return this.component.title;
  }
  set title(value: string) {
    this.component.title = value;
    ɵdetectChanges(this.component);
  }
}

The second approach is to use @angular/elements method createCustomElement(). Although this handles all the manual work we had to do in the first approach, it does not use the power of the Ivy render. We can use Angular Dependency Injection to override providers to make it use the Ivy renderer.

import {
  ApplicationRef,
  ComponentFactory,
  ComponentFactoryResolver,
  Injector,
  RendererFactory2,
  Type,
  ViewRef,
  ɵNG_COMP_DEF,
  ɵRender3ComponentFactory
} from '@angular/core';
import { createCustomElement, NgElementConfig, NgElementConstructor } from '@angular/elements';
import { EventManager, ɵDomEventsPlugin, ɵDomRendererFactory2, ɵDomSharedStylesHost } from '@angular/platform-browser';

class IvyComponentFactoryResolver extends ComponentFactoryResolver {
  resolveComponentFactory<T>(component: Type<T>): ComponentFactory<T> {
    return new ɵRender3ComponentFactory(component[ɵNG_COMP_DEF]);
  }
}

class NoopApplicationRef {
  attachView(_viewRef: ViewRef): void {}
}

export function createCustomIvyElement<P>(component: Type<any>, config2?: NgElementConfig): NgElementConstructor<P> {
  const config = { injector: Injector.NULL };

  config.injector = Injector.create({
    name: 'IvyElmentInjector',
    parent: config.injector,
    providers: [
      {
        provide: ApplicationRef,
        useFactory: () => new NoopApplicationRef()
      },
      {
        provide: ComponentFactoryResolver,
        useFactory: () => new IvyComponentFactoryResolver()
      },
      {
        provide: RendererFactory2,
        useFactory: () =>
          new ɵDomRendererFactory2(
            new EventManager([new ɵDomEventsPlugin(document)], null),
            new ɵDomSharedStylesHost(document),
            null
          )
      }
    ]
  });

  return createCustomElement(component, config);
}

Approach 1: Custom HTMLElement

Here we extend the HTMLElement class and in the constructor use power of Ivy to render a component:

export class HelloWorldElement extends HTMLElement {
...
  constructor() {
    super();

    const host = document.createElement('app-component');

    this.component = ɵrenderComponent(AppComponent, { host: this });
    
    const shadowRoot = this.attachShadow({ mode: 'open' });
    shadowRoot.appendChild(host);

  }

...
}

In your main.ts, we can now register this element with the browser using

customElements.define('h-w', HelloWorldElement);

When we serve this project we will notice that the Angular component is rendered on the screen but with no styles!!

Why?

Well first what does the ɵrenderComponent do?

Bootstraps a Component into an existing host element and returns an instance of the component.

It does this through the use of a rendererFactory. By default it uses the domRendererFactory3:

export const domRendererFactory3: RendererFactory3 = {
  createRenderer: (hostElement: RElement | null, rendererType: RendererType2 | null): Renderer3 => { 
    return getDocument();
  }
};
getDocument(): Ivy calls this whenever it needs to access the `document` object.

As you can see this renderer just returns the document object and doesn’t think about styles.

So what do we do?

We can see that ɵrenderComponent accepts a rendererFactory, and lucky for us there is a private factory that fits the bill; the ɵDomRendererFactory2:

this.component = ɵrenderComponent(AppComponent, {
  host: this,
  rendererFactory: new ɵDomRendererFactory2(
    new EventManager([new ɵDomEventsPlugin(document)], null),
    new ɵDomSharedStylesHost(document),
    01
  )
});

The ɵDomRendererFactory2 uses a switch statement to return a renderer based on the encapsulation type of the component we are trying to render.

The EventManger with the ɵDomEventsPlugin is to register DOM events and the ɵDomSharedStylesHost is so we can share styles across components.

If we set the component to use viewEncapsulation.ShadowDom, then the ɵDomRendererFactory2 will return the ShadowDomRenderer.

The ShadowDomRenderer will attach the ShadowDom for us, therefore we can remove that logic from our CustomElement class definition…neat!

We are set now right?

Well if you run the above you will get an error:

platform-browser.js:1343 Uncaught DOMException: Failed to execute ‘attachShadow’ on ‘Element’: Shadow root cannot be created on a host which already hosts a shadow tree.

Why?

The problem is that the ɵrenderComponent uses the factory to create a new renderer quite a few times. Each time creating a new renderer.

When rendering a ViewEncapsulation.Emulated component they have a guard against this and return the same renderer:

...
case ViewEncapsulation.Emulated: {
  let renderer = this.rendererByCompId.get(type.id);
  if (!renderer) {
    renderer = new EmulatedEncapsulationDomRenderer2(this.eventManager, this.sharedStylesHost, type, this.appId);
    this.rendererByCompId.set(type.id, renderer);
  }
  (<EmulatedEncapsulationDomRenderer2>renderer).applyToHost(element);
  return renderer;
}
...

But this is missing from the ViewEncapsulation.Native and ViewEncapsulation.ShadowDom cases… So unfortunately for now you have manually add this same check.

...
  case ViewEncapsulation.Native:
  case ViewEncapsulation.ShadowDom: {
    let renderer = this.rendererByCompId.get(type.id);
    if (!renderer) {
      renderer = new ShadowDomRenderer(this.eventManager, this.sharedStylesHost, element, type);
      this.rendererByCompId.set(type.id, renderer);
    }
    return renderer;
  }
...

See provided github for the full custom class.

With this done your Custom Element should render the root app component and any nested components you may have. All with styles!! Phew!

Approach 2: createCustomElement

@angular/elements method createCustomElement() abstracts the creation of the HTMLElement class. Unfortunately it doesn't draw on the power of Ivy by default.

Lucky for us the same things done in Approach 1 can be done here to; just in a different way.

We know that createCustomElement() from @angular/elements uses Angular's dependency injection. As a result we can just provide the right classes in the injectors providers array:

export function createCustomIvyElement<P>(component: Type<any>, config2?: NgElementConfig): NgElementConstructor<P> {
  const config = { injector: Injector.NULL };

  config.injector = Injector.create({
    name: 'IvyElmentInjector',
    parent: config.injector,
    providers: [
      {
        provide: ApplicationRef,
        useFactory: () => new NoopApplicationRef()
      },
      {
        provide: ComponentFactoryResolver,
        useFactory: () => new IvyComponentFactoryResolver()
      },
      {
        provide: RendererFactory2,
        useFactory: () =>
          new ɵDomRendererFactory2(
            new EventManager([new ɵDomEventsPlugin(document)], null),
            new ɵDomSharedStylesHost(document),
            null
          )
      }
    ]
  });

  return createCustomElement(component, config);
}
The IvyComponentFactoryResolver returns the ɵRender3ComponentFactory

Serving the app now will just work!

Why can we use the non-customized ɵDomRendererFactory2?

createCustomElement() uses a componentFactory vs the rendererFactory (used by ɵrenderComponent). The former checks if it is the root being created and only creates one ShadowDomRenderer if it is.

It has been very interesting exploring what the differences between createCustomElement() and ɵrenderComponent are, and how we can use modify them to use Ivy and add styles.

There seems to be a little of a drift between the two when maybe there doesn’t have to be?

Approaches to synchronizing state with view in Angular elements

Let's now take a look at how to run change detection when using Angular elements. We'll answer the following question:

I want the great bundle size of the above but am using a third-party directive. It is using markForCheck() to mark the component as dirty. But it never gets checked so values don’t update. How do I solve this?

Lucky there is a private API which can help ɵmarkDirty(). The ɵmarkDirty method (unlike markForCheck) calls scheduleTick(), which triggers change detection.

Great but how?

Angular creates a new ChangeDetectorRef a.k.a ViewRef for every componentView. When we mark a view as dirty (using markForCheck) it traverses up the view tree marking all components dirty. We can monkey patch this method and use the private API ɵmarkDirty().

In the root component we can do something like this!

...
export class AppComponent {
...
  constructor(private cdr: ChangeDetectorRef) {
    this.cdr.__proto__.markForCheck = () => ɵmarkDirty(this);
  }
...

Now when a new ViewRef is created for the third-party directive it will instantiate one that uses the private API ɵmarkDirty(). Changes will now be reflected.

The Angular extras

In the Angular world there is some great tooling out there to help with building and consuming Web Components.

Ngx-build-plus

Ngx-build-plus is a great addition. It extends the Angular CLI's default build behavior. Here are some of the features of this library:

Single bundle outputs
Takes the multiple bundle output from Angular and creates a single bundle for easy consumption.  This is a key build flag for using with @angular/elements.

Externals
Creates a partial webpack config, which is used in conjunction with Angular’s internal webpack config.  This partial config uses webpack’s “externals” configuration option preventing the bundling of certain Angular specific imported packages and instead retrieves these external dependencies at runtime. (Ref above section that discusses externals)

Angular-extensions

This is a fantastic Angular Library that takes away a lot of the headaches of orchestrating when and how to load Web Components into your application.  Supplied with a small amount of config, the library’s Angular directive will take care of adding the script tag and the actual Custom Element tag to the html. From dynamic loading and caching, to advanced template binding functionality, it is a great addition to the world of Web Components in Angular.


So that is all from me. I hope you have enjoyed looking at some of the private APIs and what Ivy can maybe bring us in the future for Custom Elements.