Become a member!

TemplatePro: Template-Vererbung, die mächtigste Funktion, die viele vergessen

🌐
Dieser Artikel ist auch in anderen Sprachen verfügbar:
🇮🇹 Italiano  •  🇬🇧 English  •  🇪🇸 Español

Vor einigen Tagen war ich bei einem Kunden für eine Beratungssitzung zu einer Webanwendung, die mit DMVCFramework, TemplatePro und HTMX gebaut worden war. Wir führten ein Code-Review durch, als ich den Ordner templates/ öffnete und stutzte.

Zwanzig HTML-Dateien. In jeder dieser Dateien waren alle zwanzig Dialoge vorhanden - kopiert und eingefügt. Die äußere Struktur war immer dieselbe: Header mit Titel, Formular, zwei Schaltflächen. Die Felder des Formulars und der Ziel-Endpunkt änderten sich von Dialog zu Dialog, aber die HTML-Hülle war identisch, zwanzigmal pro Datei, in zwanzig Dateien. Vierhundert Markup-Blöcke für das, was logisch gesehen eine Komponente mit Varianten war.

Ich fragte: “Warum haben Sie alle Dialoge in jede Datei dupliziert?”

Der Junior-Entwickler, mit React aufgewachsen, antwortete: “Klar, mit React hätte ich das besser machen können…”

“Inwiefern?” fragte ich.

“Weißt du, diese alten Sprachen wie HTML und CSS zwingen dich, dich zu wiederholen…”

Ich schwieg. Zählte bis hundert. Dann machte ich weiter.

“Bevor wir weitermachen, sprechen wir über die mächtigste Funktion von TemplatePro.”

Das ist nicht das erste Mal. Fälle wie dieser begegnen mir mit einer gewissen Häufigkeit, und jedes Mal erkläre ich dieselben Dinge neu. Das hat mich davon überzeugt, diesen Beitrag zu schreiben - obwohl die offizielle Dokumentation ausführlich darüber spricht und obwohl es einen über 200 Seiten starken Leitfaden gibt, der TemplatePro in jeder Einzelheit behandelt. Die Hoffnung ist, dass ein narrativer Beitrag, der von einem realen Fall ausgeht, das Konzept auf eine andere Weise festigt.

Kurz zusammengefasst: In TemplatePro erbt {{extends "base.html"}} ein übergeordnetes Template; {{block "name"}} definiert eine überschreibbare Region (wie eine virtuelle Methode in Delphi); {{inherited}} ruft die Implementierung des übergeordneten Templates auf. Die Vererbung ist mehrstufig ohne Tiefenbegrenzung (A←B←C). Wiederverwendbare Partials werden mit {{include "file.html"}} und Variable Mapping für isolierte Scopes eingebunden.

Template-Vererbung: das Konzept

Template-Vererbung ist der Mechanismus, mit dem ein Template ein anderes Template erweitern, dessen Struktur erben und nur die Teile überschreiben kann, die sich ändern. Die Idee stammt aus Jinja2 und anderen modernen Template-Engines. In TemplatePro wird sie mit drei Konstrukten umgesetzt:

  • {{extends "other_template.html"}} - deklariert, dass dieses Template ein anderes erweitert
  • {{block "name"}}...{{endblock}} - definiert eine ersetzbare Region
  • {{inherited}} - bindet den Inhalt des übergeordneten Blocks ein, ohne ihn zu überschreiben

Wenn du ein Delphi-Entwickler bist, hast du bereits das gesamte mentale Modell, das du brauchst, um diesen Mechanismus zu verstehen: Jeder {{block}} ist genau wie eine virtuelle Methode. Das übergeordnete Template deklariert es mit einer Standardimplementierung. Das untergeordnete Template kann es überschreiben - oder auch nicht, in welchem Fall die Implementierung des übergeordneten Templates gilt. Das untergeordnete Template der nächsten Ebene kann seinerseits das des vorherigen überschreiben. Und {{inherited}} entspricht genau dem Schlüsselwort inherited in Delphi: Es ruft die Implementierung der übergeordneten Ebene in der Kette auf und ergänzt sie um etwas.

Delphi - virtuelle Methoden:

type
  TBaseView = class
  public
    procedure RenderTitle; virtual;
    procedure RenderContent; virtual;
  end;

  TChildView = class(TBaseView)
  public
    procedure RenderContent; override; // overridden
    // RenderTitle is NOT overridden: parent's implementation applies
  end;

implementation

procedure TBaseView.RenderTitle;
begin
  WriteLn('<title>My App</title>');
end;

procedure TBaseView.RenderContent;
begin
  WriteLn('<!-- default content -->');
end;

procedure TChildView.RenderContent;
begin
  inherited; // calls TBaseView.RenderContent - optional
  WriteLn('<!-- adds its own content -->');
end;

TemplatePro - Blöcke:

{{# _base.html #}}
<title>{{block "title"}}My App{{endblock}}</title>

{{block "content"}}
  <!-- default content -->
{{endblock}}
{{# child.html #}}
{{extends "_base.html"}}

{{# "title" is not overridden: parent content applies #}}

{{block "content"}}
  {{inherited}}  {{# calls parent block - optional #}}
  <!-- adds its own content -->
{{endblock}}

Die Struktur ist identisch. Es ändert sich nur die Domäne: Klassen und Methoden auf der einen Seite, Templates und Blöcke auf der anderen.

Fall 1: einfache Vererbung - gemeinsames Layout

Die häufigste Verwendung besteht darin, die gemeinsame Struktur der Seite (Navbar, Footer, CSS, JS) von dem Teil zu trennen, der sich von View zu View ändert.

_layout.html - das übergeordnete Template:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>{{block "title"}}My App{{endblock}}</title>
  <link href="/assets/bootstrap/css/bootstrap.min.css" rel="stylesheet">
  {{block "extra_css"}}{{endblock}}
</head>
<body>
  <nav class="navbar navbar-dark bg-dark">
    <div class="container">
      <a class="navbar-brand" href="/">My App</a>
    </div>
  </nav>

  <div class="container mt-4">
    {{block "content"}}{{endblock}}
  </div>

  <script src="/assets/bootstrap/js/bootstrap.bundle.min.js"></script>
  {{block "extra_scripts"}}{{endblock}}
</body>
</html>

customers.html - das untergeordnete Template:

{{extends "_layout.html"}}

{{block "title"}}Customers{{endblock}}

{{block "content"}}
<h2>Customer list</h2>
<table class="table table-striped">
  ...
</table>
{{endblock}}

Wenn TemplatePro customers.html rendert, nimmt es die Struktur von _layout.html und ersetzt die Blöcke durch das, was im untergeordneten Template definiert ist. Nicht neu definierte Blöcke (extra_css, extra_scripts) verwenden den Inhalt des übergeordneten Templates - in diesem Fall leer.

Eine einzige Regel ist zu beachten: Inhalte außerhalb von Blöcken im untergeordneten Template werden ignoriert. Wenn du freies HTML in customers.html außerhalb eines {{block}} schreibst, wird es nicht gerendert.

{{inherited}}: erweitern ohne zu überschreiben

Manchmal möchtest du den Block des übergeordneten Templates nicht ersetzen - du möchtest etwas hinzufügen. Dafür gibt es {{inherited}}.

Angenommen, das Basis-Layout enthält bereits Standard-Meta-Tags im <head>, und für die Berichtsseite möchten wir Chart.js hinzufügen, ohne die gemeinsamen Tags zu verlieren:

_layout.html:

<head>
  {{block "head"}}
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link href="/assets/bootstrap/css/bootstrap.min.css" rel="stylesheet">
  {{endblock}}
</head>

reports.html:

{{extends "_layout.html"}}

{{block "head"}}
{{inherited}}
<link href="/assets/css/charts.css" rel="stylesheet">
<script src="/assets/js/chartjs.min.js"></script>
{{endblock}}

{{inherited}} fügt den Inhalt des übergeordneten Blocks ein, dann werden die neuen Tags hinzugefügt. Das gerenderte Ergebnis ist:

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link href="/assets/bootstrap/css/bootstrap.min.css" rel="stylesheet">
  <link href="/assets/css/charts.css" rel="stylesheet">
  <script src="/assets/js/chartjs.min.js"></script>
</head>

Keine Duplizierung. Die Berichtsseite erbt alles vom übergeordneten Template und fügt nur das hinzu, was sie benötigt.

Fall 2: mehrstufige Vererbung - A ← B ← C

Und damit kommen wir zu dem Teil, den der Kunde überhaupt nicht kannte.

TemplatePro unterstützt Vererbung mit unbegrenzter Tiefe. Ein Template B kann A erweitern, und ein Template C kann B erweitern. Die Auflösungskette durchläuft alle Ebenen von oben nach unten.

Dies ist in realen Anwendungen enorm nützlich, wo man typischerweise Folgendes hat:

  • ein Basis-Layout, das für die gesamte Anwendung gleich ist
  • ein Abschnitts-Layout, das abschnittsspezifische Elemente hinzufügt (z. B. die Sidebar des Admin-Bereichs)
  • die konkreten Seiten, die das Abschnitts-Layout erweitern

_base.html - die absolute Wurzel (Ebene A):

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>{{block "title"}}App{{endblock}}</title>
  {{block "head_extra"}}{{endblock}}
  <link href="/assets/bootstrap/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
  {{block "body"}}{{endblock}}
  <script src="/assets/bootstrap/js/bootstrap.bundle.min.js"></script>
  {{block "scripts_extra"}}{{endblock}}
</body>
</html>

_admin.html - das Layout des Verwaltungsbereichs, erweitert _base.html (Ebene B):

{{extends "_base.html"}}

{{block "head_extra"}}
<link href="/assets/css/admin.css" rel="stylesheet">
{{endblock}}

{{block "body"}}
<div class="d-flex">
  <nav class="sidebar bg-dark text-white" style="min-height:100vh; width:220px">
    <ul class="nav flex-column p-3">
      <li><a class="nav-link text-white" href="/admin/customers">Customers</a></li>
      <li><a class="nav-link text-white" href="/admin/orders">Orders</a></li>
      <li><a class="nav-link text-white" href="/admin/reports">Reports</a></li>
    </ul>
  </nav>
  <div class="flex-grow-1">
    <nav class="navbar navbar-light bg-light border-bottom px-3">
      <span class="navbar-text">Welcome back, {{:username}}</span>
    </nav>
    <div class="container-fluid p-4">
      {{block "content"}}{{endblock}}
    </div>
  </div>
</div>
{{endblock}}

_admin.html weiß nichts über die konkreten Seiten. Es weiß nur, dass es eine Sidebar, eine Navbar und einen content-Slot gibt, der gefüllt werden soll.

customers.html - die Kundenseite, erweitert _admin.html (Ebene C):

{{extends "_admin.html"}}

{{block "title"}}Customers - Admin{{endblock}}

{{block "content"}}
<h2>Customer list</h2>
<p class="text-muted">{{:total_customers}} registered customers</p>

<table class="table table-striped table-hover">
  <thead>
    <tr>
      <th>Code</th><th>Company name</th><th>City</th><th></th>
    </tr>
  </thead>
  <tbody>
    {{for customer in customers}}
    <tr>
      <td><code>{{:customer.code}}</code></td>
      <td>{{:customer.company_name}}</td>
      <td>{{:customer.city}}</td>
      <td>
        <button onclick="document.getElementById('deleteDialog-{{:customer.id}}').showModal()">
          Delete
        </button>
      </td>
    </tr>
    {{endfor}}
  </tbody>
</table>
{{endblock}}

Drei Dateien. Drei getrennte Verantwortlichkeiten. customers.html weiß nicht, wie die Sidebar funktioniert. _admin.html weiß nicht, welche Daten die konkrete Seite anzeigen wird. _base.html weiß noch nicht einmal, dass ein Admin-Bereich existiert.

Genau so sollte es funktionieren.

Zurück zu den zwanzig Dialogen

Sobald das Konzept der Vererbung klar ist, lässt sich das Problem der zwanzig Dialoge mit include und Variable Mapping lösen - der komplementären Funktion zur Vererbung, um wiederverwendbare Partials zu komponentisieren.

Das native HTML-Element für Dialoge ist <dialog>, das von allen modernen Browsern unterstützt wird. Keine Bibliothek notwendig: showModal() zum Öffnen, close() zum Schließen via JavaScript.

Im konkreten Fall des Kunden teilten die zwanzig Dialoge die äußere Struktur - das <dialog>-Tag, einen Titel, das Formular, die zwei Schaltflächen - aber darin änderte sich alles: die action des Endpunkts, der Titel des Dialogs und die Anzahl sowie Art der Formularfelder.

include mit Variable Mapping reicht nicht aus, wenn der zu variierende Inhalt strukturiertes HTML ist: Man kann keine Markup-Blöcke als Parameter übergeben. Hier kommt wieder die Template-Vererbung ins Spiel.

Die Lösung besteht darin, ein Basis-Partial für die Dialog-Hülle zu erstellen und ein untergeordnetes Partial für jeden Dialog-Typ, der dessen Struktur erbt und die variablen Blöcke überschreibt.

partials/_dialog_base.html - die gemeinsame Hülle (Ebene A):

<dialog id="{{:dialog_id}}">
  <h3>{{block "dialog_title"}}{{endblock}}</h3>
  <form method="post" action="{{block "form_action"}}{{endblock}}">
    <div class="dialog-body">
      {{block "form_fields"}}{{endblock}}
    </div>
    <div class="dialog-footer">
      <button type="button" onclick="this.closest('dialog').close()">Cancel</button>
      <button type="submit" class="btn-{{block "submit_style"}}primary{{endblock}}">
        {{block "submit_label"}}Confirm{{endblock}}
      </button>
    </div>
  </form>
</dialog>

Jeder {{block}} ist eine virtuelle Methode: Das untergeordnete Template überschreibt ihn oder verwendet den Standardwert des übergeordneten Templates.

partials/delete_customer_dialog.html - der Löschdialog (Ebene B):

{{extends "partials/_dialog_base.html"}}

{{block "dialog_title"}}Delete customer{{endblock}}
{{block "form_action"}}/admin/customers/{{:customer.id}}/delete{{endblock}}
{{block "submit_style"}}danger{{endblock}}
{{block "submit_label"}}Delete permanently{{endblock}}

{{block "form_fields"}}
<p>You are about to delete <strong>{{:customer.company_name}}</strong>.</p>
<p>This action cannot be undone.</p>
<input type="hidden" name="customer_id" value="{{:customer.id}}">
{{endblock}}

partials/edit_customer_dialog.html - der Bearbeitungsdialog (Ebene B):

{{extends "partials/_dialog_base.html"}}

{{block "dialog_title"}}Edit customer{{endblock}}
{{block "form_action"}}/admin/customers/{{:customer.id}}/edit{{endblock}}
{{block "submit_label"}}Save changes{{endblock}}

{{block "form_fields"}}
<div class="form-group">
  <label>Company name</label>
  <input type="text" name="company_name"
         value="{{:customer.company_name}}" class="form-control">
</div>
<div class="form-group">
  <label>City</label>
  <input type="text" name="city"
         value="{{:customer.city}}" class="form-control">
</div>
{{endblock}}

Im {{block "content"}} von customers.html bindet jede Zeile ihre spezifischen Partials ein:

{{for customer in customers}}
<tr>
  <td><code>{{:customer.code}}</code></td>
  <td>{{:customer.company_name}}</td>
  <td>
    <button onclick="document.getElementById('editDialog-{{:customer.id}}').showModal()">
      Edit
    </button>
    <button onclick="document.getElementById('deleteDialog-{{:customer.id}}').showModal()">
      Delete
    </button>
  </td>
</tr>

{{set dialog_id := @("editDialog-" + customer.id)}}
{{include "partials/edit_customer_dialog.html"}}

{{set dialog_id := @("deleteDialog-" + customer.id)}}
{{include "partials/delete_customer_dialog.html"}}

{{endfor}}

Die zwanzig kopierten Dialoge werden zu: einer Basis-Hülle, einem untergeordneten Partial pro Typ und einer include-Zeile für jedes Vorkommen. Wenn sich die Struktur der Hülle ändert - ein Symbol im Header, ein ARIA-Attribut, ein zusätzliches verstecktes Feld - wird nur _dialog_base.html angefasst.

Der Datenbereich propagiert sich normal durch include: Die untergeordneten Partials sehen customer.id, customer.company_name und alles, was am Einbindungspunkt verfügbar ist. Das {{set dialog_id}} vor dem include übergibt den Wert, den die Basis-Hülle benötigt.

Das vollständige mentale Modell

Dem Kunden habe ich dieses Schema hinterlassen:

_base.html                              <- absolute Basis (DOCTYPE, globales CSS, JS)
  └── _admin.html                       <- Abschnitts-Layout (Sidebar, Admin-Navbar)
        └── customers.html              <- konkrete Seite (nur Inhalt)
              └── partials/
                  ├── _dialog_base.html            <- Dialog-Hülle (virtuelle Blöcke)
                  ├── delete_customer_dialog.html  <- override: Titel, Action, Felder
                  └── edit_customer_dialog.html    <- override: Titel, Action, Felder

Fünf Ebenen. Jede weiß nur das, was sie betrifft. Wenn sich etwas ändert, wird nur eine Datei angefasst.

Template-Vererbung ist kein fortgeschrittenes Feature, das nur in großen Projekten verwendet wird. Es ist die korrekte Art, jede nicht-triviale Webanwendung mit TemplatePro zu strukturieren. Die zwanzig kopierten Dialoge waren das sichtbare Symptom eines tieferen Problems: das Fehlen dieser Struktur.


TemplatePro ist Open Source (Apache 2.0). Die offizielle Dokumentation, einschließlich des vollständigen Abschnitts zur Template-Vererbung, ist hier verfügbar. Das Repository ist auf GitHub.


Möchtest du tiefer einsteigen?

Ich habe einen vollständigen Leitfaden zu TemplatePro geschrieben: TemplatePro 1.1 - The Definitive Guide - 209 Seiten, 12 vollständige Projekte. Er deckt alles ab, was wir hier gesehen haben, und noch viel mehr: Makros, benutzerdefinierte Filter, Internationalisierung, Integration mit WebBroker, Caching kompilierter Templates, Deployment in der Produktion.

Das Buch ist in vier Sprachen auf Patreon erhältlich:

🇩🇪 Deutsch  •  🇬🇧 English  •  🇮🇹 Italiano  •  🇪🇸 Español


Viel Spass beim Coden!
Daniele Teti

Comments

comments powered by Disqus