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);
}
}
- 'this' will reference the <app-form></app-form> element.
- Render element in connectedCallback
- 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;
}
}
}
- only attributes in observedAttributes will trigger this callback
- 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
Angular Elements Under the hood
By jiali
Angular Elements Under the hood
- 2,019