Obcinanie łańcucha bez emoji w JavaScript za pomocą Intl.Segmenter
Dlaczego funkcja slice() oraz skracanie oparte na rozsiewaniu psują emoji i tekst z akcentami, jak awarii uległy rzeczywiste produkty w tym przypadku oraz jak zamiast tego przecinać łańcuchy na granicach grafemów.
Źródła: I Was Truncating Strings Wrong
Prawie każda baza kodu z polem biograficznym, wstępem do komentarza lub podsumowaniem powiadomień posiada mały pomocnik, który skraca tekst do określonej liczby znaków. Zazwyczaj wygląda poprawnie, przechodzi wszystkie testy napisane dla niego, a potem po cichu psuje tekst każdego użytkownika, którego wpis zawiera emotikonę w pobliżu granicy długości. Ten artykuł wyjaśnia, co tak naprawdę liczy JavaScript przy użyciu metod .length i .slice(), omawia rzeczywiste błędy w dobrze znanych produktach o tej samej przyczynie źródłowej oraz pokazuje, jak obcinać tekst dokładnie w miejscach widocznych dla czytelnika.
Pomocnik, który wszyscy piszą
Oto wersja, która pojawia się w niezliczonych bazach kodu i tutorialach: jeśli ciąg znaków jest dłuższy niż limit, obcina się go i dodaje trójkąt.
function truncate(str, max) {
return str.length > max ? str.slice(0, max) + '…' : str;
}
Dla tekstu w języku angielskim funkcjonuje doskonale, i to właśnie dlatego przetrwał tak długo. Problem pojawia się tylko wtedy, gdy dane wprowadzone przez użytkownika zawierają na przykład dwa emoji, a limit przypadkowo znajduje się pomiędzy nimi. Zamiast krótszego ciągu znaków, profil wyświetla znak zastępczy: czarny diament z znakiem zapytania. Na pierwszy rzut oka nic w tej funkcji nie wydaje się błędne, a zestaw testów składający się z ciągów znaków ASCII nigdy go nie wykryje.
Co faktycznie liczy .length
Ciągi znaków w JavaScript to sekwencje jednostek kodu UTF-16. Zarówno .length, jak i .slice() operują na tych 16-bitowych jednostkach, a nie na znakach w ludzkim rozumieniu.
Dla liter łacińskich, cyfr i najczęściej używanej interpunkcji jedna jednostka kodowa odpowiada jednemu znakowi, więc różnica jest niewidoczna. Znaki poza Podstawową Płaszczyzną Językową, która obejmuje większość emoji, wymagają dwóch jednostek kodowych. Ten parzysty zestaw nazywany jest parą zastępczą: wysoki zastępca następujący po niskim zastępcy, które razem kodują jeden punkt kodowy.
'🐶'.length // 2
Jeden pies na ekranie o długości 2. Jeśli .slice() przetnie tekst pomiędzy tymi dwoma fragmentami, nie otrzymasz mniejszego emoji. Otrzymasz pojedynczy zastępca, który sam w sobie nie jest ważnym znakiem Unicode. Przeglądarki zazwyczaj wyświetlają go jako znak zastępczy, natomiast bardziej rygorystyczne aplikacje wywołują błąd.
To jest pierwszy poziom problemu. Istnieje drugi, bardziej subtelny, omówiony poniżej. Najpierw warto zauważyć, że nie jest to kwestia teoretyczna.
Ten sam błąd w czterech rzeczywistych produktach
Flagi Twittera składające się z 14 znaków
Przez lata Twitter szacował długość tweetów w przybliżeniu na podstawie punktów kodyfikacji. Zwykłe emoji w postaci kciuka w górę kosztowało 2 znaki, dodanie informacji o kolorze skóry podnosiło tę wartość do 7, a niektóre flagi mogły wynosić nawet 14. Powodem jest to, że warianty koloru skóry oraz flagi składają się z kilku połączonych punktów kodyfikacji, a każdy z nich był liczony oddzielnie.
W październiku 2018 roku Twitter zmienił zasady tak, aby każde emoji było liczone jako 2 znaki, niezależnie od liczby punktów kodyfikacji, które zawiera. Ta zasada nadal stanowi część reguł liczenia w jego bibliotece o otwartym kodzie źródłowym twitter-text. Można zapoznać się z informacjami na temat tej zmiany w zakresie liczenia emoji oraz z dokumentacją dotyczącą liczenia znaków w X.
Ten przykład jest przydatny, ponieważ oddziela dwie kwestie, które łatwo pomylić:
- Ile widocznych znaków widzi użytkownik?
- Jak produkt powinien ustalać ceny lub ograniczenia dla różnych rodzajów tekstu?
Odpowiedź Twittera na drugie pytanie to zasady dotyczące produktu. Nie jest to to samo, co pomiar tego, co wyświetla się na ekranie.
Yjs i lib0: podzielony emoji, który w ciszy zatrzymał synchronizację
Yjs to powszechnie używana biblioteka do wspólnej edycji, a TipTap opiera się na niej. Yjs polega na małym pakiecie narzędziowym lib0, którego pomocnik splice używał wewnętrznie funkcji .slice().
Gdy wspólna edycja przypadkowo odbywała się pomiędzy dwiema połowami emoji, lib0 wytwarzał ciąg znaków zawierający samotny surrogate. Ten ciąg był następnie przekazywany do funkcji encodeURIComponent, która wywołuje błąd URIError, gdy napotka samotny surrogate. Nic nie przechwyciło tego błędu, więc synchronizacja po prostu się zatrzymała. Istnieje szczegółowy opis błędu surrogate w lib0.
To, co sprawia, że sytuacja ta jest szczególnie problematyczna, to fakt, iż zmiany były niewidoczne. Edytor nadal funkcjonował lokalnie – użytkownicy widzieli swój własny tekst i kontynuowali pisanie, bez żadnych sygnałów wskazujących na to, że ich zmiany już nie docierają do innych osób. Poprawka z serwisu głównego wykrywa nieprawidłowy znak na końcu ciętej strony i zastępuje go odpowiednim znakiem zastępczym.
React Native TextInput i maxLength
Komponent TextInput w React Native oferuje właściwość maxLength, a problemy z jego zachowaniem wobec emoji są zgłaszane od 2016 roku, np. w sprawach numer 10964 i 28774.
Raporty mają ten sam schemat. Ustaw maxLength, wpisz tekst do tego limitu, a następnie dodaj emotikonę. Zamiast odrzucić emotikonę lub przyjąć ją w całości, system przecina ją na granicy jednostek kodowych. Użytkownicy donoszą o widzeniu innej emotikony niż ta, którą wpisali, pola zawierającego literę „A” lub, w przypadku iOS, o całkowitym usunięciu zawartości pola.
Jeśli twoja aplikacja ma pole z nazwą użytkownika lub biogramem z określonym maksymalnym długością i nigdy nie wpisywałeś emotikony przedstawiającej kolor skóry dokładnie na tej granicy, warto to wypróbować. Krótki zestaw testów ręcznych obejmuje najważniejsze przypadki:
Hello 👋
Hello 👋🏽
Hello 🇮🇳
Hello 👨👩👧👦
Te cztery linie zawierają prostą emotikonę, jedną z modyfikatorem koloru skóry, flagę oraz sekwencję rodzinową połączoną za pomocą elementów o zerowej szerokości. Jeśli twój kod traktuje każdą z nich jako pojedynczy znak, prawdopodobnie liczy coś innego.
func truncateString w Gemini CLI
W marcu 2026 roku odkryto, że narzędzie Gemini CLI firmy Google posiada funkcję pomocniczą truncateString, która przecina ciąg znaków w miejscu maxLength. Gdy ciąg kończył się emotikonem płaczącej twarzy, a limit przecięcia znajdował się w jego obrębie, funkcja ta zwracała pojedynczy znak \uD83D – sam wysoki surrogat (problem 22753).
To jest gorsze niż tylko kwestia wyglądu. Pojedynczy surrogat sprawia, że ciąg znaków staje się niewalidnym plikiem UTF-16, a przekazanie go do kodera JSON lub API może prowadzić do błędów późniejszych w łańcuchu przetwarzania. Rozwiązaniem było to, do którego ostatecznie dochodzi każdy zespół: nigdy nie pozwalać, by punkt przecięcia znajdował się wewnątrz pary surrogatów.
Cztery produkty, cztery różne lata – jedna przyczyna podstawowa. Emotikony są jedynie najbardziej widocznym objawem szerszego problemu: tekst nie jest dzielony w miejscach, które sugerują indeksy JavaScripta.
Trzy poziomy „znaku”
Pomaga myśleć o strategiach skracania jako o działających na trzech poziomach, z których każdy uwzględnia inną jednostkę.
Poziom 1: jednostki kodu z .length i .slice()
To jest to, do czego niemal wszyscy się najpierw uciekają, ponieważ jest to oczywisty interfejs API. Jest to również jedyna z trzech metod, która może podzielić parę zastępczą. Jeśli twoje ograniczenie wynosi 20, a 20. jednostka kodu znajduje się w środku emotikonki, rezultatem jest uszkodzony tekst zamiast krótszej ciągu znaków. Błędy w Yjs, Gemini CLI i React Native występują wszystkie na tym poziomie.
Różnica w rozumieniu jest prosta do sformułowania. JavaScript indeksuje jednostki kodu UTF-16; programiści mają tendencję do przekonania, że indeksują znaki. Dla większości tekstu w języku angielskim obie te koncepcje zbiegają się, a ten zbieg ukrywa problem.
Poziom 2: punkty kodu poprzez rozszerzenie ciągu znaków
Powszechnym rozwiązaniem jest przechowanie ciągu znaków w tablicy, która iteruje po punktach kody zamiast po jednostkach kody.
const codePoints = [...str];
To sprawia, że pary zastępcze pozostają razem, dzięki czemu pojedynczy emoji psa pozostaje nienaruszony. Jest to rzeczywiście ulepszenie, a wielu programistów na tym poprzestaje, sądząc, że problem został rozwiązany.
Jest inaczej, ponieważ wiele elementów, które osoba postrzega jako jeden znak, składa się z kilku punktów kody:
- Emoji rodziny to kilka emoji osób połączonych znakami łączącymi o zerowej szerokości.
- Flaga państwa składa się z dwóch symboli wskaźników regionalnych, po jednym dla każdej litery kodu kraju.
- Wariant o innym kolorze skóry dodaje do podstawowego emoji modyfikator Fitzpatricka.
- Litera z akcentem może być literą podstawową połączoną z znakiem łączącym.
Przycięcie na poziomie punktu kody nadal może zakończyć się w środku jednej z tych sekwencji. Unikniesz nieważnego formatu UTF-16, ale użytkownik może nadal zobaczyć tylko połowę znaku, pojedynczą literę oznaczającą region lub rękę bez przypisanego jej koloru skóry.
[...'👋🏽']
// more than one item
Tablica zawiera więcej niż jeden element, a mimo to użytkownik widzi tylko jedną machającą rękę. Punkty kody i znaki postrzegane przez użytkownika to różne rzeczy, i właśnie w tym rozdźwięku kryją się pozostałe błędy.
Poziom 3: klastry grafemów z Intl.Segmenter
Jednostką odpowiadającą temu, co widzi użytkownik, jest klaster grafemów, a wbudowanym sposobem na znalezienie granic grafemów jest Intl.Segmenter z ustawieniem granularity: 'grapheme'. Poniższy pomocnik dzieli ciąg znaków na segmenty, zbiera tekst każdego z nich i przycina otrzymaną tablicę, a nie sam ciąg znaków.
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 👨👩👧👦🐶…"
Zwróć uwagę na to, co dzieje się w przykładowym wywołaniu. Emotikon przedstawiający rodzinę jest traktowany jako jeden grafem i pozostaje nienaruszony, pierwszy pies mieści się w limicie 22 znaków, a podział następuje dokładnie przed drugim psem. Żaden segment nie jest dzielony, ponieważ funkcja nigdy bezpośrednio nie indeksuje ciągu znaków.
Segmentacja grafemów obsługuje emotikony składające się z wielu punktów kodyfikacji, kombinacje akcentów oraz pisma takie jak tajski czy dewanagari, w których jeden widoczny znak często odpowiada kilku punktom kodyfikacji. Intl.Segmenter jest częścią specyfikacji internacjonalizacji ECMA-402. Chrome i Safari włączyły go w latach 2020 i 2021, Firefox dodał go w kwietniu 2024 roku, a Node.js zawiera go od wersji 16, więc w obecnych środowiskach nie ma konieczności używania polyfilli, aby uniknąć jego zastosowania.
Kuszące jest zamiast tego użycie biblioteki specjalizującej się w emoji. Rozwiązuje to jedynie widoczny objaw. Intl.Segmenter zajmuje się rzeczywistym problemem, a mianowicie tym, że granice tekstu są określane przez zasady Unicode, a nie przez indeksy tablicy.
Detale, które warto poprawnie ustalić
Przy zastosowaniu podejścia opartego na grafemach warto rozważyć kilka ulepszeń:
- Kropki eliptyczne to dodatkowy znak. Jeśli limit stanowi sztywną ograniczenie, takie jak kolumna w bazie danych lub pole API, należy przeznaczyć na nie jeden grafem albo je pominąć.
- Przykład dzieli całą ciąg znaków przed skracaniem. W przypadku bardzo długich danych można iterować po segmentach i zatrzymać się, gdy osiągnięty zostanie limit, co pozwala uniknąć tworzenia dużej tablicy.
String.prototype.isWellFormed() i toWellFormed(), które przydają się jako zabezpieczenie przed wywołaniem encodeURIComponent lub wysyłką danych do API.Gdzie ma znaczenie obcinanie z uwzględnieniem grafemów
Nie każdy łańcuch tego potrzebuje. W przypadku wewnętrznych rekordów logów lub identyfikatorów składających się wyłącznie z znaków ASCII segmentacja grafemów jest niepotrzebnym zadaniem.
Zwróć jednak uwagę na to, gdzie wystąpiły powyższe błędy: tweety, udostępniane dokumenty, pola formularzy oraz wyjście z interfejsu wiersza poleceń. Każde z tych miejsc to miejsce, gdzie tekst stworzony przez użytkownika podlega ograniczeniu, a następnie jest pokazywany ludziom. To właśnie tam dodatkowy wiersz kodu się opłaca. Są to również miejsca, gdzie błąd nigdy nie pojawia się podczas testów lokalnych, ponieważ pliki testowe są w formacie ASCII, ale pojawia się on w środowisku produkcyjnym dokładnie na tych profilach i wiadomościach, które ludzie najczęściej oglądają.
Typowe przypadki do audytu:
- Pola biografii i profilu. Ograniczenie „20 znaków” oznacza 20 widocznych znaków dla użytkownika, a nie 20 jednostek kodu UTF-16.
- Przeglądy komentarzy. Przegląd, który kończy się w połowie emotikonki, wygląda niepoprawnie, mimo że pełny komentarz jest w porządku.
Zasada praktyczna jest prosta. Wszędzie tam, gdzie ciąg może zawierać tekst Unicode widoczny dla użytkownika, należy zastąpić .slice(0, n) metodą obcinania opartą na grafemach oraz preferować Intl.Segmenter zamiast sztuczki rozszerzania. Rozszerzanie chroni pary zastępcze; segmentacja odpowiada temu, co człowiek postrzega jako jeden znak.
Częste pytania
Czy Intl.Segmenter działa w Node.js? Tak. Jest dostępny bez żadnych specjalnych ustawień od wersji Node.js 16. Jeśli nadal używasz starszej wersji środowiska, sprawdź to przed założeniem konieczności użycia polyfilla.
Czy jest wolniejszy od slice()? W pewnym stopniu, ponieważ realizuje rzeczywiste segmentowanie zamiast prostego cięcia według indeksu. W przypadku pola z biogramem, które jest skracane raz na renderowanie, różnica nie będzie zauważalna. Jeśli skracasz tysiące ciągów znaków w intensywnie wykonywanym pętli, przeprowadź pomiar wydajności przed i po zmianie.
Czy to ma znaczenie poza emoji? Tak. Połączenie akcentów oraz wielu pism z Azji Południowej i Południowo-Wschodniej, w tym tajskiego, dewanagari, tamilskiego i bengalskiego, nie odpowiada jednemu punktowi kody na jeden widoczny znak. Intl.Segmenter radzi sobie z nimi poprawnie; zarówno .slice(), jak i metoda rozszerzania mogą nadal popełniać błędy.
Czy powinienem zamiast tego użyć twitter-text? Tylko wtedy, gdy chcesz stosować specyficzną zasadę liczenia Twittera, według której każdy emoji kosztuje 2, a adresy URL – 23. To zasada biznesowa, a nie poprawka dotycząca poprawności. Aby policzyć to, co widzi użytkownik, Intl.Segmenter jest standardowym, wbudowanym narzędziem.
Podsumowanie
Podejście oparte na funkcji .slice() od dawna jest standardowym rozwiązaniem w poradnikach na temat „jak obciąć ciąg znaków”, ale przyczyniło się do błędnego określania limitu znaków w Twitterze, do cichego awarii synchronizacji w edytorze współpracy oraz do problemów trwających długo w React Native. Rozwiązanie nie jest skomplikowane – należy określić, jaka jednostka faktycznie jest przedmiotem ograniczenia, a gdy chodzi o to, co widzą użytkownicy, podzielić tekst na grupy grafemów przed jego obcięciem. Jeśli w bazie kodu znajduje się pomocnik truncate, wystarczy kilka minut, aby ustalić, na którym z trzech poziomów działa, a czterowierszowy ciąg testowy potwierdzi to fakty.
Literatura pokrewna
- Usuwanie boilerplate try/catch z tras Express za pomocą asyncHandler — Dowiedz się, jak middleware obsługujące błędy, otoczka asyncHandler oraz spersonalizowana klasa AppError przenoszą odpowiedzi na błędy w Express z każdej trasy do jednego spójnego miejsca.
- Bramy zatwierdzenia w LangGraph.js: Pauzowanie agentów za pomocą interrupt() i Command — Stwórz minimalną bramę zatwierdzenia w LangGraph.js, która pauzuje przed wywołaniem efektu ubocznego, zbiera decyzję człowieka w terminalu i bezpiecznie wznowia działanie od punktu kontrolnego.