Binding and templating internals

Deep dive into Aurelia's binding system, template compilation pipeline, and rendering architecture for framework contributors and advanced plugin authors.

Understand how Aurelia's binding and templating system works under the hood. This guide covers the compilation pipeline, binding lifecycle, observation strategies, and rendering architecture—essential knowledge for framework contributors, plugin authors, and developers debugging complex binding scenarios.

Audience

This guide is for:

  • Framework contributors - Understanding the codebase

  • Plugin authors - Extending the compiler or runtime

  • Advanced developers - Debugging complex binding issues

  • Performance optimizers - Understanding cost of bindings

Architecture Overview

┌─────────────────┐
│  Template HTML  │
└────────┬────────┘


┌─────────────────┐
│ Template Parser │  (HTMLParser)
└────────┬────────┘


┌─────────────────┐
│   Compilation   │  (TemplateCompiler)
│   - Parse       │
│   - Analyze     │
│   - Generate    │
└────────┬────────┘


┌─────────────────┐
│  Instructions   │  (Binding, Listener, etc.)
└────────┬────────┘


┌─────────────────┐
│   Rendering     │  (Renderer)
└────────┬────────┘


┌─────────────────┐
│  Live Bindings  │  (Binding, Listener, etc.)
│  + Observers    │
└─────────────────┘

Part 1: Template Compilation

Phase 1: HTML Parsing

Aurelia uses the browser's native HTML parser wrapped in HTMLParser:

// packages/template-compiler/src/html-parser.ts
export class HTMLParser {
  parse(markup: string): DocumentFragment {
    const template = document.createElement('template');
    template.innerHTML = markup;
    return template.content;
  }
}

Key Points:

  • Uses native <template> element

  • Parses into DocumentFragment

  • Preserves DOM structure exactly

  • No virtual DOM involved

Phase 2: Template Compilation

The TemplateCompiler walks the DOM and generates instructions:

// Simplified compilation flow
export class TemplateCompiler {
  compile(definition: PartialResourceDefinition): ResourceDefinition {
    // 1. Parse template into DOM
    const fragment = this.parser.parse(definition.template);

    // 2. Walk DOM tree
    const instructions = this.compileNode(fragment);

    // 3. Return compiled definition
    return {
      ...definition,
      instructions,
      template: fragment
    };
  }

  private compileNode(node: Node): IInstruction[] {
    const instructions: IInstruction[] = [];

    // Check for custom elements
    const elementDefinition = this.resources.find(CustomElement, node.nodeName);
    if (elementDefinition) {
      instructions.push(new HydrateElementInstruction(elementDefinition));
    }

    // Check for custom attributes
    for (const attr of node.attributes) {
      const attrDefinition = this.resources.find(CustomAttribute, attr.name);
      if (attrDefinition) {
        instructions.push(new HydrateAttributeInstruction(attrDefinition));
      }
    }

    // Process bindings
    for (const attr of node.attributes) {
      const command = this.parseCommand(attr);
      if (command) {
        const instruction = command.build(attr, this.parser);
        instructions.push(instruction);
      }
    }

    // Recurse to children
    for (const child of node.childNodes) {
      instructions.push(...this.compileNode(child));
    }

    return instructions;
  }
}

Phase 3: Instruction Generation

Instructions are data structures describing what to create at runtime:

// Property binding instruction
export class PropertyBindingInstruction {
  constructor(
    public from: IExpression,    // Source expression
    public to: string,            // Target property
    public mode: BindingMode      // oneTime, toView, twoWay, etc.
  ) {}
}

// Listener binding instruction
export class ListenerBindingInstruction {
  constructor(
    public from: IExpression,    // Handler expression
    public to: string,            // Event name
    public strategy: EventStrategy  // None, Capturing, etc.
  ) {}
}

// Interpolation instruction
export class InterpolationInstruction {
  constructor(
    public expressions: IExpression[],  // Expressions to interpolate
    public parts: string[]               // Static string parts
  ) {}
}

Instruction Types:

  • PropertyBindingInstruction - .bind, .one-way, .two-way

  • ListenerBindingInstruction - .trigger, .delegate, .capture

  • RefBindingInstruction - ref attribute

  • IteratorBindingInstruction - repeat.for

  • HydrateElementInstruction - Custom elements

  • HydrateAttributeInstruction - Custom attributes

  • Interpolation Instruction - ${expression}

Part 2: Expression Parsing

Expression Parser

Converts strings to Abstract Syntax Trees (AST):

// Example: "user.name | uppercase"
const ast = parser.parse("user.name | uppercase", "IsProperty");

// Results in AST:
{
  type: 'ValueConverter',
  expression: {
    type: 'AccessScope',
    name: 'user',
    ancestor: 0
  },
  name: 'uppercase',
  args: []
}

Expression Types

// Access member: obj.prop
export class AccessMember {
  constructor(
    public object: IExpression,
    public name: string
  ) {}

  evaluate(scope: IScope) {
    const obj = this.object.evaluate(scope);
    return obj?.[this.name];
  }
}

// Access scope: variableName
export class AccessScope {
  constructor(
    public name: string,
    public ancestor: number = 0
  ) {}

  evaluate(scope: IScope) {
    let currentScope = scope;
    for (let i = 0; i < this.ancestor; i++) {
      currentScope = currentScope.parentScope;
    }
    return currentScope.bindingContext[this.name];
  }
}

// Binary operation: a + b
export class Binary {
  constructor(
    public operation: string,
    public left: IExpression,
    public right: IExpression
  ) {}

  evaluate(scope: IScope) {
    const left = this.left.evaluate(scope);
    const right = this.right.evaluate(scope);
    switch (this.operation) {
      case '+': return left + right;
      case '-': return left - right;
      case '*': return left * right;
      // ... etc
    }
  }
}

Binding Modes

export const enum BindingMode {
  oneTime = 0,    // Evaluate once, never update
  toView = 1,     // Source → Target only
  fromView = 2,   // Target → Source only
  twoWay = 3,     // Source ↔ Target
  default = 4     // Let target decide
}

Part 3: Rendering & Hydration

Renderer

Converts instructions into live bindings:

export class Renderer {
  render(
    controller: IController,
    targets: ArrayLike<INode>,
    instructions: IInstruction[]
  ): void {
    for (const instruction of instructions) {
      this.renderInstruction(controller, targets, instruction);
    }
  }

  private renderInstruction(
    controller: IController,
    targets: ArrayLike<INode>,
    instruction: IInstruction
  ): void {
    switch (instruction.type) {
      case 'PropertyBinding':
        this.renderPropertyBinding(controller, targets, instruction);
        break;
      case 'ListenerBinding':
        this.renderListenerBinding(controller, targets, instruction);
        break;
      // ... other types
    }
  }

  private renderPropertyBinding(
    controller: IController,
    targets: ArrayLike<INode>,
    instruction: PropertyBindingInstruction
  ): void {
    const target = targets[instruction.targetNodeIndex];
    const binding = new PropertyBinding(
      instruction.from,
      target,
      instruction.to,
      instruction.mode,
      controller.container.get(IObserverLocator)
    );
    controller.addBinding(binding);
  }
}

Binding Lifecycle

export class PropertyBinding {
  constructor(
    private sourceExpression: IExpression,
    private target: any,
    private targetProperty: string,
    private mode: BindingMode,
    private observerLocator: IObserverLocator
  ) {}

  $bind(scope: IScope) {
    this.scope = scope;

    // Get source observer
    if (this.mode & BindingMode.toView) {
      this.sourceExpression.connect(this, scope);
    }

    // Get target observer
    if (this.mode & BindingMode.fromView) {
      this.targetObserver = this.observerLocator.getObserver(
        this.target,
        this.targetProperty
      );
      this.targetObserver.subscribe(this);
    }

    // Initial update
    if (this.mode !== BindingMode.fromView) {
      this.updateTarget(this.sourceExpression.evaluate(scope));
    }
  }

  $unbind() {
    this.scope = null;
    this.sourceExpression.disconnect(this);
    this.targetObserver?.unsubscribe(this);
  }

  // Called when source changes
  handleChange(newValue: unknown) {
    if (this.mode & BindingMode.toView) {
      this.updateTarget(newValue);
    }
  }

  // Called when target changes
  handleTargetChange(newValue: unknown) {
    if (this.mode & BindingMode.fromView) {
      this.sourceExpression.assign(this.scope, newValue);
    }
  }

  private updateTarget(value: unknown) {
    this.target[this.targetProperty] = value;
  }
}

Part 4: Observation System

Observer Types

PropertyObserver (Plain Objects)

export class PropertyObserver {
  private subscribers: ISubscriber[] = [];
  private currentValue: unknown;

  constructor(
    private obj: object,
    private propertyKey: string
  ) {
    this.currentValue = obj[propertyKey];
    this.createGetterSetter();
  }

  private createGetterSetter() {
    const { obj, propertyKey, currentValue } = this;

    Object.defineProperty(obj, propertyKey, {
      enumerable: true,
      configurable: true,
      get: () => {
        return this.currentValue;
      },
      set: (newValue: unknown) => {
        if (newValue !== this.currentValue) {
          const oldValue = this.currentValue;
          this.currentValue = newValue;
          this.notify(newValue, oldValue);
        }
      }
    });
  }

  subscribe(subscriber: ISubscriber) {
    this.subscribers.push(subscriber);
  }

  unsubscribe(subscriber: ISubscriber) {
    const index = this.subscribers.indexOf(subscriber);
    if (index !== -1) {
      this.subscribers.splice(index, 1);
    }
  }

  private notify(newValue: unknown, oldValue: unknown) {
    for (const subscriber of this.subscribers) {
      subscriber.handleChange(newValue, oldValue);
    }
  }
}

SetterObserver (Dirty Checking)

// For properties that can't use getters/setters
export class SetterObserver {
  private subscribers: ISubscriber[] = [];
  private oldValue: unknown;

  constructor(
    private obj: object,
    private propertyKey: string
  ) {
    this.oldValue = obj[propertyKey];
  }

  getValue() {
    return this.obj[this.propertyKey];
  }

  setValue(newValue: unknown) {
    this.obj[this.propertyKey] = newValue;
  }

  // Called by scheduler
  flushChanges() {
    const newValue = this.getValue();
    if (newValue !== this.oldValue) {
      const oldValue = this.oldValue;
      this.oldValue = newValue;
      this.notify(newValue, oldValue);
    }
  }
}

ArrayObserver

export class ArrayObserver {
  private subscribers: ISubscriber[] = [];

  constructor(private array: any[]) {
    this.wrapMutatorMethods();
  }

  private wrapMutatorMethods() {
    const proto = Array.prototype;
    const methods = ['push', 'pop', 'shift', 'unshift', 'splice', 'reverse', 'sort'];

    for (const method of methods) {
      this.array[method] = (...args: any[]) => {
        const result = proto[method].apply(this.array, args);
        this.notify({
          type: 'splice',
          object: this.array,
          index: 0,  // Varies by method
          removed: [],
          addedCount: args.length
        });
        return result;
      };
    }
  }

  subscribe(subscriber: ISubscriber) {
    this.subscribers.push(subscriber);
  }

  private notify(changeRecord: IArrayChangeRecord) {
    for (const subscriber of this.subscribers) {
      subscriber.handleChange(changeRecord);
    }
  }
}

ObserverLocator

Central registry for creating observers:

export class ObserverLocator {
  getObserver(obj: any, propertyKey: string): IObserver {
    // Check for existing observer
    let observer = this.getExistingObserver(obj, propertyKey);
    if (observer) {
      return observer;
    }

    // Create new observer based on object type
    if (obj instanceof Node) {
      observer = this.createElementObserver(obj, propertyKey);
    } else if (Array.isArray(obj)) {
      observer = this.createArrayObserver(obj);
    } else if (typeof obj === 'object') {
      observer = this.createPropertyObserver(obj, propertyKey);
    } else {
      throw new Error(`Cannot observe ${typeof obj}`);
    }

    this.cacheObserver(obj, propertyKey, observer);
    return observer;
  }
}

Part 5: Change Detection & Scheduling

Observation Strategies

Proxy-Based (Default)

// Wrap objects in Proxy for automatic observation
const proxy = new Proxy(obj, {
  get(target, key) {
    trackAccess(target, key);  // Record dependency
    return Reflect.get(target, key);
  },
  set(target, key, value) {
    const result = Reflect.set(target, key, value);
    notifyChange(target, key, value);  // Trigger updates
    return result;
  }
});

Getter/Setter-Based

// Define getters/setters on first access
Object.defineProperty(obj, 'name', {
  get() { return this._name; },
  set(value) {
    if (this._name !== value) {
      this._name = value;
      notifyObservers('name', value);
    }
  }
});

Task Queue

Batches DOM updates:

export class TaskQueue {
  private tasks: QueueTask[] = [];
  private flushing = false;

  queueTask(callback: () => void) {
    this.tasks.push({ callback });
    if (!this.flushing) {
      this.scheduleFlush();
    }
  }

  private scheduleFlush() {
    this.flushing = true;
    Promise.resolve().then(() => this.flush());
  }

  private flush() {
    const tasks = this.tasks.splice(0);
    for (const task of tasks) {
      task.callback();
    }
    this.flushing = false;
  }
}

RAF Queue

For animation-frame timing:

export class DOMQueue {
  private tasks: QueueTask[] = [];
  private rafId: number = 0;

  queueTask(callback: () => void, options?: { persistent?: boolean }) {
    this.tasks.push({ callback, persistent: options?.persistent });
    if (!this.rafId) {
      this.scheduleRAF();
    }
  }

  private scheduleRAF() {
    this.rafId = requestAnimationFrame(() => this.flush());
  }

  private flush() {
    const tasks = this.tasks;
    const persistentTasks = [];

    for (const task of tasks) {
      task.callback();
      if (task.persistent) {
        persistentTasks.push(task);
      }
    }

    this.tasks = persistentTasks;
    this.rafId = 0;

    if (persistentTasks.length > 0) {
      this.scheduleRAF();
    }
  }
}

Part 6: Performance Characteristics

Binding Costs

Binding Type
Compile Time
Runtime Setup
Per-Update Cost

One-Time

Low

None

Zero

To-View

Low

Low

Low

Two-Way

Low

Medium

Medium

Interpolation

Medium

Low

Low

Repeat

High

High

High

Observation Costs

Strategy
Memory
CPU (Setup)
CPU (Update)

Proxy

Low

Low

Very Low

Getter/Setter

Medium

Medium

Low

Dirty Checking

Low

Low

High

Part 7: Debugging Techniques

Enable Debug Mode

import { Aurelia } from 'aurelia';
import { DebugConfiguration } from '@aurelia/debug';

Aurelia
  .register(DebugConfiguration)
  .app(MyApp)
  .start();

Inspect Bindings

// In browser console
const controller = au.controllers.find(MyComponent);
console.log(controller.bindings);  // List all bindings

// Inspect specific binding
const binding = controller.bindings[0];
console.log({
  source: binding.sourceExpression,
  target: binding.target,
  mode: binding.mode,
  scope: binding.scope
});

Trace Observations

import { IObserverLocator } from 'aurelia';

const locator = container.get(IObserverLocator);
const originalGetObserver = locator.getObserver;

locator.getObserver = function(obj, key) {
  console.log(`Observing: ${obj.constructor.name}.${key}`);
  return originalGetObserver.call(this, obj, key);
};

Profile Bindings

// Count binding updates
let updateCount = 0;
const originalUpdateTarget = PropertyBinding.prototype.updateTarget;

PropertyBinding.prototype.updateTarget = function(value) {
  updateCount++;
  console.log(`Update #${updateCount}:`, this.targetProperty, '=', value);
  return originalUpdateTarget.call(this, value);
};

Part 8: Plugin Development

Custom Binding Command

import { bindingCommand } from '@aurelia/template-compiler';
import { PropertyBindingInstruction } from '@aurelia/runtime-html';

@bindingCommand('throttle')
export class ThrottleBindingCommand {
  public get ignoreAttr(): boolean {
    return false;
  }

  public build(info, parser) {
    const expr = parser.parse(info.attr.rawValue, 'IsProperty');

    // Wrap expression with throttling logic
    const throttledExpr = {
      ...expr,
      evaluate(scope, binding) {
        return throttle(() => expr.evaluate(scope, binding), 300);
      }
    };

    return new PropertyBindingInstruction(
      throttledExpr,
      info.attr.target,
      'toView'
    );
  }
}

Custom Observer

export class LocalStorageObserver {
  private value: string;
  private subscribers: ISubscriber[] = [];

  constructor(private key: string) {
    this.value = localStorage.getItem(key) ?? '';
    window.addEventListener('storage', this.handleStorageChange);
  }

  getValue() {
    return this.value;
  }

  setValue(newValue: string) {
    if (newValue !== this.value) {
      this.value = newValue;
      localStorage.setItem(this.key, newValue);
      this.notify(newValue);
    }
  }

  private handleStorageChange = (e: StorageEvent) => {
    if (e.key === this.key && e.newValue !== this.value) {
      this.value = e.newValue ?? '';
      this.notify(this.value);
    }
  };

  subscribe(subscriber: ISubscriber) {
    this.subscribers.push(subscriber);
  }

  private notify(newValue: string) {
    for (const subscriber of this.subscribers) {
      subscriber.handleChange(newValue);
    }
  }

  dispose() {
    window.removeEventListener('storage', this.handleStorageChange);
  }
}

Resources

Contributing

If you're interested in contributing to Aurelia's core:

  1. Join the Discord

Last updated

Was this helpful?