Startseite / Artikel

Emojisichere Zeichenkettenkürzung in JavaScript mit Intl.Segmenter

Warum slice() und die auf Spread-Operation basierende Trunkierung Emojis sowie Text mit Akzenten zerstören, wie echte Produkte dadurch versagten, und wie man stattdessen an Graphemgrenzen Strings abschneiden kann.

Verlag: REACTAPP.TOP10 Min. Lesezeit2341 Wörter

Quellen: I Was Truncating Strings Wrong

Fast jede Codebasis mit einem Bio-Feld, einer Kommentarvorschau oder einem Benachrichtigungsüberblick verfügt über einen kleinen Hilfsfunktion, der den Text auf eine bestimmte Zeichenzahl verkürzt. In der Regel scheint er korrekt zu funktionieren, bestehen alle dafür geschriebenen Tests, doch dann zerstört er heimlich den Text jedes Benutzers, dessere Eingabe in der Nähe der Grenze ein Emoji enthält. Dieser Artikel erklärt, was JavaScript tatsächlich zählt, wenn man .length und .slice() aufruft, geht auf reale Fehler in bekannten Produkten ein, die denselben Ursprung haben, und zeigt, wie man an den Grenzen tronken sollte, die ein Leser tatsächlich sieht.

Der Hilfsfunktion, den jeder schreibt

Hier ist die Version, die in unzähligen Codebasen und Tutorials vorkommt: Wenn der String länger ist als die Grenze, wird er abgeschnitten und mit drei Punkten ergänzt.

function truncate(str, max) {
  return str.length > max ? str.slice(0, max) + '…' : str;
}

Für englische Texte funktioniert es einwandfrei, und genau deshalb ist es seit so langer Zeit im Einsatz. Das Problem tritt nur auf, wenn die Eingabe eines Benutzers beispielsweise zwei Emojis enthält und die Grenze zufällig dazwischen liegt. Anstelle einer kürzeren Zeichenkette zeigt das Profil ein Ersatzzeichen an: einen schwarzen Diamanten mit einem Fragezeichen. Auf den ersten Blick wirkt die Funktion nicht fehlerhaft, und ein aus ASCII-Zeichenketten bestehendes Testset wird diesen Fehler niemals erkennen.

Was .length tatsächlich zählt

JavaScript-Zeichenketten sind Sequenzen von UTF-16-Codeeinheiten. Sowohl .length als auch .slice() arbeiten mit diesen 16-Bit-Einheiten und nicht mit Zeichen im üblichen menschlichen Sinne.

Für lateinische Buchstaben, Ziffern und die gängigsten Satzzeichen entspricht eine Codeeinheit einem Zeichen, sodass kein Unterschied sichtbar ist. Zeichen außerhalb des Basic Multilingual Plane, zu dem die meisten Emojis gehören, benötigen zwei Codeeinheiten. Dieses Paar wird als Surrogatpaar bezeichnet: ein hohes Surrogat gefolgt von einem niedrigen Surrogat, die gemeinsam einen Codepunkt kodieren.

'🐶'.length   // 2

Ein Hund auf dem Bildschirm mit einer Länge von 2. Wenn .slice() zwischen den beiden Teilen schneidet, erhält man kein kleineres Emoji. Stattdessen entsteht ein einzelnes Surrogat, das für sich allein kein gültiges Unicode-Zeichen ist. Browser rendern es in der Regel als Ersatzzeichen, während strengere Anwendungen einen Fehler auslösen.

Das ist die erste Ebene des Problems. Es gibt eine zweite, subtilere Ebene, die weiter unten erläutert wird. Zunächst ist es wichtig zu erkennen, dass es sich hierbei nicht um ein theoretisches Problem handelt.

Das gleiche Bug in vier echten Produkten

Twitters 14-Zeichen-Flags

Jahrelang berechnete Twitter die Länge eines Tweets ungefähr anhand von Codepunkten. Ein einfaches Daumen-hoch-Emoji kostete 2 Zeichen, das Hinzufügen eines Hautton-Emojis erhöhte die Zahl auf 7, und einige Flaggen kosteten bis zu 14. Der Grund dafür ist, dass Hautton-Varianten und Flaggen aus mehreren kombinierten Codepunkten bestehen, wobei jeder einzelne mitgezählt wurde.

Im Oktober 2018 änderte Twitter die Regelung so, dass jedes Emoji unabhängig von der Anzahl seiner Codepunkte mit 2 gezählt wird. Diese Gewichtung ist weiterhin Teil der Zählregeln in seiner Open-Source-Bibliothek twitter-text. Siehe die Berichterstattung zur Änderung der Emoji-Zählung sowie die Dokumentation zu den gezählten Zeichen bei X.

Dieser Fall ist nützlich, weil er zwei leicht miteinander verwechselte Fragen voneinander trennt:

  • Wie viele sichtbare Zeichen sieht der Benutzer?
  • Wie sollte das Produkt verschiedene Textarten bewerten oder beschränken?

Twitters Antwort auf die zweite Frage ist eine Produktrichtlinie. Das ist nicht dasselbe wie das Messen dessen, was auf dem Bildschirm angezeigt wird.

Yjs und lib0: Ein geteiltes Emoji, das die Synchronisierung stillschweigend beendete

Yjs ist eine weit verbreitete Bibliothek für gemeinsames Bearbeiten, und TipTap basiert darauf. Yjs ist von einem kleinen Hilfspaket namens lib0 abhängig, dessen splice-Hilfsfunktion intern .slice() verwendet.

Wenn eine gemeinsame Bearbeitung zufällig zwischen den beiden Teilen eines Emojis stattfand, erzeugte lib0 eine Zeichenkette mit einem „verlorenen“ Surrogat. Diese Zeichenkette wurde später an encodeURIComponent übergeben, welches bei einem einzelnen Surrogat einen URIError auslöst. Da nichts die Ausnahme abfing, stoppte die Synchronisierung einfach. Es gibt eine ausführliche Beschreibung des lib0-Surrogat-Fehlers.

Was dieses Problem besonders unangenehm macht, ist, dass es unsichtbar war. Der Editor arbeitete weiterhin lokal. Die Benutzer sahen ihren eigenen Text und tippten weiter, ohne zu erkennen, dass ihre Änderungen nicht mehr bei anderen anlangten. Die korrigierende Lösung erkennt einen fehlenden Ersatzzeichen am Ende der bearbeiteten Zeichenkette und ersetzt es durch das Ersatzzeichen.

React Native TextInput und maxLength

React Natives TextInput bietet eine maxLength-Eigenschaft, und seit 2016 wurden Probleme bezüglich seines Verhaltens mit Emojis gemeldet, beispielsweise die Tickets 10964 und 28774.

Die Berichte folgen demselben Muster. Legen Sie einen maxLength fest, geben Sie bis zu dieser Grenze ein und fügen Sie anschließend ein Emoji hinzu. Anstatt das Emoji abzulehnen oder es in seiner Gänze zu akzeptieren, wird die Eingabe an der Grenze einer Code-Einheit abgeschnitten. Nutzer berichten davon, ein anderes Emoji zu sehen als das, was sie eingegeben haben, ein Feld mit einem „A“ oder – auf iOS – dass das gesamte Feld geleert wird.

Falls Ihre App ein Benutzernamen- oder Bio-Feld mit einer maximalen Länge hat und Sie noch nie ein Hautton-Emoji genau an dieser Grenze eingegeben haben, lohnt es sich, dies auszuprobieren. Ein kurzer manueller Test umfasst die wichtigsten Fälle:

Hello 👋
Hello 👋🏽
Hello 🇮🇳
Hello 👨‍👩‍👧‍👦

Diese vier Zeilen enthalten ein einfaches Emoji, eines mit Hautton-Modifikator, eine Flagge sowie eine Familiensequenz, die durch nullbreite Trenner miteinander verbunden sind. Wenn Ihr Code jede dieser Einheiten als einzelnen Zeichen behandelt, zählt er vermutlich etwas anderes.

Gemini CLI’s truncateString

truncateString-Hilfsfunktion enthielt, der bei maxLength abschnitt. Wenn eine Zeichenkette mit einem Weinen-Emoji endete und die Grenze innerhalb dieser Zeichenkette lag, gab die Hilfsfunktion ein einzelnes \uD83D zurück – den High Surrogat allein (Problem 22753).

Das ist schlimmer als nur ein kosmetisches Problem. Ein einzelner Surrogat macht die Zeichenkette zu ungültigem UTF-16, und die Übermittlung an einen JSON-Encoder oder eine API kann weitere Fehler verursachen. Die Lösung ist dieselbe, zu der jedes Team letztendlich kommt: Man darf niemals zulassen, dass der Schnittpunkt innerhalb eines Surrogat-Paares liegt.

Vier Produkte, vier verschiedene Jahre – eine Ursache. Emojis sind lediglich das auffälligste Symptom eines größeren Problems: Der Text wird nicht dort geteilt, wo JavaScripts Indizes es nahelegen.

Drei Ebenen des „Zeichens“

Es hilft, Trunkierungsstrategien als auf drei Ebenen arbeitend zu betrachten, wobei jede Ebene eine andere Einheit berücksichtigt.

Ebene 1: Code-Einheiten mit .length und .slice()

Dies ist das, worauf fast jeder zuerst zurückgreift, weil es die offensichtliche API ist. Es ist außerdem die einzige der drei Ebenen, die ein Surrogatpaar trennen kann. Wenn Ihre Grenze bei 20 liegt und die 20. Code-Einheit sich mitten in einem Emoji befindet, entsteht beschädigter Text anstelle einer kürzeren Zeichenkette. Die Fehler bei Yjs, Gemini CLI und React Native treten alle auf dieser Ebene auf.

Die mentale Lücke lässt sich einfach beschreiben: JavaScript indiziert UTF-16-Code-Einheiten; Entwickler neigen dazu zu glauben, sie indizierten Zeichen. Für den größten Teil englischer Texte stimmen diese beiden Ansätze zufällig überein, und genau diese Übereinstimmung verdeckt das Problem.

Ebene 2: Codepunkte durch Aufteilen der Zeichenkette

Eine gängige Lösung besteht darin, den String in ein Array umzuwandeln, das nach Codepunkten statt nach Codeeinheiten iteriert.

const codePoints = [...str];

Dadurch bleiben Surrogatpaare zusammen, sodass ein einzelnes Hund-Emoji unverändert bleibt. Das ist eine echte Verbesserung, und viele Entwickler gehen davon aus, das Problem sei damit gelöst.

Das ist jedoch nicht der Fall, denn viele Dinge, die eine Person als ein Zeichen wahrnimmt, bestehen aus mehreren Codepunkten:

  • Ein Familien-Emoji setzt sich aus mehreren Person-Emojis zusammen, die durch nullbreite Verbindungszeichen miteinander verbunden sind.
  • Eine Landesflagge setzt sich aus zwei regionalen Indikatorsymbolen zusammen, jeweils eines für jeden Buchstaben des Ländercodes.
  • Eine Hauttonvariante fügt dem Basis-Emoji einen Fitzpatrick-Modifikator hinzu.
  • Ein betontes Buchstabe kann aus einem Basisbuchstaben und anschließendem Kombinationszeichen bestehen.

Durch Trunkierung nach Codepunkten kann man dennoch mitten in einer dieser Sequenzen aufhören. Dadurch wird zwar ungültiges UTF-16 vermieden, doch dem Benutzer kann trotzdem nur die Hälfte einer Schriftfamilie, ein einzelnes regionales Indikatorzeichen oder eine Hand ohne ihre vorgesehene Hautfarbe angezeigt werden.

[...'👋🏽']
// more than one item

Der Array enthält mehr als ein Element, doch der Leser sieht nur eine einzelne winkende Hand. Codepunkte und vom Benutzer wahrgenommene Zeichen sind unterschiedliche Dinge, und genau in dieser Lücke verstecken sich die übrigen Fehler.

Niveau 3: Grapheme-Cluster mit Intl.Segmenter

Die Einheit, die dem von einem Leser Wahrgenommenen entspricht, ist der Grapheme-Cluster, und die eingebaute Methode zur Bestimmung von Grapheme-Grenzen ist Intl.Segmenter mit granularity: 'grapheme'. Der untenstehende Helper segmentiert den String, sammelt den Text jedes Segments ein und trunkiert anschließend das resultierende Array anstelle des Rohstrings.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

function truncateGraphemes(str, max) {
  const graphemes = Array.from(segmenter.segment(str), s => s.segment);
  return graphemes.length > max
    ? graphemes.slice(0, max).join('') + '…'
    : str;
}

truncateGraphemes('Loving my new puppy 👨‍👩‍👧‍👦🐶🐶🐶!', 22);
// → "Loving my new puppy 👨‍👩‍👧‍👦🐶…"

Achten Sie darauf, was bei dem Beispielaufruf passiert. Das Familien-Emoji gilt als ein einziges Graphem und bleibt vollständig erhalten, der erste Hund fällt innerhalb der 22er-Grenze, und die Trennung erfolgt sauber vor dem zweiten Hund. Kein Segment wird jemals geteilt, denn die Funktion greift niemals direkt auf den String zu.

Die Grapheme-Segmentation kümmert sich um Emojis mit mehreren Codepunkten, kombinierte Akzente sowie Schriften wie Thai oder Devanagari, bei denen ein sichtbares Zeichen oft aus mehreren Codepunkten besteht. Intl.Segmenter ist Teil der ECMA-402-Internationalisierungs-Spezifikation. Chrome und Safari führten es 2020 bzw. 2021 ein, Firefox fügte es im April 2024 hinzu, und Node.js enthält es seit Version 16 – daher gibt es in aktuellen Umgebungen keinen Grund, aufgrund von Polyfill-Kosten darauf zu verzichten.

Es ist verlockend, stattdessen eine spezielle Emoji-Bibliothek zu verwenden. Das behebt nur das sichtbare Symptom. Intl.Segmenter löst das eigentliche Problem, nämlich dass Textgrenzen durch Unicode-Regeln definiert werden und nicht durch Array-Indizes.

Details, die richtig umgesetzt werden sollten

Mehrere Optimierungen sind bei der Anwendung des Grapheme-Ansatzes zu berücksichtigen:

  • Das Ellipsenzeichen ist ein zusätzliches Zeichen. Wenn die Längengrenze eine feste Einschränkung darstellt – beispielsweise in einer Datenbankspalte oder einem API-Feld – sollte ein Grapheme dafür reserviert werden oder es einfach weggelassen werden.
  • In dem Beispiel wird der gesamte String vor dem Trunkieren segmentiert. Bei sehr langen Eingaben kann man die Segmente durchlaufen und bei Erreichen der Grenze aufhören, wodurch eine große Array-Struktur vermieden wird.
  • Die Anzahl der Grapheme entspricht nicht der Anzeigebreite. Ein CJK-Zeichen oder ein Emoji kann zwei Terminalspalten einnehmen, weshalb eine CLI, die Spalten ausrichtet, neben der Segmentierung auch eine Brechenermittlung durchführen muss.
  • Zur Erkennung oder Behebung von Zeichenketten, die bereits einzelne Ersatzzeichen enthalten, bieten moderne Laufzeitumgebungen String.prototype.isWellFormed() und toWellFormed() an, die als Schutzmaßnahme vor dem Aufruf von encodeURIComponent oder beim Senden von Daten an eine API nützlich sind.
  • Wo graphemorientierte Trunkierung wichtig ist

    Nicht jede Zeichenkette benötigt dies. Für interne Protokollzeilen oder ausschließlich ASCII-basierte Identifikatoren ist die Graphemsegmentierung überflüssige Arbeit.

    Betrachten Sie jedoch, wo die oben genannten Fehler auftraten: Tweets, geteilte Dokumente, Formulareingabefelder und CLI-Ausgaben. Jeder dieser Orte ist ein Ort, an dem textbasierte Inhalte, die von Benutzern erstellt werden, begrenzt werden und anschließend den Nutzern angezeigt werden. Genau dort lohnt sich die zusätzliche Zeile Code. Es sind auch diese Orte, an denen der Fehler in lokalen Tests nie auftaucht, weil die Testdaten im ASCII-Format vorliegen, aber in der Produktion genau auf den Profilen und Nachrichten erscheint, die die Menschen am häufigsten betrachten.

    Typische Fälle für eine Überprüfung:

    1. Bio- und Profilfelder. Eine „20-Zeichen“-Begrenzung bedeutet 20 sichtbare Zeichen für den Benutzer, nicht 20 UTF-16-Codeeinheiten.
    2. Voransichten von Kommentaren. Eine Voransicht, die mit einem halben Emoji endet, wirkt fehlerhaft, obwohl der vollständige Kommentar in Ordnung ist.
  • Benachrichtigungen. Ein beschädigtes Zeichen in einer Push-Benachrichtigung ist sehr auffällig und lässt das gesamte Produkt unzuverlässig erscheinen.
  • Kollaborative Editor. Der Text wird ständig zwischen Benutzern geteilt und wieder zusammengeführt, wodurch die Handhabung der Grenzen direkt die Datenintegrität beeinflusst.
  • CLI-Ausgabe. Terminal-Tools zeigen zunehmend echten Benutzerinhalt an, weshalb die Handhabung von Unicode nicht mehr nur eine Sache des Browsers ist.
  • Die praktische Regel ist einfach: Überall, wo ein String Benutzer sichtbaren Unicode-Text enthalten kann, ersetzen Sie .slice(0, n) durch eine auf Graphemen basierende Trunkierung und bevorzugen Sie Intl.Segmenter vor dem Spread-Trick. Der Spread-Trick schützt Surrogatpaare; die Segmentierung entspricht dem, was ein Mensch als ein Zeichen wahrnimmt.

    Häufige Fragen

    Funktioniert Intl.Segmenter in Node.js? Ja. Er steht seit Node.js 16 ohne zusätzliche Flagge zur Verfügung. Falls Sie immer noch eine ältere Version verwenden, überprüfen Sie dies erst, bevor Sie annehmen, dass ein Polyfill erforderlich ist.

    Ist er langsamer als slice()? Einigermaßen, denn er führt eine echte Segmentierung durch anstelle eines Index-Schneidens. Bei einem Bio-Feld, das pro Aufladung einmal abgeschnitten wird, ist der Unterschied nicht messbar. Wenn Sie Tausende von Zeichen in einer häufig ausgeführten Schleife abkürzen, führen Sie vor und nach einem Benchmark durch.

    Ist das außerhalb von Emojis relevant? Ja. Kombinationen aus Akzentzeichen sowie vielen süd- und südostasiatischen Schriften, einschließlich Thailändisch, Devanagari, Tamil und Bengali, führen nicht dazu, dass ein Codepunkt einem einzigen sichtbaren Zeichen entspricht. Intl.Segmenter handhabt sie korrekt; sowohl .slice() als auch das Ausbreiten von Eigenschaften können dabei Fehler machen.

    Sollte ich stattdessen twitter-text verwenden? Nur, wenn Sie Twitters spezifische Zählungsregelung wünschen, bei der jedes Emoji 2 und URLs 23 kosten. Das ist eine Geschäftsregel, keine Korrekturmaßnahme. Um zu zählen, was der Benutzer sieht, ist Intl.Segmenter das standardmäßige, eingebaute Werkzeug.

    Zusammenfassung

    Der Ansatz mit der Code-Einheit .slice() war lange Zeit die Standardlösung in Übungen zum Thema „Wie kürzt man einen String ab?“, und er hat zu einer Fehlberechnung der Zeichenzahlbegrenzung bei Twitter, zu einem stillen Synchronisierungsfehler in einem gemeinsam genutzten Editor sowie zu langanhaltenden Problemen in React Native geführt. Die Lösung ist nicht kompliziert: Bestimmen Sie, welche Einheit tatsächlich die Begrenzung betrifft – insbesondere dann, wenn es um das geht, was die Benutzer sehen – und teilen Sie den String vor dem Kürzen in Graphem-Cluster auf. Falls Ihre Codebasis einen truncate-Hilfsfunktion enthält, dauert es nur wenige Minuten, herauszufinden, auf welchem der drei Ebenen er arbeitet, und eine vierzeilige Testzeichenkette reicht aus, um dies zu beweisen.