Angular Elements Under the hood

Who am I

  • Name: Jia Li
  • Company: Sylabs.io
  • Zone.js: Code Owner
  • Angular: Trusted Collaborator
  • @JiaLiPassion

Agenda

  • Implement Web Components via VanillaJS 
  • Implement Web Components via Angular (without Angular Elements)
  • Zone with Web Components

What is Web Components

  • The ability to create Custom Html Tag
  • Readable template, less code, reusable

Vanilla JS

Sample Component

Step 1:

  • Input:
    • Label text can be set from outside of the component
  • Output:
    • Submit clicked, will trigger submit event with input text

Goal

<body>
  <label>my label</label>
  <input/>
  <button>Submit</button>
</body>
<body>
  <app-form label="my label"></app-form>
</body>

Custom Elements v1

Callback Called When
constructor element is created or upgraded
connectedCallback element is inserted into DOM
disconnectedCallback element is removed from DOM
attributeChangedCallback attributes in observedAttributes being changed (CUD)

Custom Elements v1

class AppForm extends HTMLElement {
  constructor() {}

  connectedCallback() {}

  disconnectedCallback() {}
  
  attributeChangedCallback() {}
}

Lifecycle

<app-form label="my label"></app-form>

const appForm = document.createElement('app-form');
// AppForm constructor is called
appForm.setAttribute('label', 'my-label');
// AppForm attributeChangedCallback is called
document.body.appendChild(appForm);
// AppForm connectedCallback is called.


document.body.removeChild(appForm);
// AppForm disconnectedCallback is called.

constructor

class AppForm extends HTMLElement {
  constructor() {
    super();
  }
}

Don't access element/attributes because they may not be present before upgrade phase.

connectedCallback

class AppForm extends HTMLElement {
  initialLabel = 'default label';
  connectedCallback() {
    const label = this.initialLabel;
    this.innerHTML = `<label>${label}</label>
      <input/><button>Submit</button>`;
    const button = this.querySelector('button');
    button.addEventListener('click', this.btnClicked);
  }
}
  1.  'this' will reference the <app-form></app-form> element.
  2. Render element in connectedCallback
  3. read initial value from attribute

disconnectedCallback

class AppForm extends HTMLElement {
  disconnectedCallback() {
    // clean up code
    const button = this.querySelector('button');
    button.removeEventListener('click', this.btnClicked);
  }
}

Cleanup resource or event listeners

attributeChangedCallback

class AppForm extends HTMLElement {
  static get observedAttributes() {
    return ['label'];
  }

  attributeChangedCallback(name, oldValue, newValue) {
    this.updateLabel(newValue);
  }

  updateLabel(newLabel) {
    const label = this.querySelector('label');
    if (label) {
      label.textContent = newLabel;
    } else {
      this.initialLabel = newLabel;
    }
  }
}
  1. only attributes in observedAttributes will trigger this callback
  2. attributeChangedCallback may be called before connectedCallback

attribute <-> property sync

class AppForm extends HTMLElement {
  get label() {
    return this.getAttribute('label');
  }

  set label(newLabel) {
    this.setAttribute('label', newLabel);
  }
}
<body>   
  <app-form label="my label"></app-form>
  <script>
    const appForm = document.querySelector('app-form');
    // set by property
    appForm.label = 'new label';
    // set by attribute
    appForm.setAttribute('label', 'new label');
  </script>
</body>

Custom Event

class AppForm extends HTMLElement {
  btnClicked() {
    const customEvent = document.createEvent('CustomEvent');
    customEvent.initCustomEvent('submitted', false, false, 
      this.querySelector('input').value);
    this.dispatchEvent(customEvent);
  }
}
<script>
  appForm.addEventListener('submitted', (event) => {
    console.log('input from custom element', event.detail);
  });
</script>

<app-form label="my label"></app-form>

Register Custom Tag

customElements.define('app-form', AppForm);

Vanilla JS Demo

Angular without Angular Elements

AppFormComponent

import { Component, OnInit, Input, Output } from '@angular/core';

@Component({
  selector: 'app-app-form',
  template: `
    <label>{{ label }}</label> <input /><button>Submit</button>
  `,
  styleUrls: ['./app-form.component.css']
})
export class AppFormComponent implements OnInit {
  @Input() label: string;
  @Output() submitted = new EventEmitter();
  constructor() {}

  ngOnInit() {}

  submit() { this.submitted.emit(...) }
}

CustomElementsWrapper

class CustomElementsWrapper extends HTMLElement {
  constructor() {
    super();
  }

  static get observedAttributes() {
    return [];
  }

  connectedCallback() {}

  disconnectedCallback() {}

  attributeChangedCallback(attrName: string,
    oldVal: string, newVal: string) { }
}

static init

  • initialize observedAttributes based on the Angular Component’s inputs, to support things we’ll need to do in attributeChangedCallback()
  • Create properties on CustomElementsWrapper based on Components' inputs

static init -> prepare

export class CustomElementsWrapper {
  static prepare(injector: Injector, component: any) {
    const componentFactory = injector
      .get(ComponentFactoryResolver)
      .resolveComponentFactory(component);
    componentFactory.inputs.forEach(input => 
      observedAttributes.push(input.templateName));
  }
}
export class AppFormComponent {
  @Input('label1') label: string;
}

<app-form label1="new label"></app-form>

// componentFactory.inputs
[
  {
    propName: 'label',
    templateName: 'label1'
  }
]

connectedCallback

  • Initialize our Angular Component (just like in Dynamic Components)

Angular Dynamic Component

export function dynamicLoadComponent(element: HTMLElement, injector: Injector) {
  // create injector for this dynamic component
  const childInjector = Injector.create({ providers: [], parent: injector });

  // resolve componentFactory based on Component Type
  const componentFactoryResolver = injector.get(ComponentFactoryResolver);
  const componentFactory = componentFactoryResolver.
    resolveComponentFactory(AppFormComponent);

  // create component instance with injector, projectionNode and root element.
  const componentRef = componentFactory.create(childInjector, [], element);

  // trigger change detection to render component
  componentRef.changeDetectorRef.detectChanges();

  // add component view to applicationRef to enable future change detection
  const applicationRef = injector.get<ApplicationRef>(ApplicationRef);
  applicationRef.attachView(componentRef.hostView);

  return componentRef;
}

connectedCallback-> initComponent

export class CustomElementsWrapper {
  initialInputValues = {};
  connectedCallback() {
    loadDynamicComponent(this, this.injector);
    this.componentFactory.inputs.forEach(
      prop => this.componentRef.instance[prop.propName] =
        this.initialInputValues[prop.propName]);
  }

  attributeChangedCallback(propName: string, _: string,
    value: string) {
    // component not loaded yet
    if (!this.componentRef) {
      this.initialInputValues[propName] = value;
      return;
    }
    // ...
  }
}

disconnectedCallback

  • destroy component

disconnectedCallback 

class CustomElementsWrapper {
  destroy() {
    this.componentRef.destroy();
  }
}

attributeChangedCallback

  • Update component input property and trigger change detection to render the updated value
  • Also update the property of the CustomElementsWrapper 

attributeChangedCallback>setInput

class CustomElementsWrapper {
  attributeChangedCallback(propName: string, _: string,
     value: string) {
    if (!this.componentRef) {
      this.initialInputValues[propName] = value;
      return;
    }
    if (this.componentRef.instance[propName] === value) {
      return;
    }
    this.componentRef.instance[propName] = value;
    this.changeDetectorRef.detectChanges();
  }
}

<app-form></app-form>

<script>
  const appForm = document.querySelector('app-form');
  appForm.setAttribute('label', 'newLabel');
</script>

attributeChangedCallback->setInput

class CustomElementsWrapper {
  componentFactory.inputs.map(({ propName }) => propName)
    .forEach(property => {
      Object.defineProperty(CustomElementsWrapper.prototype, property, {
        get: function() {
          return this.componentRef[property];
        },
        set: function(newValue: any) {
          this.componentRef[property] = newValue;
          this.componentRef.changeDetectorRef.detectChanges();
        },
        configurable: true,
        enumerable: true,
    });
}
<app-form></app-form>

<script>
  const appForm = document.querySelector('app-form');
  appForm.label = 'newLabel';
</script>

@Output->Custom Event

<app-form></app-form>
<script>
  appForm.addEventListener('submitted', (event) => 
    { console.log('custom element is submitted with input', event.detail); });
</script>

export class CustomElementsWrapper {
  connectedCallback() {
    const eventEmitters =
        this.componentFactory.outputs.map(({propName, templateName}) => {
          const emitter = (this.componentRef.instance as any)[propName] as
              EventEmitter<any>;
          emitter.subscribe((value: any) => {              
            const customEvent = document.createEvent('CustomEvent');
            customEvent.initCustomEvent(propName, false, false, value);
            this.dispatchEvent(customEvent);
          });
        });

    });
  }
}

NgModule

@NgModule({
  declarations: [AppFormComponent],
  imports: [BrowserModule],
  entryComponents: [AppFormComponent],
  providers: [],
  bootstrap: []
})
export class AppModule {
  constructor(private injector: Injector) {}

  ngDoBootstrap() {
    CustomElementsWrapper.prepare(this.injector, AppFormComponent);
    customElements.define('app-form', CustomElementsWrapper);
  }
}

Demo

Shadow DOM

isolated DOM/Scoped CSS

Enhanced Vanilla JS

class AppForm extends HTMLElement {
  constructor() {
    this.shadowRoot = this.attachShadow({mode: 'open'});
  }

  connectedCallback() {
    // create a shadow root
    this.shadowRoot.innerHTML = `<label>${label}</label>
      <input/><button>Submit</button>`;
  }
}

Angular with Shadow DOM

@Component({
  ...,
  encapsulation: ViewEncapsulation.ShadowDom
})
export class AppFormComponent implements OnInit {
}

Projection with slot

<app-form>
Projection
<div slot="named">Named slot projection</div>
</app-form>

@Component({
  selector: 'app-app-form',
  template: `
    <label>{{ label }}</label> <input #input />
    <button (click)="submit()">Submit</button><br />
    Projection: <slot></slot>
    named projection: <slot name="named"></slot>
  `,
  styleUrls: ['./app-form.component.css']
})
export class AppFormComponent implements OnInit {
}

Ivy

Render and CD (zone-less)

import { ɵrenderComponent as renderComponent,
  ɵdetectChanges as detectChanges} from '@angular/core';
export class AppFormElement extends HTMLElement {
  set label(newLabel: string) {
    ...
    this.comp.label = newLabel;
    detectChanges(this.comp);
  }

  connectedCallback() {
    this.comp = renderComponent(AppFormComponent, { host: this });
    ...
  }
}

No NgModule

import { enableProdMode } from '@angular/core';

import { environment } from './environments/environment';
import { AppFormElement } from './app/app-form/app-form-element';

if (environment.production) {
  enableProdMode();
}

customElements.define('app-form', AppFormElement);

Ivy Demo

Zone.js 0.9.1 

class AppModule {
  ngDoBootstrap() {
    // all callbacks will be in the zone
    // which is the same when define
    customElements.define('app-form', CustomElementsWrapper);
  }
}

class CustomElementsWrapper extends HTMLElement {
  connectedCallback() {
    // don't need this line any more
    // this.componentRef.changeDetectorRef.detectChanges();
  }

  attributeChangedCallback() {
    // don't need this line any more
    // this.componentRef.changeDetectorRef.detectChanges();
  }
}

Angular Elements (Scoped Zone Proposal)

Web App

JQuery

React

Angular Elements

Zone.js

Zone.js

Zone.js

Zone.js is merged into angular mono repo !!!

Thank you!

1. https://github.com/JiaLiPassion/ng-japan-custom-elements-angular

2. https://github.com/JiaLiPassion/ivy-custom-elements

3. https://codepen.io/JiaLiPassion/pen/RzEvBG

Made with Slides.com