Wie teilt man Text korrekt in einzelne Zeichen auf?
Nutze Intl.Segmenter, um Zeichenfolgen in benutzerwahrgenommene Zeichen statt in Codeeinheiten zu unterteilen
Einführung
Wenn du versuchst, das Emoji „👨👩👧👦“ mit den Standard-String-Methoden von JavaScript in einzelne Zeichen zu zerlegen, erhältst du ein fehlerhaftes Ergebnis. Anstelle eines Familien-Emojis siehst du einzelne Personen-Emojis und unsichtbare Zeichen. Dasselbe Problem tritt bei Buchstaben mit Akzent wie „é“, Flaggen-Emojis wie „🇺🇸“ und vielen anderen Textelementen auf, die auf dem Bildschirm als ein einziges Zeichen erscheinen.
Das liegt daran, dass die integrierten String-Methoden von JavaScript Zeichenfolgen als Sequenzen von UTF-16-Codeeinheiten behandeln und nicht als benutzerwahrgenommene Zeichen. Ein sichtbares Zeichen kann aus mehreren Codeeinheiten bestehen, die zusammengefügt werden. Wenn du nach Codeeinheiten trennst, zerreißt du diese Zeichen.
JavaScript stellt die Intl.Segmenter-API bereit, um dies korrekt zu handhaben. Diese Lektion erklärt, was vom Benutzer wahrgenommene Zeichen sind, warum Standard-String-Methoden sie nicht korrekt aufteilen können und wie Intl.Segmenter verwendet wird, um Text in tatsächliche Zeichen aufzuteilen.
Was benutzerwahrgenommene Zeichen sind
Ein benutzerwahrgenommenes Zeichen ist das, was eine Person beim Lesen als einzelnes Zeichen erkennt. In der Unicode-Terminologie werden sie als Graphem-Cluster bezeichnet. Meistens entspricht ein Graphem-Cluster dem, was du als ein Zeichen auf dem Bildschirm siehst.
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 zusammen ein einzelnes Emoji bilden. Das Familien-Emoji „👨👩👧👦“ ist ein Graphem-Cluster, der aus sieben Codepunkten besteht, die mit speziellen unsichtbaren Zeichen verbunden sind.
Wenn Sie Zeichen in einem Text zählen, möchten Sie Graphem-Cluster zählen, nicht Code-Points oder Code-Units. Wenn Sie Text in Zeichen aufteilen, möchten Sie an Graphem-Cluster-Grenzen teilen, nicht an beliebigen Positionen innerhalb eines Clusters.
JavaScript-Strings sind Sequenzen von UTF-16-Code-Units. Jede Code-Unit repräsentiert entweder einen vollständigen Code-Point oder einen Teil eines Code-Points. Ein Graphem-Cluster kann mehrere Code-Points umfassen, und jeder Code-Point kann mehrere Code-Units umfassen. 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 split('')-Methode teilt einen String an jeder Code-Unit-Grenze. Dies funktioniert korrekt für einfache ASCII-Zeichen, bei denen jedes Zeichen eine Code-Unit ist. Sie versagt bei Zeichen, die mehrere Code-Units umfassen.
const simple = "hello";
console.log(simple.split(''));
// Output: ["h", "e", "l", "l", "o"]
Einfacher ASCII-Text wird korrekt aufgeteilt, da jeder Buchstabe eine Code-Unit ist. Emoji und andere komplexe Zeichen werden jedoch auseinandergerissen.
const emoji = "😀";
console.log(emoji.split(''));
// Output: ["\ud83d", "\ude00"]
Das lächelnde Gesicht-Emoji besteht aus zwei Code-Units. Die split('')-Methode zerlegt es in zwei separate Teile, die für sich allein keine gültigen Zeichen sind. Bei der Anzeige erscheinen diese Teile als Ersatzzeichen oder gar nicht.
Flaggen-Emoji verwenden regionale Indikatorsymbole, die sich zu Flaggen kombinieren. Jede Flagge benötigt zwei Code-Points.
const flag = "🇺🇸";
console.log(flag.split(''));
// Output: ["\ud83c", "\uddfa", "\ud83c", "\uddf8"]
Das US-Flaggen-Emoji wird in vier Code-Units aufgeteilt, die zwei regionale Indikatoren repräsentieren. Keiner der Indikatoren ist für sich allein ein gültiges Zeichen. Sie benötigen beide Indikatoren zusammen, um die Flagge zu bilden.
Familien-Emoji verwenden Zero-Width-Joiner-Zeichen, um mehrere Personen-Emoji zu einem zusammengesetzten Zeichen zu kombinieren.
const family = "👨👩👧👦";
console.log(family.split(''));
// Output: ["👨", "", "👩", "", "👧", "", "👦"]
Das Familien-Emoji wird in einzelne Personen-Emojis und unsichtbare Verbindungszeichen aufgeteilt. Das ursprüngliche zusammengesetzte Zeichen wird zerstört, und Sie sehen vier separate Personen anstelle einer Familie.
Akzentuierte Buchstaben können in Unicode auf zwei Arten dargestellt werden. Einige akzentuierte Buchstaben sind einzelne Codepunkte, während andere einen Basisbuchstaben mit einem kombinierenden diakritischen Zeichen kombinieren.
const combined = "é"; // e + combining acute accent
console.log(combined.split(''));
// Output: ["e", "́"]
Wenn der Buchstabe é als zwei Codepunkte dargestellt wird (Basisbuchstabe plus kombinierendes Akzentzeichen), zerlegt die Aufteilung ihn in separate Teile. Das Akzentzeichen erscheint allein, was nicht den Erwartungen der Benutzer entspricht, wenn Text in Zeichen aufgeteilt wird.
Verwendung von Intl.Segmenter zur korrekten Textaufteilung
Der Intl.Segmenter-Konstruktor erstellt einen Segmentierer, der Text nach gebietsschemaspezifischen Regeln aufteilt. Übergeben Sie eine Locale-Kennung als erstes Argument und ein Options-Objekt, das die Granularität angibt, als zweites Argument.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
Die grapheme-Granularität weist den Segmentierer an, Text an Graphem-Cluster-Grenzen aufzuteilen. Dies respektiert die Struktur der vom Benutzer wahrgenommenen Zeichen und zerlegt sie nicht.
Rufen Sie die segment()-Methode mit einem String 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);
}
// Output:
// "h"
// "e"
// "l"
// "l"
// "o"
Jedes Segment-Objekt enthält eine segment-Eigenschaft mit dem Zeichentext und eine index-Eigenschaft 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 Segmenttext.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "hello";
const characters = [...segmenter.segment(text)].map(s => s.segment);
console.log(characters);
// Output: ["h", "e", "l", "l", "o"]
Dieses Muster konvertiert den Iterator in ein Array von Segmentobjekten und extrahiert dann nur den Text aus jedem Segment. Das Ergebnis ist ein Array von Strings, eines für jeden Graphemcluster.
Emoji korrekt in Zeichen aufteilen
Die Intl.Segmenter-API verarbeitet alle Emojis korrekt, einschließlich zusammengesetzter Emojis, die mehrere Code-Punkte verwenden.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const emoji = "😀";
const characters = [...segmenter.segment(emoji)].map(s => s.segment);
console.log(characters);
// Output: ["😀"]
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);
// Output: ["🇺🇸"]
Die beiden regionalen Indikatorsymbole bilden ein Graphem-Cluster, das 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);
// Output: ["👨👩👧👦"]
Alle Personen-Emojis und Zero-Width-Joiner bilden ein Graphem-Cluster. Der Segmentierer behandelt das gesamte Familien-Emoji als ein Zeichen und bewahrt dessen Erscheinungsbild und Bedeutung.
Text mit Akzentbuchstaben aufteilen
Die Intl.Segmenter-API verarbeitet akzentuierte Buchstaben korrekt, unabhängig davon, wie sie in Unicode kodiert sind.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const precomposed = "café"; // precomposed é
const characters = [...segmenter.segment(precomposed)].map(s => s.segment);
console.log(characters);
// Output: ["c", "a", "f", "é"]
Wenn der Akzentbuchstabe é als einzelner Codepunkt kodiert ist, behandelt der Segmentierer ihn als ein Zeichen. Dies entspricht den Benutzererwartungen beim Aufteilen des Wortes.
Wenn derselbe Buchstabe als Basisbuchstabe plus kombinierendes diakritisches Zeichen kodiert ist, behandelt der Segmentierer ihn dennoch als ein Zeichen.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const decomposed = "café"; // e + combining acute accent
const characters = [...segmenter.segment(decomposed)].map(s => s.segment);
console.log(characters);
// Output: ["c", "a", "f", "é"]
Der Segmentierer erkennt, dass der Basisbuchstabe und das kombinierende Zeichen ein einzelnes Graphem-Cluster bilden. Das Ergebnis sieht identisch zur präkomponierten 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 Basisbuchstaben und Zeichen.
Zeichen korrekt zählen
Ein häufiger Anwendungsfall für das Aufteilen von Text ist das Zählen der enthaltenen Zeichen. Die split('')-Methode liefert falsche Zählungen für Text mit komplexen Zeichen.
const text = "👨👩👧👦";
console.log(text.split('').length);
// Output: 7
Das Familien-Emoji erscheint als ein Zeichen, wird aber als sieben gezählt, wenn es nach Codeeinheiten aufgeteilt wird. Dies entspricht nicht den Erwartungen der Benutzer.
Die Verwendung von Intl.Segmenter liefert genaue Zeichenzählungen.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "👨👩👧👦";
const count = [...segmenter.segment(text)].length;
console.log(count);
// Output: 1
Der Segmentierer erkennt das Familien-Emoji als einen Graphemcluster, sodass die Zählung eins beträgt. Dies entspricht dem, was Benutzer auf dem Bildschirm sehen.
Sie können eine Hilfsfunktion erstellen, um Graphemcluster 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"));
// Output: 5
console.log(countCharacters("café"));
// Output: 4
console.log(countCharacters("👨👩👧👦"));
// Output: 1
console.log(countCharacters("🇺🇸"));
// Output: 1
Diese Funktion funktioniert korrekt für ASCII-Text, Buchstaben mit Akzenten, Emoji 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 Graphemclustern konvertieren.
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 Graphemcluster gezählt werden. Wenn Sie die Standard-Array-Indizierung auf die Zeichenkette anwenden würden, würden Sie ein ungültiges Ergebnis erhalten, da das Emoji mehrere Codeeinheiten umfasst.
Dieser Ansatz ist nützlich bei der Implementierung von Operationen auf Zeichenebene wie Zeichenauswahl, Zeichenhervorhebung oder zeichenweise Animationen.
Text korrekt umkehren
Das Umkehren einer Zeichenkette durch Umkehren ihres Arrays von Codeeinheiten erzeugt falsche Ergebnisse für komplexe Zeichen.
const text = "Hello 👋";
console.log(text.split('').reverse().join(''));
// Output: "�� olleH"
Das Emoji bricht, weil seine Codeeinheiten separat umgekehrt werden. Die resultierende Zeichenkette enthält ungültige Zeichensequenzen.
Die Verwendung von Intl.Segmenter zum Umkehren von Text bewahrt die Zeichenintegrität.
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 Graphemcluster bleibt während der Umkehrung intakt. Das Emoji bleibt gültig, weil seine Codeeinheiten nicht getrennt werden.
Verstehen des Locale-Parameters
Der Intl.Segmenter-Konstruktor 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
Verschiedene Locale-Identifikatoren erzeugen dieselben Graphem-Segmentierungsergebnisse. Der Unicode-Standard definiert Graphem-Cluster-Grenzen auf eine Weise, die sprachübergreifend funktioniert.
Die Angabe einer Locale ist jedoch weiterhin eine gute Praxis für die Konsistenz mit anderen Intl-APIs und für den Fall, dass zukünftige Unicode-Versionen locale-spezifische Regeln einführen.
Wiederverwendung von Segmentern für bessere Performance
Das Erstellen einer neuen Intl.Segmenter-Instanz umfasst das Laden von Locale-Daten und die Initialisierung interner Strukturen. Wenn Sie mehrere Strings mit denselben Einstellungen segmentieren müssen, erstellen Sie den Segmenter 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 Segmenters für jeden String. Der Performance-Unterschied wird signifikant, wenn große Textmengen verarbeitet werden.
Kombination von Graphem-Segmentierung mit anderen Operationen
Sie können 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 Truncate-Funktion zählt Graphem-Cluster anstatt Code-Einheiten. Sie bewahrt Emojis und andere komplexe Zeichen beim Kürzen, sodass die Ausgabe niemals defekte 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 Code-Einheit-Position 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 Substring-Extraktion mappen müssen.
Umgang mit leeren Strings und Sonderfällen
Die Intl.Segmenter-API verarbeitet leere Strings und andere Grenzfä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. Es ist 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.