12k
All articles

Tagged Template Literals: Mini-DSLs in JavaScript entwickeln

Tagged Template Literals in JavaScript: cooked und raw Strings, WeakMap-Cache, sicheres HTML, SQL und Mini-DSL-Beispiele.

OpenReplay Team
OpenReplay Team
Tagged Template Literals: Mini-DSLs in JavaScript entwickeln

Ein Tagged Template Literal ist ein Funktionsaufruf, dessen Argumente aus einem Template Literal stammen: Die Laufzeitumgebung teilt das Literal an jeder ${...}-Grenze auf, übergibt die statischen Zeichenkettensegmente und die ausgewerteten Interpolationen an eine benannte Funktion und lässt diese Funktion zurückgeben, was immer sie möchte. Dieser letzte Punkt ist entscheidend. Wenn Sie styled.div`...` oder gql`...` schreiben, rufen Sie eine Funktion auf, die die Bestandteile des Templates empfängt und etwas zurückgibt, das keine Zeichenkette ist – eine React-Komponente, ein geparster GraphQL-Dokumentknoten. Dieser Artikel erklärt den Aufrufmechanismus genau und verwendet ihn anschließend, um vier funktionierende Mini-DSLs zu entwickeln, darunter einen parametrisierten SQL-Tag, der wertbasierte Injection konstruktionsbedingt verhindert.

Sie haben diese Syntax bereits verwendet. Im Folgenden wird erläutert, wie sie funktioniert und wie Sie Ihre eigene entwickeln können.

Wichtige Erkenntnisse

  • Ein Tagged Template tag`...` ist syntaktischer Zucker für tag(strings, ...values), wobei strings ein Array der statischen Segmente ist und ...values die von links nach rechts ausgewerteten interpolierten Ausdrücke enthält.
  • Das strings-Array hat stets genau ein Element mehr als valuesstrings.length === values.length + 1 — da ein Template Literal immer mit einem Zeichenkettensegment beginnt und endet, selbst wenn dieses leer ist.
  • Tag-Funktionen müssen keine Zeichenketten zurückgeben: styled-components gibt eine React-Komponente zurück, graphql-tag einen DocumentNode-AST und lit-html ein TemplateResult.
  • Da dasselbe eingefrorene Template-Objekt bei jeder Auswertung eines bestimmten Tagged Templates wiederverwendet wird, kann eine WeakMap mit dem strings-Array als Schlüssel geparste Arbeit für dieses Literal zwischenspeichern.
  • Ein sicherer SQL-Tag verhindert wertbasierte SQL-Injection, indem er Interpolationen in Abfrageparameter umwandelt, anstatt sie in den SQL-Text einzufügen – vorausgesetzt, er parametrisiert Werte immer und fügt niemals rohes SQL oder Bezeichner direkt ein.

Die Aufruf-Signatur: tag`...` wird zu tag(strings, ...values) aufgelöst

Ein Tagged Template ist syntaktischer Zucker für einen gewöhnlichen Funktionsaufruf. Der Ausdruck tag`Hi, ${name}!` entspricht dem Aufruf von tag mit den statischen Segmenten als erstem Argument und jedem interpolierten Wert als nachfolgendem Argument. Die beiden folgenden Formen sind zur Veranschaulichung der Aufruf-Signatur weitgehend äquivalent:

const name = "Zach";

// Tagged Template Literal
tag`Hi, ${name}!`;

// Das aufgelöste Äquivalent
tag(["Hi, ", "!"], name);

Der erste Parameter ist ein Array der statischen Zeichenkettensegmente, aufgeteilt an jeder ${...}-Stelle. Die übrigen Argumente sind die interpolierten Werte, die Sie mit einem Rest-Parameter sammeln. (Die MDN-Dokumentation zu Tagged Templates beschreibt diese Aufrufstruktur.)

function tag(strings, ...values) {
  console.log(strings); // ["Hi, ", "!"]
  console.log(values);  // ["Zach"]
}

const name = "Zach";
tag`Hi, ${name}!`;

Interpolationen werden zum Zeitpunkt des Aufrufs von links nach rechts ausgewertet und als ihre tatsächlichen Werte übergeben – nicht als Zeichenketten. Ein Tag empfängt ein Funktionsobjekt, ein Array oder ein Objekt genau so, wie es geschrieben wurde:

function tag(strings, ...values) {
  return values;
}

tag`${() => 42} and ${[1, 2, 3]}`;
// [ [Function], [1, 2, 3] ] — tatsächliche Werte, keine Zeichenketten

Dies ist der erste praktische Unterschied zu einem einfachen Template Literal, das Interpolationen immer in Zeichenketten umwandelt und stets eine Zeichenkette zurückgibt. Ein Tag fängt die Bestandteile ab, bevor eine Umwandlung stattfindet.

Die Invariante strings.length === values.length + 1

Das strings-Array hat stets genau ein Element mehr als das values-Array — strings.length === values.length + 1 — da ein Template Literal immer mit einem Zeichenkettensegment beginnt und endet, selbst wenn dieses Segment eine leere Zeichenkette ist. Ein Literal mit drei Interpolationen hat vier Zeichenkettensegmente; ein Literal, das mit ${x} beginnt, hat schlicht eine leere Zeichenkette als erstes Segment:

function tag(strings, ...values) {
  console.log(strings.length, values.length);
}

tag`${1}${2}`;
// strings: ["", "", ""]  →  3
// values:  [1, 2]        →  2

Diese Invariante ist der Grund, warum die Reduktion über strings die sauberste Methode ist, Segmente und Werte zu verschränken: Man durchläuft die Zeichenketten und hängt nach jeder, außer der letzten, values[i] an.

Cooked vs. Raw: Wenn der Unterschied entscheidend ist

Das strings-Array enthält die cooked-Interpretation, bei der \n ein Zeilenumbruch und \t ein Tabulator ist. Die Eigenschaft strings.raw enthält die raw-Quellzeichen, bei denen \n die zweistellige Zeichenfolge Backslash-n ist. Diese Unterscheidung ist wichtig, wenn Ihre DSL Text verarbeitet, der Backslashes strukturell verwendet – etwa Regex-Muster, LaTeX-Markup oder Windows-Dateipfade.

function showBoth(strings) {
  console.log(strings[0]);     // cooked: ein echter Zeilenumbruch
  console.log(strings.raw[0]); // raw: die Zeichen \ und n
}

showBoth`line\nbreak`;
// cooked → "line", dann "break" in der nächsten Zeile
// raw    → "line\nbreak"

JavaScript liefert genau einen eingebauten Tag für diesen Zweck: String.raw. Er gibt die Raw-Interpretation des Literals zurück, sodass Escape-Sequenzen als wörtliche Zeichen erhalten bleiben. (Die MDN-Referenz zu String.raw dokumentiert ihn als einzigen eingebauten Template-Tag; er verhält sich nur dann wie ein Identity-Tag, wenn das Literal keine Escape-Sequenzen enthält.)

const winPath = String.raw`C:\Users\dev\project`;
console.log(winPath); // "C:\Users\dev\project" — Backslashes bleiben erhalten

const pattern = new RegExp(String.raw`\d{3}-\d{4}`);
pattern.test("555-1234"); // true — kein doppeltes Escaping erforderlich

Ohne String.raw müsste die Regex-Quelle \\d{3}-\\d{4} lauten, um das Cooking zu überstehen. Jeder selbst geschriebene Tag kann dasselbe Verhalten bieten, indem er aus strings.raw statt aus strings liest.

Tag-Funktionen können beliebige Werte zurückgeben

Eine Tag-Funktion ist schlicht eine Funktion, und ihr Rückgabewert ist das, was der Autor bestimmt. Dies ist die zentrale Erkenntnis, die Tagged Templates von einer Zeichenketten-Formatierungskuriosität zu einem Werkzeug für domänenspezifische Sprachen macht. Die statischen Segmente und Werte sind Rohmaterial; der Tag entscheidet, was daraus konstruiert wird. styled-components gibt eine React-Komponente zurück, graphql-tag einen DocumentNode-AST und lit-html ein TemplateResult.

Die vier folgenden DSLs steigern sich von der Zeichenkettenausgabe bis hin zu parametrisierten Abfrageobjekten und demonstrieren Rückgabetypen, die kein einfaches Template Literal erzeugen kann.

Ein HTML-Escape-Tag (XSS verhindern)

Ein HTML-Escape-Tag umhüllt jeden interpolierten Wert mit HTML-Entity-Kodierung, bevor er das DOM erreicht, sodass Benutzereingaben ihren Textkontext nicht verlassen können. Der verhinderte Fehler: Das direkte Interpolieren nicht vertrauenswürdiger Eingaben in Markup ist ein gespeicherter oder reflektierter XSS-Angriffsvektor – ein Payload im Wert wird ausgeführt, wenn die Zeichenkette in das DOM eingefügt wird:

const userInput = '<img src="x" onerror="alert(1)">';  
const markup = `<div>${userInput}</div>`;  
// element.innerHTML = markup → der onerror-Handler wird ausgeführt

Ein safeHtml-Tag fängt jede Interpolation ab und maskiert sie, bevor sie in die Ausgabe gelangt, während die statischen Segmente – die der Entwickler, nicht der Benutzer, verfasst hat – unverändert bleiben:

function escapeHtml(value) {
  return String(value)
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");
}

function safeHtml(strings, ...values) {
  return strings.reduce((acc, segment, i) => {
    const value = i < values.length ? escapeHtml(values[i]) : "";
    return acc + segment + value;
  }, "");
}

const userInput = '<img src="x" onerror="alert(1)">';  
const markup = safeHtml`<div>${userInput}</div>`;  
// "<div>&lt;img src=&quot;x&quot; onerror=&quot;alert(1)&quot;&gt;</div>" — harmlos

Die Asymmetrie ist der entscheidende Punkt: Statische Segmente sind vertrauenswürdig (Sie haben sie geschrieben), Interpolationen hingegen nicht (sie können Benutzereingaben enthalten), und der Tag ist die einzige Stelle, an der diese Unterscheidung durchgesetzt wird.

Ein Safe-SQL-Tag (parametrisierte Abfragen konstruktionsbedingt)

Ein Safe-SQL-Tag verhindert wertbasierte SQL-Injection, indem er Interpolationen in Abfrageparameter umwandelt, anstatt sie in den SQL-Text einzufügen. Die Zeichenkettenverkettungs-Variante einer Abfrage ist der klassische SQL-Injection-Fehler – das Einfügen eines Werts in den Abfragetext ermöglicht es diesem Wert, die Struktur der Abfrage zu verändern:

// FEHLERHAFT — nicht verwenden
const id = "1 OR 1=1";
const query = `SELECT * FROM users WHERE id = ${id}`;
// "SELECT * FROM users WHERE id = 1 OR 1=1" → gibt jede Zeile zurück

Ein sicherer SQL-Tag verhindert wertbasierte SQL-Injection, indem er Interpolationen in Abfrageparameter umwandelt, anstatt sie in den SQL-Text einzufügen – vorausgesetzt, der Tag parametrisiert interpolierte Werte immer und fügt niemals rohes SQL oder Bezeichner in die Abfrage ein. Der Tag gibt ein { text, values }-Objekt zurück: Das Feld text enthält Platzhalter ($1, $2, …), und values enthält die tatsächlichen Daten, getrennt von der Abfragestruktur:

function sql(strings, ...values) {
  const text = strings.reduce((acc, segment, i) => {
    return acc + segment + (i < values.length ? `$${i + 1}` : "");
  }, "");
  return { text, values };
}

const id = "1 OR 1=1";
const query = sql`SELECT * FROM users WHERE id = ${id}`;
// {
//   text: "SELECT * FROM users WHERE id = $1",
//   values: ["1 OR 1=1"]
// }

Diese { text, values }-Struktur entspricht genau dem, was node-postgres als parametrisierte Abfrage akzeptiert: client.query(query) übergibt den Abfragetext und die Parameterwerte getrennt an PostgreSQL, und die Datenbank interpretiert den Parameterwert niemals als SQL-Syntax. Die schädliche Zeichenkette "1 OR 1=1" wird als einzelner Parameterwert behandelt, nicht als SQL-Syntax, und kann daher nicht Teil der Abfragestruktur werden.

Der Grund, warum dies gegenüber der Zeichenkettenverkettungs-Variante robuster ist, liegt in der Struktur: Mit dem Tag kann ein interpolierter Wert immer nur ein Parameter werden. Es gibt in der getaggten Form keine Syntax, die einen Wert direkt in den Abfragetext einfügt. Die Sicherheit gilt für Werte; sie erstreckt sich nicht auf dynamische Tabellen- oder Spaltennamen, die Bezeichner sind und separat validiert werden müssen, anstatt interpoliert zu werden.

Ein i18n-Tag mit WeakMap-Identitäts-Caching

Da dasselbe eingefrorene Template-Objekt bei jeder Auswertung eines bestimmten Tagged Templates wiederverwendet wird – ein in der ECMAScript-Spezifikation definiertes Verhalten – kann eine WeakMap mit dem strings-Array als Schlüssel geparste Arbeit für dieses Literal zwischenspeichern: einmal parsen und das gecachte Ergebnis bei nachfolgenden Auswertungen wiederverwenden. Dies ist der Identitätstrick, der es Template-basierten Bibliotheken ermöglicht, dasselbe Template nicht bei jedem Rendering neu zu parsen.

Die Wiederverwendung ist beobachtbar. Dasselbe strings-Array ist referenziell identisch über Aufrufe desselben Tagged Templates hinweg:

let first, second;
function capture(strings) {
  return strings;
}

function render() {
  return capture`hello ${1} world`;
}

first = render();
second = render();
console.log(first === second); // true — dasselbe eingefrorene Array-Objekt

Dies lässt sich in jeder Browser-Konsole oder in der Node.js-REPL nachvollziehen; es handelt sich um die spezifikationsdefinierte Wiederverwendung des Template-Objekts, nicht um eine Implementierungsbesonderheit. (Die Template-Auswertungssemantik der ECMAScript-Spezifikation legt fest, dass für ein gegebenes Template Literal dasselbe Template-Objekt zurückgegeben wird.)

Eine WeakMap mit strings als Schlüssel macht daraus einen Cache. Hier berechnet ein i18n-Tag die Formatzeichenkette einmal pro Literal vor und verwendet sie wieder:

const cache = new WeakMap();

function t(strings, ...values) {
  let key = cache.get(strings);
  if (key === undefined) {
    // aufwendige Arbeit wird einmal pro eindeutigem Literal durchgeführt
    key = strings.join("{}");
    cache.set(strings, key);
  }
  return lookupTranslation(key, values);
}

Eine WeakMap hält ihre Schlüssel schwach, sodass Cache-Einträge für die Garbage Collection freigegeben werden, sobald das Template-Objekt nicht mehr referenziert wird – kein manuelles Löschen, keine Zeichenkettenschlüssel-Kollisionen.

Ein CSS-in-JS-Style-Tag

Ein Style-Tag kann ein einfaches Objekt zurückgeben, das der Verbraucher andernorts zusammensetzt – ähnlich wie styled-components und Emotion ein CSS-Literal in etwas umwandeln, das ein Komponenten-System verarbeiten kann. Anstatt eine Zeichenkette zurückzugeben, parst der Tag Deklarationen in ein Schlüssel-Wert-Objekt:

function css(strings, ...values) {
  const cssText = strings.reduce(
    (acc, segment, i) => acc + segment + (values[i] ?? ""),
    ""
  );
  return Object.fromEntries(
    cssText
      .split(";")
      .map((d) => d.trim())
      .filter(Boolean)
      .map((d) => {
        const [prop, val] = d.split(":").map((s) => s.trim());
        return [prop, val];
      })
  );
}

const styles = css`
  display: flex;
  align-items: center;
`;
// { display: "flex", "align-items": "center" }

Dies entspricht der Struktur des realen Pendants. In styled-components gibt styled.div`...` eine React-Komponente zurück; die styled-components-Dokumentation beschreibt die vollständige API. Der Mechanismus ist identisch mit dem obigen Beispiel – eine Tag-Funktion, die ein CSS-Literal verarbeitet und einen Nicht-Zeichenkettenwert zurückgibt, den das Framework verarbeiten kann.

Reale Bibliotheken, die auf Tagged Templates aufbauen

Die folgenden Bibliotheken verwenden Tagged Templates als ihre öffentliche API. Jede taggt eine andere Sprache und gibt einen anderen Typ zurück, was am deutlichsten demonstriert, dass der Rückgabewert die Entscheidung des Autors ist.

BibliothekWas getaggt wirdWas zurückgegeben wirdAnwendungsfall
styled-componentsCSSEine React-KomponenteKomponentenspezifisches CSS-in-JS
EmotionCSSEin Klassenname / Styled ComponentCSS-in-JS mit Objekt- und Tagged-Template-APIs
lit-htmlHTMLEin TemplateResultEffizientes DOM-Rendering
graphql-tagGraphQLEin DocumentNode-ASTParsen von Abfragen für GraphQL-Clients
sql-template-stringsSQLEin Prepared-Statement-ObjektParametrisiertes SQL (Hinweis: wird nicht mehr aktiv gepflegt)
common-tagsTextFormatierte ZeichenkettenWiederverwendbare Template-Literal-Tag-Funktionen für ES2015+

Die TypeScript-Signatur für eine Tag-Funktion

Die TypeScript-Signatur für eine Tag-Funktion lautet function tag(strings: TemplateStringsArray, ...values: unknown[]): T, wobei TemplateStringsArray als ReadonlyArray<string> mit einem zusätzlichen readonly raw: readonly string[]-Member typisiert ist. Der Typ ist in TypeScripts Standardbibliothek integriert und erfordert keinen Import.

// Generische Form einer Tag-Funktion
type Tag<T> = (
  strings: TemplateStringsArray,
  ...values: unknown[]
) => T;

// Angewendet auf den Safe-SQL-Tag:
interface SqlQuery {
  text: string;
  values: unknown[];
}

function sql(strings: TemplateStringsArray, ...values: unknown[]): SqlQuery {
  const text = strings.reduce(
    (acc, segment, i) => acc + segment + (i < values.length ? `$${i + 1}` : ""),
    ""
  );
  return { text, values };
}

TemplateStringsArray ist in lib.es5.d.ts definiert und seit frühen TypeScript-Versionen verfügbar. (Die TypeScript-1.5-Versionshinweise verwenden TemplateStringsArray in ihrem Tagged-Template-Beispiel.) Beachten Sie, dass die readonly-Modellierung hier eine typebene Beschreibung ist: Das Einfrieren des Template-Objekts zur Laufzeit ist ein separates ECMAScript-Verhalten, das der TypeScript-Typ nicht bewirkt.

Wann Tagged Templates nicht verwendet werden sollten

Wenn Ihre DSL eine echte Grammatik erfordert – Operatorrangfolge, Lookahead oder rekursive Strukturen – sind Tagged Templates das falsche Werkzeug. Die Tag-Funktion empfängt eine flache Liste von Zeichenkettensegmenten und ausgewerteten Werten, keinen Parse-Baum. Alles, was mehr als eine Links-nach-rechts-Auswertung von Interpolationen erfordert, gehört in einen Parser-Kombinator oder eine PEG-Grammatik. Greifen Sie auf einen dedizierten Parser wie nearley oder Peggy zurück, wenn Sie auf eine echte Sprache stoßen.

Drei konkrete Signale, dass Sie Tagged Templates entwachsen sind:

  1. Sie benötigen eine Grammatik. Rangfolge, verschachtelte Geltungsbereiche oder Backtracking lassen sich nicht als flache Strings-und-Werte-Aufteilung ausdrücken. Verwenden Sie einen Parser.
  2. Die Auswertungsreihenfolge geht über Links-nach-rechts hinaus. Interpolationen werden zum Zeitpunkt des Aufrufs von links nach rechts ausgewertet, bevor der Tag ausgeführt wird. Ein Tag kann sie weder verzögern noch umordnen – er sieht nur die Ergebnisse.
  3. Sie befinden sich in einer heißen Schleife. Interpolierte Ausdrücke werden bei jedem Aufruf ausgewertet, auch wenn dasselbe Template-Objekt wiederverwendet wird. Eine aufwendige Interpolation in einer engen Render-Schleife zahlt ihren Preis bei jeder Iteration. Das strings-Array wird per Identität gecacht; die Werte hingegen nicht.

Für alles zwischen einfacher Interpolation und einer vollständigen Sprache – Maskierung, Parametrisierung, Cache-Schlüsselung, Erstellung einer Styled Component – sind Tagged Templates das richtige Werkzeug.

Beginnen Sie mit dem Safe-SQL-Tag. Er ist klein genug, um ihn in einem Durchgang zu lesen, gibt ein { text, values }-Objekt zurück, das mit node-postgres kompatibel ist, und demonstriert die Eigenschaft, die Tagged Templates es wert macht, eingesetzt zu werden: Ein Wert, der eine ${...}-Grenze überschreitet, kann in eine sichere Rolle gezwungen werden, die Zeichenkettenverkettung niemals garantieren kann.

Häufig gestellte Fragen

Warum ist strings.length in einem Tagged Template immer um eins größer als values.length?

Ein Template Literal beginnt und endet immer mit einem Zeichenkettensegment, selbst wenn dieses leer ist, sodass die statischen Segmente jede Interpolation auf beiden Seiten einrahmen. Bei n Interpolationen gibt es n plus 1 Zeichenkettensegmente, was die Invariante strings.length === values.length + 1 ergibt. Ein Literal wie tag-Backtick-Dollar-Klammer-x erzeugt eine leere Zeichenkette vor der Interpolation und eine leere Zeichenkette danach, sodass zwei Interpolationen drei Zeichenkettensegmente ergeben.

Was ist der Unterschied zwischen den cooked und raw Zeichenketten in einer Tag-Funktion?

Das cooked-Array, auf das über strings zugegriffen wird, interpretiert Escape-Sequenzen: Backslash-n wird zu einem echten Zeilenumbruch und Backslash-t zu einem Tabulator. Das raw-Array, auf das über strings.raw zugegriffen wird, bewahrt die wörtlichen Quellzeichen, sodass Backslash-n als die zwei Zeichen Backslash und n erhalten bleibt. Der Unterschied ist entscheidend, wenn eine DSL Text verarbeitet, der Backslashes strukturell verwendet, etwa Regex-Muster, LaTeX-Markup oder Windows-Dateipfade, bei denen das Cooking die Eingabe verfälschen würde.

Funktionieren Tagged Template Literals in TypeScript ohne zusätzliche Einrichtung?

Ja. Die Tag-Funktions-Signatur lautet function tag(strings: TemplateStringsArray, ...values: unknown[]): T, wobei TemplateStringsArray in TypeScripts Standardbibliotheksdatei lib.es5.d.ts definiert ist und keinen Import erfordert. Sie ist als ReadonlyArray von string mit einem zusätzlichen readonly raw-Member typisiert. Die readonly-Modellierung ist nur auf Typebene; das Einfrieren des Template-Objekts zur Laufzeit ist ein separates ECMAScript-Verhalten, das der TypeScript-Typ nicht erzwingt.

Kann eine Tagged-Template-Tag-Funktion etwas anderes als eine Zeichenkette zurückgeben?

Ja. Eine Tag-Funktion ist eine gewöhnliche Funktion und kann jeden Wert zurückgeben, den der Autor wählt. styled-components gibt eine React-Komponente zurück, graphql-tag einen DocumentNode-AST und lit-html ein TemplateResult. Ein Safe-SQL-Tag kann ein text-und-values-Objekt zurückgeben, das mit node-postgres kompatibel ist. Einfache Template Literals wandeln immer in eine Zeichenkette um, aber ein Tag fängt die Segmente und Werte vor der Umwandlung ab, sodass der Rückgabetyp vollständig von der Implementierung abhängt.

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.