Wie teilt man Text korrekt in einzelne Zeichen auf?
Verwenden Sie Intl.Segmenter, um Strings in benutzerwahrnehmbare Zeichen statt in Codeeinheiten aufzuteilen
Einführung
Wenn Sie versuchen, das Emoji "👨👩👧👦" mit den Standard-String-Methoden von JavaScript in einzelne Zeichen aufzuteilen, erhalten Sie ein fehlerhaftes Ergebnis. Anstelle eines Familien-Emojis sehen Sie separate Personen-Emojis und unsichtbare Zeichen. Das gleiche Problem tritt bei akzentuierten Buchstaben wie "é", Flaggen-Emojis wie "🇺🇸" und vielen anderen Textelementen auf, die auf dem Bildschirm als einzelne Zeichen erscheinen.
Dies geschieht, weil die eingebaute String-Aufteilung in JavaScript Strings als Sequenzen von UTF-16-Codeeinheiten behandelt und nicht als vom Benutzer wahrgenommene Zeichen. Ein einzelnes sichtbares Zeichen kann aus mehreren zusammengefügten Codeeinheiten bestehen. Wenn Sie nach Codeeinheiten aufteilen, brechen Sie diese Zeichen auseinander.
JavaScript bietet die Intl.Segmenter-API, um dies korrekt zu handhaben. Diese Lektion erklärt, was vom Benutzer wahrgenommene Zeichen sind, warum Standard-String-Methoden sie nicht richtig aufteilen können und wie man Intl.Segmenter verwendet, um Text in tatsächliche Zeichen aufzuteilen.
Was vom Benutzer wahrgenommene Zeichen sind
Ein vom Benutzer wahrgenommenes Zeichen ist das, was eine Person beim Lesen eines Textes als einzelnes Zeichen erkennt. In der Unicode-Terminologie werden diese als Graphem-Cluster bezeichnet. Meistens entspricht ein Graphem-Cluster dem, was Sie als ein Zeichen auf dem Bildschirm sehen.
Der Buchstabe "a" ist ein Graphem-Cluster, der aus einem Unicode-Codepunkt besteht. Das Emoji "😀" ist ein Graphem-Cluster, der aus zwei Codepunkten besteht, die ein einzelnes Emoji bilden. Das Familien-Emoji "👨👩👧👦" ist ein Graphem-Cluster, der aus sieben Codepunkten besteht, die durch spezielle unsichtbare Zeichen verbunden sind.
Wenn Sie Zeichen in einem Text zählen, möchten Sie Graphem-Cluster zählen, nicht Codepunkte oder Codeeinheiten. Wenn Sie Text in Zeichen aufteilen, möchten Sie an den Grenzen von Graphem-Clustern teilen, nicht an beliebigen Positionen innerhalb eines Clusters.
JavaScript-Strings sind Sequenzen von UTF-16-Codeeinheiten. Jede Codeeinheit repräsentiert entweder einen vollständigen Codepunkt oder einen Teil eines Codepunkts. Ein Graphem-Cluster kann sich über mehrere Codepunkte erstrecken, und jeder Codepunkt kann sich über mehrere Codeeinheiten erstrecken. Dies erzeugt eine Diskrepanz zwischen der Art und Weise, wie JavaScript Text speichert, und wie Benutzer Text wahrnehmen.
Warum die split-Methode bei komplexen Zeichen versagt
Die Methode split('') teilt einen String an jeder Code-Unit-Grenze. Dies funktioniert korrekt für einfache ASCII-Zeichen, bei denen jedes Zeichen eine Code-Unit ist. Bei Zeichen, die mehrere Code-Units umfassen, versagt diese Methode.
const simple = "hello";
console.log(simple.split(''));
// Ausgabe: ["h", "e", "l", "l", "o"]
Einfacher ASCII-Text wird korrekt aufgeteilt, da jeder Buchstabe eine Code-Unit ist. Emojis und andere komplexe Zeichen werden jedoch zerlegt.
const emoji = "😀";
console.log(emoji.split(''));
// Ausgabe: ["\ud83d", "\ude00"]
Das lächelnde Gesicht-Emoji besteht aus zwei Code-Units. Die Methode split('') zerlegt es in zwei separate Teile, die für sich genommen keine gültigen Zeichen sind. Bei der Anzeige erscheinen diese Teile als Ersatzzeichen oder gar nicht.
Flaggen-Emojis verwenden regionale Indikatorsymbole, die sich zu Flaggen kombinieren. Jede Flagge benötigt zwei Codepunkte.
const flag = "🇺🇸";
console.log(flag.split(''));
// Ausgabe: ["\ud83c", "\uddfa", "\ud83c", "\uddf8"]
Das US-Flaggen-Emoji wird in vier Code-Units aufgeteilt, die zwei regionale Indikatoren darstellen. Kein Indikator ist für sich allein ein gültiges Zeichen. Man benötigt beide Indikatoren zusammen, um die Flagge zu bilden.
Familien-Emojis verwenden Zeichen mit Nullbreite (Zero-Width Joiner), um mehrere Personen-Emojis zu einem zusammengesetzten Zeichen zu kombinieren.
const family = "👨👩👧👦";
console.log(family.split(''));
// Ausgabe: ["👨", "", "👩", "", "👧", "", "👦"]
Das Familien-Emoji wird in einzelne Personen-Emojis und unsichtbare Verbindungszeichen aufgeteilt. Das ursprüngliche zusammengesetzte Zeichen wird zerstört, und man sieht vier separate Personen anstelle einer Familie.
Akzentbuchstaben können in Unicode auf zwei Arten dargestellt werden. Einige Akzentbuchstaben sind einzelne Codepunkte, während andere einen Grundbuchstaben mit einem kombinierenden diakritischen Zeichen verbinden.
const combined = "é"; // e + kombinierender Akut-Akzent
console.log(combined.split(''));
// Ausgabe: ["e", "́"]
Wenn der Buchstabe é als zwei Codepunkte dargestellt wird (Grundbuchstabe plus kombinierender Akzent), zerbricht die Aufteilung ihn in separate Teile. Das Akzentzeichen erscheint allein, was nicht dem entspricht, was Benutzer erwarten, wenn Text in Zeichen aufgeteilt wird.
Verwendung von Intl.Segmenter zur korrekten Texttrennung
Der Konstruktor Intl.Segmenter erstellt einen Segmentierer, der Text gemäß sprachspezifischen Regeln unterteilt. Übergeben Sie eine Gebietsschema-Kennung als erstes Argument und ein Options-Objekt, das die Granularität als zweites Argument angibt.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
Die Granularität grapheme weist den Segmentierer an, Text an Graphem-Cluster-Grenzen zu teilen. Dies respektiert die Struktur der vom Benutzer wahrgenommenen Zeichen und zerlegt sie nicht.
Rufen Sie die Methode segment() mit einer Zeichenfolge auf, um einen Iterator von Segmenten zu erhalten. Jedes Segment enthält den Text und Positionsinformationen.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "hello";
const segments = segmenter.segment(text);
for (const segment of segments) {
console.log(segment.segment);
}
// Ausgabe:
// "h"
// "e"
// "l"
// "l"
// "o"
Jedes Segment-Objekt enthält eine Eigenschaft segment mit dem Zeichentext und eine Eigenschaft index mit seiner Position. Sie können direkt über die Segmente iterieren, um auf jedes Zeichen zuzugreifen.
Um ein Array von Zeichen zu erhalten, verteilen Sie den Iterator in ein Array und mappen Sie auf den Segment-Text.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "hello";
const characters = [...segmenter.segment(text)].map(s => s.segment);
console.log(characters);
// Ausgabe: ["h", "e", "l", "l", "o"]
Dieses Muster konvertiert den Iterator in ein Array von Segment-Objekten und extrahiert dann nur den Text aus jedem Segment. Das Ergebnis ist ein Array von Strings, einer für jeden Graphem-Cluster.
Korrekte Aufteilung von Emojis in Zeichen
Die Intl.Segmenter-API behandelt alle Emojis korrekt, einschließlich zusammengesetzter Emojis, die mehrere Codepunkte verwenden.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const emoji = "😀";
const characters = [...segmenter.segment(emoji)].map(s => s.segment);
console.log(characters);
// Ausgabe: ["😀"]
Das Emoji bleibt als ein Graphem-Cluster intakt. Der Segmentierer erkennt, dass beide Code-Einheiten zum selben Zeichen gehören und teilt sie nicht auf.
Flaggen-Emojis bleiben als einzelne Zeichen erhalten, anstatt in regionale Indikatoren aufgeteilt zu werden.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const flag = "🇺🇸";
const characters = [...segmenter.segment(flag)].map(s => s.segment);
console.log(characters);
// Ausgabe: ["🇺🇸"]
Die beiden regionalen Indikatorsymbole bilden einen Graphem-Cluster, der die US-Flagge darstellt. Der Segmentierer hält sie als ein Zeichen zusammen.
Familien-Emojis und andere zusammengesetzte Emojis bleiben als einzelne Zeichen erhalten.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const family = "👨👩👧👦";
const characters = [...segmenter.segment(family)].map(s => s.segment);
console.log(characters);
// Ausgabe: ["👨👩👧👦"]
Alle Personen-Emojis und Zero-Width-Joiner bilden einen Graphem-Cluster. Der Segmentierer behandelt das gesamte Familien-Emoji als ein Zeichen und bewahrt so sein Erscheinungsbild und seine Bedeutung.
Text mit Akzentbuchstaben teilen
Die Intl.Segmenter-API behandelt Akzentbuchstaben korrekt, unabhängig davon, wie sie in Unicode kodiert sind.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const precomposed = "café"; // vorkomponiertes é
const characters = [...segmenter.segment(precomposed)].map(s => s.segment);
console.log(characters);
// Ausgabe: ["c", "a", "f", "é"]
Wenn der Akzentbuchstabe é als einzelner Codepunkt kodiert ist, behandelt der Segmenter ihn als ein Zeichen. Dies entspricht den Erwartungen der Benutzer, wie das Wort aufgeteilt werden sollte.
Wenn derselbe Buchstabe als Grundbuchstabe plus kombinierendes diakritisches Zeichen kodiert ist, behandelt der Segmenter ihn immer noch als ein Zeichen.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const decomposed = "café"; // e + kombinierender Akut-Akzent
const characters = [...segmenter.segment(decomposed)].map(s => s.segment);
console.log(characters);
// Ausgabe: ["c", "a", "f", "é"]
Der Segmenter erkennt, dass der Grundbuchstabe und das kombinierende Zeichen einen einzigen Graphem-Cluster bilden. Das Ergebnis sieht identisch zur vorkomponierten Version aus, obwohl die zugrunde liegende Kodierung unterschiedlich ist.
Dieses Verhalten ist wichtig für die Textverarbeitung in Sprachen, die Diakritika verwenden. Benutzer erwarten, dass Akzentbuchstaben als vollständige Zeichen behandelt werden, nicht als separate Grundbuchstaben und Markierungen.
Zeichen korrekt zählen
Ein häufiger Anwendungsfall für die Textteilung ist das Zählen der enthaltenen Zeichen. Die Methode split('') liefert falsche Zählungen für Text mit komplexen Zeichen.
const text = "👨👩👧👦";
console.log(text.split('').length);
// Ausgabe: 7
Das Familien-Emoji erscheint als ein Zeichen, wird aber als sieben gezählt, wenn es nach Code-Einheiten aufgeteilt wird. Dies entspricht nicht den Erwartungen der Benutzer.
Die Verwendung von Intl.Segmenter liefert genaue Zeichenzahlen.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "👨👩👧👦";
const count = [...segmenter.segment(text)].length;
console.log(count);
// Ausgabe: 1
Der Segmenter erkennt das Familien-Emoji als einen Graphem-Cluster, daher ist die Zählung eins. Dies entspricht dem, was Benutzer auf dem Bildschirm sehen.
Sie können eine Hilfsfunktion erstellen, um Graphem-Cluster in beliebigen Zeichenketten zu zählen.
function countCharacters(text) {
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
return [...segmenter.segment(text)].length;
}
console.log(countCharacters("hello"));
// Ausgabe: 5
console.log(countCharacters("café"));
// Ausgabe: 4
console.log(countCharacters("👨👩👧👦"));
// Ausgabe: 1
console.log(countCharacters("🇺🇸"));
// Ausgabe: 1
Diese Funktion funktioniert korrekt für ASCII-Text, Akzentbuchstaben, Emojis und alle anderen Unicode-Zeichen. Die Zählung entspricht immer der Anzahl der vom Benutzer wahrgenommenen Zeichen.
Zeichen an bestimmter Position abrufen
Wenn Sie auf ein Zeichen an einer bestimmten Position zugreifen müssen, können Sie den Text zuerst in ein Array von Graphem-Clustern umwandeln.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "Hello 👋";
const characters = [...segmenter.segment(text)].map(s => s.segment);
console.log(characters[6]);
// Output: "👋"
Das winkende Hand-Emoji befindet sich an Position 6, wenn man Graphem-Cluster zählt. Wenn Sie die standardmäßige Array-Indizierung für den String verwenden würden, erhielten Sie ein ungültiges Ergebnis, da das Emoji mehrere Code-Units umfasst.
Dieser Ansatz ist nützlich bei der Implementierung von zeichenbasierten Operationen wie Zeichenauswahl, Zeichenhervorhebung oder zeichenweisen Animationen.
Text korrekt umkehren
Das Umkehren eines Strings durch Umkehren seines Arrays von Code-Units erzeugt falsche Ergebnisse für komplexe Zeichen.
const text = "Hello 👋";
console.log(text.split('').reverse().join(''));
// Output: "�� olleH"
Das Emoji wird beschädigt, weil seine Code-Units separat umgekehrt werden. Der resultierende String enthält ungültige Zeichensequenzen.
Die Verwendung von Intl.Segmenter zum Umkehren von Text bewahrt die Integrität der Zeichen.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "Hello 👋";
const characters = [...segmenter.segment(text)].map(s => s.segment);
const reversed = characters.reverse().join('');
console.log(reversed);
// Output: "👋 olleH"
Jeder Graphem-Cluster bleibt während der Umkehrung intakt. Das Emoji bleibt gültig, da seine Code-Units nicht getrennt werden.
Den Locale-Parameter verstehen
Der Konstruktor Intl.Segmenter akzeptiert einen Locale-Parameter, aber für die Graphem-Segmentierung hat die Locale minimale Auswirkungen. Graphem-Cluster-Grenzen folgen Unicode-Regeln, die größtenteils sprachunabhängig sind.
const segmenterEn = new Intl.Segmenter('en', { granularity: 'grapheme' });
const segmenterJa = new Intl.Segmenter('ja', { granularity: 'grapheme' });
const text = "Hello 👋 こんにちは";
const charactersEn = [...segmenterEn.segment(text)].map(s => s.segment);
const charactersJa = [...segmenterJa.segment(text)].map(s => s.segment);
console.log(charactersEn);
console.log(charactersJa);
// Both outputs are identical
Unterschiedliche Locale-Kennungen erzeugen die gleichen Graphem-Segmentierungsergebnisse. Der Unicode-Standard definiert Graphem-Cluster-Grenzen so, dass sie sprachübergreifend funktionieren.
Dennoch ist die Angabe einer Locale eine gute Praxis für die Konsistenz mit anderen Intl-APIs und für den Fall, dass zukünftige Unicode-Versionen lokalitätsspezifische Regeln einführen.
Wiederverwendung von Segmentierern für bessere Leistung
Das Erstellen einer neuen Intl.Segmenter-Instanz beinhaltet das Laden von Gebietsschema-Daten und die Initialisierung interner Strukturen. Wenn Sie mehrere Zeichenketten mit denselben Einstellungen segmentieren müssen, erstellen Sie den Segmentierer einmal und verwenden Sie ihn wieder.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const texts = [
"Hello 👋",
"Café ☕",
"World 🌍",
"Family 👨👩👧👦"
];
texts.forEach(text => {
const characters = [...segmenter.segment(text)].map(s => s.segment);
console.log(characters);
});
// Output:
// ["H", "e", "l", "l", "o", " ", "👋"]
// ["C", "a", "f", "é", " ", "☕"]
// ["W", "o", "r", "l", "d", " ", "🌍"]
// ["F", "a", "m", "i", "l", "y", " ", "👨👩👧👦"]
Dieser Ansatz ist effizienter als das Erstellen eines neuen Segmentierers für jede Zeichenkette. Der Leistungsunterschied wird signifikant, wenn große Textmengen verarbeitet werden.
Kombination von Graphem-Segmentierung mit anderen Operationen
Sie können die Graphem-Segmentierung mit anderen String-Operationen kombinieren, um komplexere Textverarbeitungsfunktionen zu erstellen.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
function truncateByCharacters(text, maxLength) {
const characters = [...segmenter.segment(text)].map(s => s.segment);
if (characters.length <= maxLength) {
return text;
}
return characters.slice(0, maxLength).join('') + '...';
}
console.log(truncateByCharacters("Hello 👋 World", 7));
// Output: "Hello 👋..."
console.log(truncateByCharacters("Family 👨👩👧👦 Photo", 8));
// Output: "Family 👨👩👧👦..."
Diese Kürzungsfunktion zählt Graphem-Cluster anstelle von Code-Einheiten. Sie bewahrt Emojis und andere komplexe Zeichen beim Kürzen, sodass die Ausgabe niemals gebrochene Zeichen enthält.
Arbeiten mit String-Positionen
Die von Intl.Segmenter zurückgegebenen Segment-Objekte enthalten eine index-Eigenschaft, die die Position im ursprünglichen String angibt. Diese Position wird in Code-Einheiten gemessen, nicht in Graphem-Clustern.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "Hello 👋";
for (const segment of segmenter.segment(text)) {
console.log(`Character "${segment.segment}" starts at position ${segment.index}`);
}
// Output:
// Character "H" starts at position 0
// Character "e" starts at position 1
// Character "l" starts at position 2
// Character "l" starts at position 3
// Character "o" starts at position 4
// Character " " starts at position 5
// Character "👋" starts at position 6
Das winkende Hand-Emoji beginnt an der Code-Einheitsposition 6, obwohl es die Positionen 6 und 7 im zugrunde liegenden String belegt. Das nächste Zeichen würde an Position 8 beginnen. Diese Information ist nützlich, wenn Sie zwischen Graphem-Positionen und String-Positionen für Operationen wie die Teilstring-Extraktion abbilden müssen.
Umgang mit leeren Strings und Sonderfällen
Die Intl.Segmenter-API behandelt leere Strings und andere Sonderfälle korrekt.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const empty = "";
const characters = [...segmenter.segment(empty)].map(s => s.segment);
console.log(characters);
// Output: []
Ein leerer String erzeugt ein leeres Array von Segmenten. Keine spezielle Behandlung erforderlich.
Leerzeichen werden als separate Graphem-Cluster behandelt.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const whitespace = "a b\tc\nd";
const characters = [...segmenter.segment(whitespace)].map(s => s.segment);
console.log(characters);
// Output: ["a", " ", "b", "\t", "c", "\n", "d"]
Leerzeichen, Tabulatoren und Zeilenumbrüche bilden jeweils eigene Graphem-Cluster. Dies entspricht den Erwartungen der Benutzer bei der zeichenbasierten Textverarbeitung.