Property vs Attribute Binding bij LIT Elements in Angular ​
Inleiding ​
Bij het integreren van LIT elements (webcomponenten) in Angular applicaties ontstaan er subtiele maar belangrijke verschillen in hoe data wordt doorgegeven aan componenten. Deze verschillen worden vooral zichtbaar wanneer er DOM-manipulatie plaatsvindt, zoals bij het gebruik van Angular CDK's drag & drop functionaliteit.
In deze gids verkennen we de fundamentele verschillen tussen property binding en attribute binding, waarom deze verschillen belangrijk zijn voor DCR components, en hoe je deze kennis kunt gebruiken om robuuste integraties te bouwen tussen Angular en LIT elements.
Het verschil tussen Properties en Attributes ​
DOM Properties vs HTML Attributes ​
Om de verschillen te begrijpen, moeten we eerst het verschil kennen tussen DOM properties en HTML attributes:
HTML Attributes zijn de waarden die je schrijft in HTML markup:
<input type="text" value="initial" class="form-control" />DOM Properties zijn de JavaScript eigenschappen van DOM elementen:
const input = document.querySelector('input');
input.value = 'new value';
input.className = 'updated-class';Synchronisatie tussen Properties en Attributes ​
Voor veel standaard HTML elementen gebeurt er automatische synchronisatie:
<!-- HTML attribute -->
<input type="text" value="initial" />const input = document.querySelector('input');
// Property reading
console.log(input.value); // "initial"
// Property update
input.value = 'updated';
// Attribute blijft ongewijzigd!
console.log(input.getAttribute('value')); // "initial"
// Maar property is wel bijgewerkt
console.log(input.value); // "updated"Dit gedrag is inconsistent en afhankelijk van het specifieke element en eigenschap.
Angular's Binding Strategieën ​
Property Binding: [property]="value" ​
Angular's property binding zet de waarde direct als JavaScript property op het DOM element:
// Angular component
@Component({
template: ` <dcr-icon [icon]="'bundles:' + fileExt"></dcr-icon> `,
})
export class FileViewerComponent {
fileExt = 'pdf';
}Dit resulteert in:
// Intern door Angular uitgevoerd
element.icon = 'bundles:pdf';Voordelen van property binding:
- Type-veiligheid: Directe toewijzing van JavaScript waarden
- Prestaties: Geen string serialisatie/deserialisatie
- Complexe data: Kan objecten, arrays, functies doorgeven
- Directe toegang: Geen conversie via attributes
Nadelen van property binding:
- Volatiliteit: Properties bestaan alleen in memory
- DOM-manipulatie: Kunnen verloren gaan bij DOM-bewegingen
- Debugging: Niet zichtbaar in HTML inspector
- SSR-compatibiliteit: Niet beschikbaar tijdens server-side rendering
Attribute Binding: [attr.property]="value" ​
Angular's attribute binding zet de waarde als HTML attribute:
// Angular component
@Component({
template: ` <dcr-icon [attr.icon]="'bundles:' + fileExt"></dcr-icon> `,
})
export class FileViewerComponent {
fileExt = 'pdf';
}Dit resulteert in:
// Intern door Angular uitgevoerd
element.setAttribute('icon', 'bundles:pdf');Voordelen van attribute binding:
- Persistentie: Attributes blijven bestaan tijdens DOM-manipulatie
- Zichtbaarheid: Zichtbaar in HTML inspector en DevTools
- Serialisatie: Kunnen worden geserialiseerd/gedeserialiseerd
- SSR-compatibiliteit: Beschikbaar tijdens server-side rendering
Nadelen van attribute binding:
- String-only: Alleen string waarden (automatische serialisatie)
- Prestaties: Serialisatie/deserialisatie overhead
- Type-verlies: Complexe types worden geconverteerd naar strings
- Case-sensitivity: Attribute namen zijn case-insensitive in HTML
LIT Elements en Reactieve Properties ​
Hoe LIT Properties Werken ​
LIT elements definiëren reactieve properties die kunnen reageren op zowel property als attribute wijzigingen:
// dcr-icon.ts
import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import styles from './dcr-icon.scss';
@customElement('dcr-icon')
export class DcrIcon extends LitElement {
static styles = styles;
@property({ type: String })
icon = '';
render() {
return html`
<svg class="icon">
<!-- SVG content gebaseerd op icon property -->
</svg>
`;
}
}Property Reflection en Observed Attributes ​
LIT elements kunnen automatisch synchroniseren tussen properties en attributes:
// dcr-button.ts
import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import styles from './dcr-button.scss';
@customElement('dcr-button')
export class DcrButton extends LitElement {
static styles = styles;
// Deze property wordt gereflecteerd naar een HTML attribute
@property({ type: String, reflect: true })
variant: 'primary' | 'secondary' | 'outline' = 'primary';
// Deze property is alleen beschikbaar als JavaScript property
@property({ type: Boolean })
disabled = false;
// Complex object - niet geschikt voor reflection
@property({ type: Object })
config: ButtonConfig = {};
render() {
return html`
<button class="btn btn--${this.variant}" ?disabled=${this.disabled}>
<slot></slot>
</button>
`;
}
}// dcr-button.scss
@use '@diekeure/cds-tokens' as cds;
:host {
display: inline-block;
}
:host([variant='primary']) .btn {
background-color: rgb(cds.get('cds.sys.color.primary'));
color: rgb(cds.get('cds.sys.color.on-primary'));
}
:host([variant='secondary']) .btn {
background-color: rgb(cds.get('cds.sys.color.secondary'));
color: rgb(cds.get('cds.sys.color.on-secondary'));
}
.btn {
border: none;
padding: cds.get('cds.sys.spacing.xs') cds.get('cds.sys.spacing.s');
border-radius: cds.get('cds.sys.shape.small');
cursor: pointer;
@include cds.font('label.large');
}Observed Attributes ​
LIT automatisch genereert observedAttributes voor properties met reflection:
// Automatisch gegenereerd door LIT
static get observedAttributes() {
return ['variant']; // alleen properties met reflect: true
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'variant') {
this.variant = newValue;
}
}Het DOM Manipulation Probleem ​
Het Scenario ​
Stel je voor dat we een bestandsverkenner hebben met drag & drop functionaliteit:
// file-explorer.component.ts
import { Component } from '@angular/core';
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
interface FileItem {
id: string;
name: string;
extension: string;
size: number;
}
@Component({
selector: 'app-file-explorer',
template: `
<div cdkDropList (cdkDropListDropped)="drop($event)" class="file-list">
<div *ngFor="let file of files" cdkDrag class="file-item">
<!-- Property binding - PROBLEMATISCH bij drag & drop -->
<dcr-icon [icon]="'bundles:' + file.extension"></dcr-icon>
<!-- Attribute binding - WERKT WEL bij drag & drop -->
<!-- <dcr-icon [attr.icon]="'bundles:' + file.extension"></dcr-icon> -->
<span class="filename">{{ file.name }}</span>
<span class="filesize">{{ file.size | filesize }}</span>
</div>
</div>
`,
styleUrls: ['./file-explorer.component.scss'],
})
export class FileExplorerComponent {
files: FileItem[] = [
{ id: '1', name: 'document.pdf', extension: 'pdf', size: 1024 },
{ id: '2', name: 'image.jpg', extension: 'jpg', size: 2048 },
{ id: '3', name: 'spreadsheet.xlsx', extension: 'xlsx', size: 512 },
];
drop(event: CdkDragDrop<FileItem[]>) {
moveItemInArray(this.files, event.previousIndex, event.currentIndex);
}
}// file-explorer.component.scss
@use '@diekeure/cds-tokens' as cds;
.file-list {
display: flex;
flex-direction: column;
gap: cds.get('cds.sys.spacing.xs');
padding: cds.get('cds.sys.spacing.s');
}
.file-item {
display: flex;
align-items: center;
gap: cds.get('cds.sys.spacing.xs');
padding: cds.get('cds.sys.spacing.xs');
border: 1px solid rgb(cds.get('cds.sys.color.outline'));
border-radius: cds.get('cds.sys.shape.small');
cursor: grab;
&:active {
cursor: grabbing;
}
}
.filename {
flex: 1;
@include cds.font('body.medium');
}
.filesize {
@include cds.font('body.small');
color: rgb(cds.get('cds.sys.color.on-surface-variant'));
}Wat Gebeurt Er Tijdens het Slepen? ​
- Drag Start: Angular CDK creëert een clone van het gesleepte element
- DOM Manipulation: Het originele element wordt uit de DOM verwijderd
- Property Loss: JavaScript properties bestaan alleen in memory en gaan verloren
- Drag End: Element wordt weer ingevoegd op nieuwe positie
- Re-evaluation: Angular evalueert bindings opnieuw, maar timing kan problemen veroorzaken
Probleem Visualisatie ​
<!-- Voor drag start -->
<dcr-icon icon="bundles:pdf">
<!-- Interne state: this.icon = "bundles:pdf" -->
</dcr-icon>
<!-- Tijdens drag (element clone) -->
<dcr-icon>
<!-- Property verloren! this.icon = undefined -->
<!-- Geen icon wordt getoond -->
</dcr-icon>
<!-- Na drop -->
<dcr-icon icon="bundles:pdf">
<!-- Property mogelijk nog steeds undefined -->
<!-- Angular's change detection heeft de property niet opnieuw gezet -->
</dcr-icon>De Attribute Binding Oplossing ​
Met attribute binding blijft de data behouden:
<!-- Voor drag start -->
<dcr-icon icon="bundles:pdf">
<!-- HTML: <dcr-icon icon="bundles:pdf"> -->
<!-- Property: this.icon = "bundles:pdf" (via observedAttributes) -->
</dcr-icon>
<!-- Tijdens drag (element clone) -->
<dcr-icon icon="bundles:pdf">
<!-- HTML attribute blijft behouden -->
<!-- LIT's attributeChangedCallback herstelt property -->
</dcr-icon>
<!-- Na drop -->
<dcr-icon icon="bundles:pdf">
<!-- Volledig functioneel, geen data verlies -->
</dcr-icon>Geavanceerde Scenarios ​
Complexe Data Structures ​
Voor complexe objecten die niet kunnen worden geserialiseerd naar attributes:
// data-table.component.ts
import { Component } from '@angular/core';
interface ColumnConfig {
key: string;
label: string;
sortable: boolean;
formatter?: (value: any) => string;
}
@Component({
selector: 'app-data-table',
template: `
<div cdkDropList (cdkDropListDropped)="drop($event)">
<dcr-table [columns]="columns" [data]="tableData" cdkDrag></dcr-table>
</div>
`,
})
export class DataTableComponent {
columns: ColumnConfig[] = [
{
key: 'name',
label: 'Naam',
sortable: true,
},
{
key: 'email',
label: 'Email',
sortable: true,
},
{
key: 'createdAt',
label: 'Aangemaakt',
sortable: true,
formatter: (date: Date) => date.toLocaleDateString('nl-BE'),
},
];
tableData = [{ name: 'Jan Janssen', email: 'jan@example.com', createdAt: new Date() }];
}Voor dit scenario moet je een hybride aanpak gebruiken:
// dcr-table.ts
import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import styles from './dcr-table.scss';
@customElement('dcr-table')
export class DcrTable extends LitElement {
static styles = styles;
// Complex object - kan alleen via property binding
@property({ type: Array })
columns: ColumnConfig[] = [];
@property({ type: Array })
data: any[] = [];
// Backup serialisatie voor critical properties
@property({ type: String, attribute: 'columns-json' })
private columnsJson = '';
// Custom setter voor automatic backup
set columns(value: ColumnConfig[]) {
this._columns = value;
this.columnsJson = JSON.stringify(value);
}
get columns() {
return this._columns;
}
private _columns: ColumnConfig[] = [];
// Recovery mechanism
connectedCallback() {
super.connectedCallback();
// Als property verloren is maar attribute bestaat, herstel dan
if (!this.columns.length && this.columnsJson) {
try {
this.columns = JSON.parse(this.columnsJson);
} catch (e) {
console.warn('Could not parse columns JSON:', this.columnsJson);
}
}
}
render() {
return html`
<table>
<thead>
<tr>
${this.columns.map((column) => html` <th>${column.label}</th> `)}
</tr>
</thead>
<tbody>
${this.data.map(
(row) => html`
<tr>
${this.columns.map((column) => html` <td>${this.formatCellValue(row[column.key], column)}</td> `)}
</tr>
`
)}
</tbody>
</table>
`;
}
private formatCellValue(value: any, column: ColumnConfig): string {
return column.formatter ? column.formatter(value) : String(value);
}
}Event Handling en State Management ​
Een common pattern is om state management te combineren met beide binding types:
// file-upload-zone.component.ts
import { Component } from '@angular/core';
interface UploadFile {
id: string;
name: string;
size: number;
status: 'pending' | 'uploading' | 'completed' | 'error';
progress: number;
}
@Component({
selector: 'app-file-upload-zone',
template: `
<div cdkDropList (cdkDropListDropped)="reorderFiles($event)">
<dcr-file-upload-item
*ngFor="let file of files; trackBy: trackByFileId"
[file]="file"
[attr.file-id]="file.id"
[attr.status]="file.status"
(remove)="removeFile($event)"
(retry)="retryUpload($event)"
cdkDrag
></dcr-file-upload-item>
</div>
`,
})
export class FileUploadZoneComponent {
files: UploadFile[] = [];
trackByFileId(index: number, file: UploadFile): string {
return file.id;
}
removeFile(fileId: string) {
this.files = this.files.filter((f) => f.id !== fileId);
}
retryUpload(fileId: string) {
const file = this.files.find((f) => f.id === fileId);
if (file) {
file.status = 'pending';
// Start upload process
}
}
reorderFiles(event: CdkDragDrop<UploadFile[]>) {
moveItemInArray(this.files, event.previousIndex, event.currentIndex);
}
}// dcr-file-upload-item.ts
import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import styles from './dcr-file-upload-item.scss';
@customElement('dcr-file-upload-item')
export class DcrFileUploadItem extends LitElement {
static styles = styles;
// Complex object via property binding
@property({ type: Object })
file: UploadFile | null = null;
// Critical state via attribute binding (survives drag & drop)
@property({ type: String, reflect: true, attribute: 'file-id' })
fileId = '';
@property({ type: String, reflect: true })
status: UploadFile['status'] = 'pending';
// Auto-sync critical attributes from file object
willUpdate(changedProperties: PropertyValues) {
if (changedProperties.has('file') && this.file) {
this.fileId = this.file.id;
this.status = this.file.status;
}
}
render() {
if (!this.file) {
return html`<div class="error">No file data available</div>`;
}
return html`
<div class="file-item file-item--${this.status}">
<dcr-icon [attr.icon]="'file-types:' + this.getFileExtension()"></dcr-icon>
<div class="file-info">
<span class="filename">${this.file.name}</span>
<span class="filesize">${this.formatFileSize(this.file.size)}</span>
</div>
${this.renderProgress()} ${this.renderActions()}
</div>
`;
}
private renderProgress() {
if (this.status !== 'uploading') return '';
return html`
<div class="progress">
<div class="progress-bar" style="width: ${this.file?.progress || 0}%"></div>
</div>
`;
}
private renderActions() {
return html`
<div class="actions">
${this.status === 'error' ? html`<button @click=${this.handleRetry}>Retry</button>` : ''}
<button @click=${this.handleRemove}>Remove</button>
</div>
`;
}
private getFileExtension(): string {
return this.file?.name.split('.').pop() || 'unknown';
}
private formatFileSize(bytes: number): string {
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
private handleRetry() {
this.dispatchEvent(
new CustomEvent('retry', {
detail: { fileId: this.fileId },
bubbles: true,
composed: true,
})
);
}
private handleRemove() {
this.dispatchEvent(
new CustomEvent('remove', {
detail: { fileId: this.fileId },
bubbles: true,
composed: true,
})
);
}
}Best Practices ​
1. Gebruik Attribute Binding voor Critical Data ​
Voor data die essentieel is voor de basis functionaliteit van je component:
// Goed - critical data via attributes
@Component({
template: `
<dcr-icon [attr.icon]="iconName"></dcr-icon>
<dcr-button [attr.variant]="buttonType"></dcr-button>
<dcr-badge [attr.count]="notificationCount"></dcr-badge>
`,
})
export class CriticalDataComponent {
iconName = 'user';
buttonType = 'primary';
notificationCount = 5;
}2. Gebruik Property Binding voor Complex Data ​
Voor objecten, arrays, en functies die niet kunnen worden geserialiseerd:
// Goed - complex data via properties
@Component({
template: `
<dcr-data-table
[columns]="tableColumns"
[data]="tableData"
[sorter]="customSorter"
></dcr-data-table>
`
})
export class ComplexDataComponent {
tableColumns: ColumnConfig[] = [...];
tableData: any[] = [...];
customSorter = (a: any, b: any) => { /* custom logic */ };
}3. Testing Both Binding Strategies ​
Test beide binding strategieën in je unit tests:
// dcr-icon.spec.ts
import { expect, fixture, html } from '@open-wc/testing';
import { DcrIcon } from './dcr-icon';
describe('DcrIcon', () => {
it('should work with property binding', async () => {
const el = await fixture<DcrIcon>(html`<dcr-icon></dcr-icon>`);
// Property binding
el.icon = 'bundles:pdf';
await el.updateComplete;
expect(el.icon).to.equal('bundles:pdf');
// Test rendered output
});
it('should work with attribute binding', async () => {
const el = await fixture<DcrIcon>(html`<dcr-icon icon="bundles:pdf"></dcr-icon>`);
expect(el.icon).to.equal('bundles:pdf');
expect(el.getAttribute('icon')).to.equal('bundles:pdf');
});
it('should survive DOM manipulation (simulated drag & drop)', async () => {
const container = await fixture(html`
<div>
<dcr-icon icon="bundles:pdf"></dcr-icon>
</div>
`);
const icon = container.querySelector('dcr-icon') as DcrIcon;
expect(icon.icon).to.equal('bundles:pdf');
// Simulate DOM manipulation (like drag & drop)
const parent = icon.parentElement!;
const clonedIcon = icon.cloneNode(true) as DcrIcon;
parent.removeChild(icon);
parent.appendChild(clonedIcon);
await clonedIcon.updateComplete;
// With attribute binding, icon should still work
expect(clonedIcon.icon).to.equal('bundles:pdf');
});
});Conclusie ​
Het begrijpen van property vs attribute binding is cruciaal voor het succesvol integreren van LIT elements in Angular applicaties, vooral wanneer DOM-manipulatie zoals drag & drop involved is.
Belangrijkste takeaways:
Property binding (
[property]) is ideaal voor:- Complexe data types (objecten, arrays, functies)
- Type-veilige data overdracht
- Prestatie-kritieke scenarios
Attribute binding (
[attr.property]) is essentieel voor:- Data die moet overleven DOM-manipulatie
- Drag & drop scenarios
- Server-side rendering compatibiliteit
- Debugging en inspection
Hybride strategieën zijn vaak de beste oplossing:
- Gebruik property binding voor volledige functionaliteit
- Backup kritieke data via attribute binding
- Implementeer recovery mechanismen in je LIT components
LIT components kunnen beide strategieën ondersteunen:
- Definieer properties met
@property()decorator - Gebruik
reflect: truevoor automatische synchronisatie - Implementeer custom converters voor type safety
- Definieer properties met
Door deze principes toe te passen, kun je robuuste DCR components bouwen die correct functioneren in alle Angular scenarios, inclusief complexe drag & drop interfaces.