Suchen und Ersetzen: die Regex-Fallen, eine nach der anderen vorgeführt
Veröffentlicht am 30.5.2025 · 13 Min. Lesezeit · Text- & Sprach-Tools
Daniel Okonkwo — Front-end-Entwickler und Tech-Redakteur bei Allin
Web-Performance · Dateiformate
Anhand von 5 Quellen geprüft
Die Arten, wie ein Suchen-und-Ersetzen schiefgeht, sind konkret und reproduzierbar, man kann sie also vorführen statt vor ihnen zu warnen. Quantoren sind standardmäßig gierig: An der Zeichenkette <b>bold</b> and <i>italic</i> liefert das Ersetzen von /<.+>/g durch nichts eine leere Zeichenkette, denn .+ läuft bis zum letzten > der Zeile; das genügsame /<.+?>/g und das negierte /<[^>]+>/g liefern beide bold and italic. Der Punkt erfasst niemals einen Zeilenumbruch, solange das s-Flag fehlt: /Preis.*Einheiten/ ist über zwei Zeilen falsch, /Preis.*Einheiten/s ist wahr. Die Ersetzungszeichenkette hat eine eigene Syntax, die mit dem Muster nichts zu tun hat: $& fügt den ganzen Treffer ein, $1 eine Gruppe, und $$ ist der einzige Weg, ein wörtliches Dollarzeichen auszugeben - '$$$1' auf 'Summe: 42 EUR' ergibt 'Summe: $42', während '$$&' das wörtliche '$&' ergibt. Ein Regex-Objekt mit g-Flag behält zwischen Aufrufen ein lastIndex, das Wiederverwenden über Zeilen hinweg überspringt also Treffer: dieselbe /\d+/g, gegen drei Bestellzeilen getestet, ergab wahr, falsch, wahr. Und /i ist reine Groß-Klein-Faltung, kein Sprachwissen: /i/i erfasst İ nicht, /I/i erfasst ı nicht. Erst zählen, dann ersetzen.
Gierig gegen genügsam an derselben Zeichenkette, der Punkt, der Zeilenumbrüche überspringt, $& und $$ in der Ersetzung, eine wiederverwendete /g-Regex, die stillschweigend eine Zeile auslässt, und warum /i vom türkischen i nichts weiß. Jeder Fehlschlag in Node ausgeführt, mit einer Zähl-dann-Ersetze-Routine, die sie einfängt.
Gierig und genügsam an derselben Eingabe
Nimm die Zeichenkette <b>bold</b> and <i>italic</i> und versuch, die Tags mit /<.+>/g zu löschen. Das Ergebnis ist eine leere Zeichenkette. Der Quantor + ist gierig: Er nimmt so viel wie möglich und gibt Zeichen nur zurück, wenn der Rest des Musters scheitert - .+ verschluckt also alles bis zum letzten > und ein einziger Treffer deckt die ganze Zeile ab. Der erste Treffer von /<.+>/ ist buchstäblich die gesamte Eingabe.
Zwei Korrekturen helfen, und sie sind nicht gleichwertig. Ein Fragezeichen macht den Quantor genügsam: /<.+?>/g nimmt so wenig wie möglich, sein erster Treffer ist <b>, und die Ersetzung liefert bold and italic. Den Punkt durch eine negierte Klasse zu ersetzen erledigt dieselbe Aufgabe anders: /<[^>]+>/g kann ein > überhaupt nicht überqueren und liefert deshalb ebenfalls bold and italic. Bevorzuge die negierte Klasse, wo es eine gibt - sie benennt die Grenze, statt sich auf das Zurücksetzen der Engine zu verlassen, und sie wird nicht stillschweigend wieder gierig, wenn du dem Muster später einen optionalen Teil hinzufügst.
Der Punkt hält am Zeilenende
In JavaScript erfasst . jedes Zeichen außer einem Zeilenabschluss. Nimm einen zweizeiligen Datensatz: Preis: 12,50 €, dann ein Zeilenumbruch, dann Bestand: 3 Einheiten. Der Test /Preis.*Einheiten/ liefert falsch. Dasselbe Muster mit dem s-Flag, /Preis.*Einheiten/s, liefert wahr, denn s - dotAll - hebt die Ausnahme für den Zeilenabschluss auf. Der ältere Behelf, eine Zeichenklasse, die alles abdeckt, /Preis[\s\S]*Einheiten/, liefert ebenfalls wahr und funktioniert in Laufzeitumgebungen, die älter sind als das Flag.
Genau hier scheitert ein Ersetzen leise statt laut. Ein Muster, das einen Absatz überspannen sollte, findet schlicht nichts, die Ersetzungszahl kommt als null zurück, und eine Pipeline, die diese Zahl nicht prüft, meldet Erfolg. Es ist auch der Grund, warum /.+/g ein brauchbarer Zeilenteiler ist: An demselben zweizeiligen Datensatz liefert es zwei Treffer, einen pro Zeile - eine Funktion, wenn man sie will, und eine Überraschung, wenn nicht.
Die Ersetzungszeichenkette hat eine eigene Syntax
Das zweite Argument von replace ist kein Klartext. Es ist eine kleine Sprache, und ihr einziges Metazeichen ist $. An 'Summe: 42 EUR' mit /(\d+) (USD|EUR)/ ergibt die Ersetzung [$&] 'Summe: [42 EUR]', $1 ergibt 'Summe: 42', und $<amount> - bei im Muster benannter Gruppe - ergibt 'Summe: [42]'. Die vollständige Liste ist kurz: $&, $1 bis $99, $<name>, $` für alles vor dem Treffer, $' für alles danach und $$ für ein wörtliches Dollarzeichen.
Daraus folgen zwei Fallen. Die erste: Das $ einer Ersetzung ist nicht das $ eines Musters. In einem Muster ist $ ein Anker für Eingabeende; in einer Ersetzung bedeutet es das nie. Ein $ dort zu schreiben, wo man ein Währungszeichen will, ergibt nur mit Glück ein wörtliches $ - replace(/EUR/, '$') liefert tatsächlich 'Summe: 42 $', weil ein einzelnes $ ohne besonderes Folgezeichen durchgereicht wird, aber sobald eine Ziffer oder ein Und-Zeichen folgt, ändert sich die Bedeutung. Schreibe $$ und denk nicht weiter darüber nach.
Die zweite Falle ist eine Ziffern-Mehrdeutigkeit. $10 bedeutet Gruppe 10, wenn das Muster zehn Erfassungsgruppen hat, und Gruppe 1 gefolgt vom Zeichen 0, wenn nicht: Bei einem Muster mit zehn Gruppen lieferte die Ersetzung [$10] den Wert [j], bei einem Muster mit einer Gruppe [a0]. Die Bedeutung deiner Ersetzungszeichenkette hängt also davon ab, wie viele Gruppen das Muster zufällig enthält - füg eine Gruppe hinzu, und die Ersetzung ändert ihr Verhalten, ohne bearbeitet worden zu sein. Benannte Gruppen beseitigen die Mehrdeutigkeit vollständig, und wenn die Ersetzung Daten statt Code ist, übergib eine Funktion: Dein Rückgabewert wird unverändert eingefügt, replace(/\d+/, () => '$&') an 'Summe: 42 EUR' erzeugt also das wörtliche '$&' statt der gefundenen Zahl.
Eine /g-Regex merkt sich, wo sie aufgehört hat
Ein RegExp-Objekt mit g-Flag trägt eine veränderliche lastIndex-Eigenschaft, die .test() und .exec() beide lesen und schreiben. Verwende dasselbe Objekt über mehrere Zeichenketten hinweg wieder, beginnt jede Suche dort, wo die vorige endete. Nimm const re = /\d+/g und teste es gegen drei Zeilen - order 12, order 7, order 349 - und die Ergebnisse lauten wahr, falsch, wahr. Die mittlere Zeile enthält sehr wohl eine Zahl. Sie wurde übersprungen, weil lastIndex zu Beginn der Suche 8 war, und nach dem dritten Aufruf stand lastIndex auf 9.
Derselbe Effekt zeigt sich an einer einzigen Zeichenkette. Vier aufeinanderfolgende Aufrufe re.test('banana') mit /a/g liefern wahr, wahr, wahr, falsch: drei a, dann eine erfolglose Suche, die lastIndex auf 0 zurücksetzt, sodass ein fünfter Aufruf wieder wahr liefern würde. Nichts daran ist ein Fehler - es ist genau das, was exec() in einer while-Schleife brauchbar macht -, aber es macht aus einer Regex-Konstante auf Modulebene eine getarnte gemeinsam genutzte veränderliche Variable.
Drei Abhilfen, nach Vorzug geordnet. Setz kein g auf eine Regex, die du mit .test() verwendest: Das Flag bringt dort nichts und verursacht alles Obige. Verwende matchAll oder match mit g, wenn du jedes Vorkommen willst - beide verwalten den Index für dich. Oder setz, wenn du ein Objekt wiederverwenden musst, vor jeder Suche re.lastIndex = 0 und wisse, dass du die fragile Variante gewählt hast.
Groß-Klein-Unabhängigkeit ist kein Sprachwissen
Das i-Flag wendet die Unicode-Groß-Klein-Faltung an, und das ist eine feste Tabelle, keine Locale-Regel. Das klarste Gegenbeispiel ist das Türkische, dessen Alphabet zwei i kennt: das gepunktete i/İ und das punktlose ı/I. Führe die Tests aus: /i/i erfasst İ nicht, /I/i erfasst ı nicht - beide liefern falsch. Zugleich liefert 'I'.toLowerCase() den Wert 'i', 'I'.toLocaleLowerCase('tr') dagegen 'ı', und 'i'.toLocaleUpperCase('tr') liefert 'İ'. Den Groß-Klein-Funktionen kann man eine Locale mitteilen. Dem Regex-Flag nicht.
Drei weitere Ergebnisse aus demselben Lauf, deren jedes schon jemanden einen Vormittag gekostet hat. /ss/i erfasst ß nicht und /ß/i erfasst SS nicht, weil die Groß-Klein-Faltung eine Eins-zu-eins-Zuordnung von Zeichen ist und das scharfe s zu zwei Buchstaben expandiert. /k/i erfasst das Kelvin-Zeichen K nicht, /k/iu - mit dem Unicode-Flag - hingegen schon, weil u die einfache Faltung einschaltet: Ein scheinbar rein syntaktisches Flag verändert also, welche Zeichen als gleich gelten. Und /é/i erfasst kein zerlegtes É, ein e gefolgt von einem kombinierenden Akut: Faltung ist nicht Normalisierung, beides sind unabhängige Entscheidungen, wie der Artikel dieser Reihe über Akzente ausführlicher zeigt.
Verankerung, und warum \b bei akzentuierten Wörtern lügt
\b ist kein Zeichen. Es ist eine Nullbreiten-Behauptung, die dort zutrifft, wo genau auf einer Seite ein Wortzeichen steht (in JavaScript: [A-Za-z0-9_]). Diese Definition ist reines ASCII und liefert für jede Sprache mit diakritischen Zeichen genau das umgekehrte Ergebnis. /\bcafé\b/ erfasst 'un café noir' nicht, denn nach dem é - das kein Wortzeichen ist - folgt ein Leerzeichen, das ebenfalls keines ist, also gibt es keine Grenze. Dasselbe Muster erfasst 'un cafés' sehr wohl, weil auf é ein s folgt und das eine Grenze ist. Die Behauptung greift dort, wo das Wort weitergeht, und versagt dort, wo es endet.
Der Ersatz für \b ist ein Paar Unicode-Umgebungsprüfungen: /(?<![\p{L}\p{N}])café(?![\p{L}\p{N}])/u erfasst 'un café noir' und verweigert 'un cafés' - genau das, was ein Mensch unter einem ganzen Wort versteht. Das u-Flag ist nötig, damit \p{…} überhaupt erkannt wird. Ist das ersetzte Wort reines ASCII, bleibt \b in Ordnung - /\bBahn\b/ verhält sich korrekt -, aber sobald das Muster einen Buchstaben außerhalb von A–Z enthält, prüf beide Enden von Hand.
Zählen, dann ersetzen: ein durchgerechnetes Beispiel
Nimm die Zeile: Bahn, Bahnhof, Autobahn, die Bahn fährt. Die Aufgabe ist, Bahn durch Zug zu ersetzen. Zähl zuerst: die Zeile.match(/Bahn/g).length ergibt 3. Erwartet hattest du 2. Diese eine Zahl, gelesen bevor irgendetwas geschrieben wurde, ist der gesamte Schutz - und hättest du ersetzt statt gezählt, wäre die Ausgabe 'Zug, Zughof, Autobahn, die Zug fährt' gewesen. Beachte dabei, dass Autobahn unversehrt blieb: Das Muster ist groß-klein-empfindlich, das kleine b in Autobahn passte nicht.
Verankern und erneut zählen: /\bBahn\b/g findet 2, die erwartete Zahl, und erst dann ist Ersetzen sicher. Das Ergebnis lautet 'Zug, Bahnhof, Autobahn, die Zug fährt'. Danach noch einmal zählen: /\bBahn\b/g muss jetzt 0 finden und /\bZug\b/g muss 2 finden. Drei Zählungen und ein Ersetzen, insgesamt vier Zeilen, und jede davon kannst du jemandem zeigen, der fragt, was die Änderung bewirkt hat.
Ein Detail aus derselben Übung ist es wert, behalten zu werden. Ohne Anker gefundene 3 statt 4 Treffer sind kein Rundungsfehler: Sie sagen, dass Bahnhof getroffen und das kleingeschriebene bahn in Autobahn verfehlt wurde. Eine Zahl unter der Erwartung ist ebenso aufschlussreich wie eine darüber, und sie weist auf einen anderen Fehler hin - hier auf die fehlende Groß-Klein-Unabhängigkeit statt auf die fehlende Wortgrenze.
| Zeichen | Was es einfügt | Geschriebene Ersetzung | Ergebnis |
|---|---|---|---|
| $& | Den ganzen Treffer | [$&] | Summe: [42 EUR] |
| $1 | Erfassungsgruppe 1 | $1 | Summe: 42 |
| $$ | Ein wörtliches Dollarzeichen | $$$1 | Summe: $42 |
| $10 | Gruppe 10, falls vorhanden, sonst Gruppe 1 und dann das Zeichen 0 | $10 | Summe: 420 |
| $` und $' | Alles vor, alles nach dem Treffer | <$`> | Summe: <Summe: > |
| $<name> | Eine benannte Gruppe; bleibt wörtlich, wenn das Muster keine hat | [$<amount>] | Summe: [42] |
Häufige Fragen
- Warum hat mein Ersetzen eine ganze Zeile gelöscht?
- Fast immer ein gieriger Quantor zwischen zwei Begrenzern, die mehrfach vorkommen. /<.+>/ trifft in einer Zeile mit zwei Tags vom ersten < bis zum letzten >, der einzige Treffer ist also die ganze Zeile. Setz ein Fragezeichen für einen genügsamen Quantor, /<.+?>/, oder besser: verbiete den schließenden Begrenzer innerhalb des Treffers mit einer negierten Klasse, /<[^>]+>/.
- Warum liefert dieselbe Regex beim zweiten Aufruf eine andere Antwort?
- Weil sie das g-Flag und ein lastIndex hat, das zwischen den Aufrufen überlebt. /a/g, viermal gegen 'banana' getestet, liefert wahr, wahr, wahr, falsch. Lass das g-Flag weg, wenn du nur eine Ja-Nein-Antwort willst, verwende matchAll, wenn du jedes Vorkommen willst, oder setz vor jeder Suche re.lastIndex = 0, falls das Objekt wirklich geteilt werden muss.
- Wie setze ich ein wörtliches Dollarzeichen in die Ersetzung?
- Schreibe $$. Ein einzelnes $ funktioniert nur, wenn nichts Besonderes folgt, und ist damit ein Fehler, der auf die nächste Bearbeitung wartet: '$$$1' an 'Summe: 42 EUR' ergibt 'Summe: $42', während das naheliegende '$$&' das wörtliche '$&' statt des Treffers ergibt. Stammt der Ersetzungstext aus Daten - ein Formularwert, eine Übersetzung, alles, was du nicht selbst getippt haben -, übergibst dich statt einer Zeichenkette eine Funktion, deren Rückgabewert ohne jede $-Verarbeitung eingefügt wird.
- Versteht das i-Flag Akzente und andere Alphabete?
- Es versteht Groß-Klein-Faltung und sonst nichts. Es setzt a und A über weite Teile von Unicode gleich, aber es normalisiert nicht - /é/i scheitert an einem zerlegten É -, es expandiert nicht - /ss/i scheitert an ß -, und es kennt keine Locale - /i/i scheitert am türkischen İ. Brauchst du eines davon, normalisiere den Text zuerst und vergleiche mit einem Kollator der Sensitivität 'base', statt vom Flag ein Sprachwissen zu erwarten, das es nie hatte.
- Wie treffe ich über zwei Zeilen hinweg?
- Setz das s-Flag, das den Punkt auch Zeilenabschlüsse erfassen lässt: /Preis.*Einheiten/ ist über zwei Zeilen falsch, /Preis.*Einheiten/s ist wahr. Verwechsle es nicht mit m, bei dem es um Anker geht und nicht um den Punkt: m lässt ^ und $ am Anfang und Ende jeder Zeile statt der gesamten Eingabe treffen und lässt den Punkt unverändert. Oft will man beide, und sie sind unabhängig voneinander.
- Warum versagt \b bei einem Wort, das auf einen akzentuierten Buchstaben endet?
- Weil \b gegen die ASCII-Wortklasse [A-Za-z0-9_] definiert ist und ein akzentuierter Buchstabe nicht dazugehört. Eine Grenze verlangt genau auf einer Seite ein Wortzeichen; zwischen é und einem folgenden Leerzeichen gibt es keines, deshalb erfasst /\bcafé\b/ 'un café noir' nicht - wohl aber 'un cafés', wo auf é ein ASCII-s folgt. Ersetze die Behauptungen durch Unicode-Umgebungsprüfungen: /(?<![\p{L}\p{N}])café(?![\p{L}\p{N}])/u, und denk daran, dass \p{…} das u-Flag braucht.
Artikel, die dich interessieren könnten
Alle Ratgeber →Ähnliche Tools
Quellen
- Ecma International — ECMAScript Language Specification — RegExp objects, the lastIndex property, and String.prototype.replace (the GetSubstitution algorithm)
- MDN Web Docs — String.prototype.replace() — specifying a string or a function as the replacement
- MDN Web Docs — Regular expressions — quantifiers, assertions and the d, g, i, m, s, u, v, y flags
- Unicode Consortium — Unicode Technical Standard #18: Unicode Regular Expressions
- Unicode Consortium — Unicode Standard Annex #44: Unicode Character Database — case folding and the SpecialCasing data for Turkish i
Hast du einen Fehler in diesem Artikel entdeckt?