Internals of Angular Elements

By Eamon O'Tuathail

Overview

Angular Elements brings the world of standard Web Components to modern Angular. Ideas such as custom elements, shadow DOM, HTML templates and slots are now usable from Angular code in a simple way. To learn more about using Angular Elements, visit:

This is a clear presentation on Angular Elements by its main developer:

Here we are going to explore the internals of Angular Elements. Angular Elements is implemented in the elements package in the main Angular source tree:

Source Tree Layout

The root of the Angular Elements package has these files:

and these sub-directories:

Source

BUILD.bazel

The BUILD.bazel file can be considered in three sections - initial setup, ng_module definition and ng_package definition. The initial setup is just this pair of lines:

package(default_visibility = ["//visibility:public"])
load("//tools:defaults.bzl", "ng_module", "ng_package")

The ng_module definition states the intenal name to use (elements). the module name for exported types, the sources to build (srcs) and dependencies (which we note are just these three: Angular/Core, Angular/Platfrom-Browser and RxJS):

ng_module(
    name = "elements",
    srcs = glob(
        [
            "*.ts",
            "src/**/*.ts",
        ],
    ),
    module_name = "@angular/elements",
    deps = [
        "//packages/core",
        "//packages/platform-browser",
        "@rxjs",
    ],
)

The ng_package definition states the npm name, the sources for the package, the entry point (index.js), additional packages (schematics), tags and dependencies:

ng_package(
    name = "npm_package",
    srcs = glob([
        "**/*.externs.js",
        "**/package.json",
    ]),
    entry_point = "packages/elements/index.js",
    packages = [
        "//packages/elements/schematics:npm_package",
    ],
    tags = [
        "ivy-jit",
        "release-with-framework",
    ],
    deps = [
        ":elements",
    ],
)

We note tags includes ivy-jit used for Render3 suppport.

index.ts

The index.ts file has this single line:

export * from './public_api';

public_api.ts

The public_api.ts file specifies the package exports, which come from three separate source files in the src sub-directory:

export {NgElement, NgElementConfig, NgElementConstructor, WithProperties, createCustomElement} from './src/create-custom-element';

export {NgElementStrategy, NgElementStrategyEvent, NgElementStrategyFactory} from './src/element-strategy';

export {VERSION} from './src/version';

We will examine each export shortly.

tsconfig-build.json

The tsconfig-build.json file specifies additional compiler configuration to be passed to the ThpeScript compiler.

{
  "extends": "../tsconfig-build.json",

  "compilerOptions": {
    "baseUrl": ".",
    "rootDir": ".",
    "paths": {
      "@angular/core": ["../../dist/packages/core"],
      "@angular/platform-browser": ["../../dist/packages/platform-browser"],
      "rxjs/*": ["../../node_modules/rxjs/*"]
    },
    "outDir": "../../dist/packages/elements"
  },

  "files": [
    "public_api.ts",
    "../../node_modules/zone.js/dist/zone.js.d.ts"
  ],

  "angularCompilerOptions": {
    "annotateForClosureCompiler": true,
    "strictMetadataEmit": false,
    "skipTemplateCodegen": true,
    "flatModuleOutFile": "elements.js",
    "flatModuleId": "@angular/elements"
  }
}

Two files are defined as the root of compilation - public_api.ts and zone.js.d.ts (part of Zone.js). Paths are provided to the three packages elements depends upon - Angular/Core, Angular/Platform-Browser and RxJS.

package.json

The package.json file provides the package definition. It lists dependencies (required when running the code) and peerDependencies (required by as dependencies by client code consuming this package) as follows:

"dependencies": {
    "tslib": "^1.9.0"
  },
  "peerDependencies": {
    "@angular/core": "0.0.0-PLACEHOLDER",
    "@angular/platform-browser": "0.0.0-PLACEHOLDER",
    "rxjs": "^6.0.0"
  },

Three important settings are ng-update, sideEffects and schematics:

  "ng-update": {
    "packageGroup": "NG_UPDATE_PACKAGE_GROUP"
  },
  "sideEffects": false,
  "schematics": "./schematics/collection.json"

We see how NG_UPDATE_PACKAGE_GROUP is used in the shared default.s.bzl file:

Src Sub-Directory

The Src sub-directory contains these source files:

Version.ts provides a version string and utils.ts provides utility-level functions, such as help scheduling a callback or converting from camelCased strings to kebab-cased.

The main function provided by extract-projectabel-nodes.ts is called extractProjectableNodes and is defined as follows:

export function extractProjectableNodes(
    host: HTMLElement, 
    ngContentSelectors: string[])
        : Node[][] {                    1
  const nodes = host.childNodes;
  const projectableNodes: Node[][] = ngContentSelectors.map(() => []);
  let wildcardIndex = -1;

2 ngContentSelectors.some((selector, i) => { if (selector === '*') { wildcardIndex = i; return true; } return false; });

3 for (let i = 0, ii = nodes.length; i < ii; ++i) { const node = nodes[i]; const ngContentIndex = findMatchingIndex(node, ngContentSelectors, wildcardIndex);

if (ngContentIndex !== -1) {
  projectableNodes[ngContentIndex].push(node);
}

}

return projectableNodes; }

At 1 we see the function signature takes as input an HTMLElement host and an array of string for the content selectors. The return value is a multidimensional array of nodes. At 2 we see ngContentSelectors is processed to find the number of wildcard indices (marked by '*'). Then at 3 we see the nodes (host.childNodes) are scanned and a call made into the helpder function findMatchingIndex, and the result is used to populate the projectableNodes array and this is then returned.

element-strategy.ts defines three important interfaces. NgElementStrategyEvent somply contains a string and an any. It is used by NgElementStrategy:

// Interface for the events emitted through the NgElementStrategy.
export interface NgElementStrategyEvent {
  name: string;
  value: any;
}

NgElementStrategyFactory is an interface with a factory function used to create an element strategy. It takes an injector as a parameter:

// Factory used to create new strategies for each NgElement instance.
export interface NgElementStrategyFactory {
  /** Creates a new instance to be used for an NgElement. */
  create(injector: Injector): NgElementStrategy;
}

NgElementStrategy defines various settings for teh element, such as getting and setting named input values, connecting and disconnecting and an observable of events:

// Underlying strategy used by the NgElement to create/destroy the 
// component and react to input changes.
export interface NgElementStrategy {
  events: Observable<NgElementStrategyEvent>;
  connect(element: HTMLElement): void;
  disconnect(): void;
  getInputValue(propName: string): any;
  setInputValue(propName: string, value: string): void;
}

The two big files in Angular Elements are:

The component-factory-strategy.ts file defines two classes, ComponentNgElementStrategyFactory and ComponentNgElementStrategy, that respectively derive from the NgElementStrategyFactory and NgElementStrategy interfaces that we have just seen.

The first is defined as:

export class ComponentNgElementStrategyFactory implements NgElementStrategyFactory {
  componentFactory: ComponentFactory;

constructor(private component: Type, private injector: Injector) { 1 this.componentFactory = injector.get(ComponentFactoryResolver).resolveComponentFactory(component); } create(injector: Injector) { 2 return new ComponentNgElementStrategy(this.componentFactory, injector); } }

We see at 1 that a ComponentFactoryResolver instance is requsted from the injector and its resolveComponentFactory method called. We see at 2 the create method returns a new instance of ComponentNgElementStrategy.

ComponentNgElementStrategy implements NgElementStrategy with a constructor that initializes two private properties:

export class ComponentNgElementStrategy implements NgElementStrategy { 
  constructor(
    private componentFactory: ComponentFactory<any>, 
    private injector: Injector) {}
  ...
}

The main part of the connect method calls initializeComponent:

connect(element: HTMLElement) {
    ..
    if (!this.componentRef) {
      this.initializeComponent(element);
    }
  }

The initializeComponent method is as follows:

/*
  Creates a new component through the component factory with the provided element host and sets up its initial inputs, listens for outputs changes, and runs an initial change detection.
*/
  protected initializeComponent(element: HTMLElement) {
    const childInjector = 
      Injector.create({providers: [], 
      parent: this.injector});
    const projectableNodes =
        extractProjectableNodes(element,
           this.componentFactory.ngContentSelectors);
    this.componentRef = this.componentFactory.create(
             childInjector, projectableNodes, element);

    this.implementsOnChanges =
        isFunction(
          (this.componentRef.instance as any as OnChanges).ngOnChanges);

    this.initializeInputs();
    this.initializeOutputs();

    this.detectChanges();

    const applicationRef = this.injector.get<ApplicationRef>(ApplicationRef);
    applicationRef.attachView(this.componentRef.hostView);
  }

The create-custom-element.ts file contains a couple of interfaces, an abstract class, a type and a function. the WithProperties type is defined as:

/* Additional type information that can be added to the NgElement class, for properties that are added based on the inputs and methods of the underlying component.*/
export type WithProperties<P> = {
  [property in keyof P]: P[property]
};

NgElementConstructor defines a class constructor:

/* Prototype for a class constructor based on an Angular component that can be used for custom element registration. Implemented and returned by the {@link createCustomElement createCustomElement() function}. */
export interface NgElementConstructor<P> {
  /* An array of observed attribute names for the custom element, derived by transforming input property names from the source component.*/
  readonly observedAttributes: string[];

  /* Initializes a constructor instance.
   * @param injector The source component's injector.
   */
  new (injector: Injector): NgElement&WithProperties<P>;
}

We note the return type of new is NgElement&WithProperties<P>, the intersection of NgElement and WithProperties. Read more about TypeScript intersection types here:

The NgElement class derives from HTMLElement and starts with two protected properties:

export abstract class NgElement extends HTMLElement {
  // The strategy that controls how a component is transformed in a custom element.
  protected ngElementStrategy !: NgElementStrategy;
  //  A subscription to change, connect, and disconnect events in the custom element.
  protected ngElementEventsSubscription: Subscription|null = null;

Then it defines three abstract methods for the custom element methods attributeChangedCallback, connectedCallback and disconnectedCallback:

  /**
    * Prototype for a handler that responds to a change in an observed attribute.
    * @param attrName The name of the attribute that has changed.
    * @param oldValue The previous value of the attribute.
    * @param newValue The new value of the attribute.
    * @param namespace The namespace in which the attribute is defined.
    * @returns Nothing.
    */
  abstract attributeChangedCallback(
      attrName: string, oldValue: string|null, newValue: string, namespace?: string): void;
  // Prototype for a handler that responds to the insertion of the custom element in the DOM.

  abstract connectedCallback(): void;
  // Prototype for a handler that responds to the deletion of the custom element from the DOM.
  abstract disconnectedCallback(): void;
}

NgElementConfig is another simple class, with just two properties:

/**
 * A configuration that initializes an NgElementConstructor with the
 * dependencies and strategy it needs to transform a component into
 * a custom element class.
 *
 * @experimental
 */
export interface NgElementConfig {
  /**
   * The injector to use for retrieving the component's factory.
   */
  injector: Injector;
  /**
   * An optional custom strategy factory to use instead of the default.
   * The strategy controls how the tranformation is performed.
   */
  strategyFactory?: NgElementStrategyFactory;
}

The heavy lifting as regards custom elements is in the createCustomElement function. This includes a nested class, NgElementImpl, which we will look at first. when you create your own custom elemnt using Angular Elements, it is going to derive from NgElementImpl, so it plays a central role. It starts as follows:

class NgElementImpl extends NgElement {
    static readonly['observedAttributes'] = Object.keys(attributeToPropertyInputs);
    constructor(injector?: Injector) {
      super();
      this.ngElementStrategy = strategyFactory.create(injector || config.injector);
    }

Note the constructor calls super(), as required by the Custom Elements specification. The strategyFactory variable comes from the enclosing function, createCustomElement, whcih we will look at shortly.

The attributeChangedCallback handles attribute changes:

    attributeChangedCallback(
        attrName: string, oldValue: string|null, newValue: string, namespace?: string): void {
      if (!this.ngElementStrategy) {
        this.ngElementStrategy = strategyFactory.create(config.injector);
      }

      const propName = attributeToPropertyInputs[attrName] !;
      this.ngElementStrategy.setInputValue(propName, newValue);
    }

Connected and disconnected calbacks are as follows:

    connectedCallback(): void {
      if (!this.ngElementStrategy) {
        this.ngElementStrategy = strategyFactory.create(config.injector);
      }

      this.ngElementStrategy.connect(this);

      // Listen for events from the strategy and dispatch them as custom events
      this.ngElementEventsSubscription = this.ngElementStrategy.events.subscribe(e => {
        const customEvent = createCustomEvent(this.ownerDocument, e.name, e.value);
        this.dispatchEvent(customEvent);
      });
    }

    disconnectedCallback(): void {
      if (this.ngElementStrategy) {
        this.ngElementStrategy.disconnect();
      }

      if (this.ngElementEventsSubscription) {
        this.ngElementEventsSubscription.unsubscribe();
        this.ngElementEventsSubscription = null;
      }
    }
  }

If we exclude NgElementImpl, then createCustomElement function looks like:

export function createCustomElement<P>(
    component: Type<any>, config: NgElementConfig): NgElementConstructor<P> {
  const inputs = getComponentInputs(component, config.injector);

  const strategyFactory =
      config.strategyFactory || new ComponentNgElementStrategyFactory(component, config.injector);

  const attributeToPropertyInputs = getDefaultAttributeToPropertyInputs(inputs);

  /* Add getters and setters to the prototype for each property input. If the config does not contain property inputs, use all inputs by default.*/
  inputs.map(({propName}) => propName).forEach(property => {
    Object.defineProperty(NgElementImpl.prototype, property, {
      get: function() { return this.ngElementStrategy.getInputValue(property); },
      set: function(newValue: any) { this.ngElementStrategy.setInputValue(property, newValue); },
      configurable: true,
      enumerable: true,
    });
  });

  return (NgElementImpl as any) as NgElementConstructor<P>;
}

Schematics Sub-Directory

Angular Elements include schematics support, in the schematics sub-directory:

It adds the document-register-element polyfill. Let's see how it works. At the root of the schematics sub-directory is a file called collection.json, with this content:

{
  "$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
  "schematics": {
    "ng-add": {
      "description": "Adds the document-register-element polyfill.",
      "factory": "./ng-add"
    }
  }
}

It describes what commands are supported, and where the command is implemented (in this case, ng-add). Inside the ng-add sub-directory, we see there is an index.ts file, with one exported default function, along with two helper functions. The default function is:

export default function(options: Schema): Rule {
  return chain([
    options && options.skipPackageJson ? noop() 
        : addPackageJsonDependency(), addScript(options)
  ]);
}

The chain function is taken from @angular-devkit/schematics. The addPackageJsonDependency helpder function extends package.json:

/** Adds a package.json dependency for document-register-element */
function addPackageJsonDependency() {
  return (host: Tree, context: SchematicContext) => {

    if (host.exists('package.json')) {
      const jsonStr = host.read('package.json') !.toString('utf-8');
      const json = JSON.parse(jsonStr);

      // If there are no dependencies, create an entry for dependencies.
      const type = 'dependencies';
      if (!json[type]) {
        json[type] = {};
      }

      // If not already present, add the dependency.
      const pkg = 'document-register-element';
      const version = '^1.7.2';
      if (!json[type][pkg]) {
        json[type][pkg] = version;
      }

      // Write the JSON back to package.json
      host.overwrite('package.json', JSON.stringify(json, null, 2));
      context.logger.log('info', 'Added `document-register-element` as a dependency.');

      // Install the dependency
      context.addTask(new NodePackageInstallTask());
    }

    return host;
  };
}

The addScript helper function is for document-register-element.js:

/** Adds the document-register-element.js script to the angular CLI json. */
function addScript(options: Schema) {
  return (host: Tree, context: SchematicContext) => {
    const script = 'node_modules/document-register-element/build/document-register-element.js';


    try {
      // Handle the new json - angular.json
      const angularJsonFile = host.read('angular.json');
      if (angularJsonFile) {
        const json = JSON.parse(angularJsonFile.toString('utf-8'));
        const project = Object.keys(json['projects'])[0] || options.project;
        const scripts = json['projects'][project]['architect']['build']['options']['scripts'];
        scripts.push({input: script});
        host.overwrite('angular.json', JSON.stringify(json, null, 2));
      }
    } catch (e) {
      context.logger.log(
          'warn', 'Failed to add the polyfill document-register-element.js to scripts');
    }

    context.logger.log('info', 'Added document-register-element.js polyfill to scripts');

    return host;
  };
}