Головна / Статті

Обрізання рядка, сумісне з емодзі, у JavaScript за допомогою Intl.Segmenter

Чому функція slice() та обрізання за допомогою розповсюдження пошкоджують емодзі та текст із діакритичними знаками, як через це зламалися реальні продукти, та як замість цього обрізати рядки за межами графем.

Видавець: REACTAPP.TOP10 хв читання2341 слів

Джерела: I Was Truncating Strings Wrong

Майже кожен кодовий базис із полем біографії, попереднім переглядом коментаряв або зведенням сповіщень має невеликий допоміжний функціонал, який скорочує текст до встановленої кількості символів. Зазвичай він виглядає коректно, проходить усі тести, написані для нього, але потім таємно пошкоджує текст будь-якого користувача, чий вхідний даний містить емодзі поблизу межі обрізки. У цій статті пояснюється, що насправді підраховує JavaScript при використанні .length та .slice(), розглядаються реальні баги у відомих продуктах, які мають одну й ту саму причину, та показано, як обрізати текст саме на межах, які бачить користувач.

Допоміжний функціонал, який пишуть усі

Ось версія, яка зустрічається у безлічі кодових баз та навчальних матеріалів: якщо рядок довший за встановлений ліміт, його обрізають та додається символ „…“.

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

Для англійського тексту він працює ідеально, саме тому й існує так довго. Проблема виникає лише тоді, коли у введеному тексті є, скажімо, два емодзі, і ліміт збігається з місцем між ними. Замість коротшого рядка профіль відображає замінний символ: чорний діамант із запитальним знаком. На перший погляд у функції немає жодних проблем, а тестовий набір з рядками ASCII ніколи її не виявить.

Що насправді підраховується методом .length

Рядки JavaScript — це послідовності кодових одиниць UTF-16. І метод .length, і метод .slice() працюють з цими 16-бітними одиницями, а не з символами у людському розумінні.

Для латинських літер, цифр та найпоширенішого пунктуаційного знаку одна кодова одиниця дорівнює одному символу, тож різниця є непомітною. Символи, які знаходяться поза основною багатомовною площиною, до якої належать більшість емодзі, потребують двох кодових одиниць. Ця пара називається парою замінників: високий замінник, який слідує за низьким замінником, разом кодують одну кодову точку.

'🐶'.length   // 2

Один пес на екрані, довжина 2. Якщо .slice() розрізає між двома частинами, ви не отримуєте меншого емодзі. Ви отримуєте окремий замінник, який сам по собі не є дійсним символом Unicode. Браузери зазвичай відображають його як символ-замінник, а більш суворі програми кидають помилку.

Це перший рівень проблеми. Існує ще другий, більш тонкий рівень, про який йдеться нижче. По-перше, варто зазначити, що це не теоретична проблема.

Та сама помилка у чотирьох реальних продуктах

14-символьні прапорці Twitter

Протягом багатьох років Twitter оцінював довжину твіту приблизно за кількістю кодових позицій. Звичайний емодзі у вигляді пальця, піднятого вгору, коштував 2 символи, додавання емодзі з відтінком шкіри підвищувало цю кількість до 7, а деякі прапорці — аж до 14. Причина в тому, що варіанти з відтінком шкіри та прапорці складаються з кількох поєднаних кодових позицій, і кожна з них рахувалася окремо.

У жовтні 2018 року Twitter змінив правила так, що кожен емодзі тепер оцінюється у 2 одиниці, незалежно від кількості кодових позицій, які він містить. Це правило все ще є частиною алгоритмів підрахунку в його бібліотеці з відкритим кодом twitter-text. Дивіться інформацію про зміни у підрахунку емодзі та документацію щодо підрахунку символів у X.

Цей приклад корисний, тому що він розділяє два питання, які легко плутати:

  • Скільки видимих символів бачить користувач?
  • Як продукт повинен формувати ціну чи встановлювати обмеження для різних типів тексту?

Відповідь Twitter на друге запитання — це політика продукту. Це не те саме, що вимірювання того, що відображається на екрані.

Yjs та lib0: розділений емодзі, який мовчки зупинив синхронізацію

Yjs — це широко використовувана бібліотека для спільної редагування, на якій будується TipTap. Yjs залежить від невеликого пакету утиліт lib0, чий помічник splice внутрішньо використовував .slice().

Коли спільна редагування відбувалася між двома частинами емодзі, lib0 створював рядок, що містив «сиротливий» суррогат. Цей рядок згодом передавався до encodeURIComponent, який генерував помилку URIError, коли натрапляв на одинокий суррогат. Ніщо не ловило цю помилку, тому синхронізація просто зупинилася. Існує детальний опис багу з суррогатом у lib0.

Те, що робить ситуацію особливо проблематичною, — це те, що це було невидимо. Редактор продовжував працювати локально. Користувачі бачили свій власний текст та продовжували вводити інформацію, не маючи жодних ознак того, що їхні зміни більше не доходять до інших. Виправлення, розроблене командою проекту, виявляє неправильний символ на краю розділеної стрінгу та замінює його на символ-замінник.

React Native TextInput та maxLength

У компоненті TextInput React Native є властивість maxLength, і проблеми з його поведінкою щодо емодзі фіксуються з 2016 року, наприклад у справах 10964 та 28774.

Звіти мають однакову структуру. Встановлюється maxLength, вводиться текст до цього ліміту, а потім додається емодзі. Замість того, щоб відхилити емодзі або прийняти його повністю, вхідний текст обрізається на межі кодової одиниці. Користувачі повідомляли про те, що бачили інше емодзі, ніж те, яке вони ввели, коробку з символом „A“ або, у iOS, про повне очищення поля.

Якщо у вашому додатку є поле імені користувача або біографії з максимальною довжиною та ви ніколи не вводили емодзі, що позначає колір шкіри, саме на межі цього ліміту, варто спробувати це зробити. Кілька швидких ручних тестів охоплюють основні випадки:

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

У цих чотирьох рядках є просте емодзі, емодзі з модифікатором кольору шкіри, прапорець та послідовність символів, з’єднані безширинними з’єднувачами. Якщо ваш код розглядає кожен з них як окремий символ, ймовірно, він підраховує щось інше.

Функція truncateString у Gemini CLI

У березні 2026 року було виявлено, що у інструменті Gemini CLI від Google існує допоміжна функція truncateString, яка обрізає рядок на рівні значення maxLength. Якщо рядок закінчується емодзі сльозливого обличчя, а значення ліміту знаходиться всередині нього, ця функція повертає окремий символ \uD83D — високий сурогат сам по собі (проблема 22753).

Це гірше, ніж просто естетична проблема. Окремий сурогат робить рядок недійсним у форматі UTF-16, а передача його кодувальнику JSON чи API може спричинити помилки далі у ланцюжку обробки. Рішенням, до якого зрештою дійде кожна команда, є таке: ніколи не дозволяти точці обрізання потрапляти всередину пари сурогатів.

Чотири продукти, чотири різні роки — одна основна причина. Емодзі є лише найбільш помітним симптомом більш серйозної проблеми: текст не розділяється там, де це передбачають індекси JavaScript.

Три рівні „символа“

Корисно уявляти стратегії обрізання як процеси, що діють на трьох рівнях, кожен з яких враховує іншу одиницю.

Рівень 1: кодові одиниці з .length та .slice()

Саме до цього майже всі звертаються першими, оскільки це очевидний API. Це також єдиний із трьох способів, який може розділити пару замінників. Якщо ваш ліміт становить 20, а 20-та кодова одиниця знаходиться посеред емодзі, результатом буде пошкоджений текст, а не коротший рядок. Усі баги у Yjs, Gemini CLI та React Native полягають саме на цьому рівні.

Цей психологічний розрив легко пояснити. JavaScript індексує кодові одиниці UTF-16; розробники схильні вважати, що вони індексують символи. Для більшості англійського тексту це збігається, і саме цей збіг приховує проблему.

Рівень 2: кодові точки шляхом розтягування рядка

Поширеним рішенням є перетворення рядка на масив, який ітерується за кодовими позиціями, а не за кодовими одиницями.

const codePoints = [...str];

Це дозволяє зберігати пари сурогатних символів разом, тож один емодзі собаки залишається недоторканим. Це справжнє покращення, і багато розробників на цьому зупиняються, вважаючи, що проблема вирішена.

Це не так, адже багато того, що людина сприймає як один символ, насправді складається з кількох кодових позицій:

  • Емодзі родини складається з кількох емодзі людей, об’єднаних символами з нульовою шириною.
  • Прапор країни складається з двох символів-індикаторів регіону, по одному на кожну літеру коду країни.
  • Варіант відтінку шкіри додає до базового емодзі модифікатор Фіцпатріка.
  • Літера з акцентом може бути базовою літерою, до якої додається знак поєднання.

Обрізання за кодовими пунктами все одно може зупинитися посеред однієї з цих послідовностей. Ви уникаєте недійсного формату UTF-16, але користувач все одно може побачити лише частину символа, окрему літеру-індикатор регіону чи руку без її заданого відтінку шкіри.

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

У масиві є більше одного елемента, проте користувач бачить лише одну руку, яка махає. Кодові пункти та символи, які сприймає користувач, — це різні речі, і саме в цій різниці ховаються залишкові помилки.

Рівень 3: кластери графем з Intl.Segmenter

Одиницею, яка відповідає тому, що бачить користувач, є кластер графем, а вбудованим способом знаходження меж графем є Intl.Segmenter із параметром granularity: 'grapheme'. Наведений нижче функціонал розділяє рядок на сегменти, збирає текст кожного сегмента та обрізає отриманий масив замість самого рядка.

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 👨‍👩‍👧‍👦🐶…"

Зверніть увагу на те, що відбувається під час прикладного виклику. Емодзі з сім’ї розглядається як один графем, тож залишається цілим; перший пес вписується у ліміт у 22 символи, і розріз відбувається чітко перед другим псом. Жоден сегмент не розділяється, оскільки функція ніколи не працює безпосередньо з рядком.

Сегментація графем дозволяє обробляти емодзі з кількома кодовими точками, комбінації акцентів, а також письма на кшталт тайського чи деванагарі, де один видимий символ часто складається з кількох кодових точок. Intl.Segmenter є частиною специфікації інтернаціоналізації ECMA-402. Chrome та Safari додали його у 2020 та 2021 роках, Firefox — у квітні 2024 року, а Node.js включив його з версії 16; тому у сучасних середовищах немає причин уникати його через необхідність використання поліфілів.

Існує спокуса скористатися бібліотекою, призначеною спеціально для емодзі. Це допомагає лише усунути видимий симптом. Intl.Segmenter вирішує справжню проблему — а саме те, що межі тексту визначаються правилами Unicode, а не індексами масиву.

Деталі, які варто правильно налаштувати

При використанні підходу з графемами варто врахувати кілька удосконалень:

  • Три крапки є додатковим символом. Якщо обмеження є суворим, наприклад, для стовпця бази даних чи поля API, виділіть для них окрему графему або пропустіть їх.
  • У прикладі весь рядок сегментується перед обрізанням. Для дуже довгих вхідних даних можна перебирати сегменти та зупинятися при досягненні обмеження, що дозволяє уникнути створення великого масиву.
  • Кількість графем не відповідає ширині відображення. Символ CJK або емодзі може займати дві колонки терміналу, тому CLI, яка вирівнює колонки, потребує обчислення ширини окремо від процесу сегментації.
  • Щоб виявити або виправити рядки, які вже містять окремі замінники, сучасні середовища виконання надають методи String.prototype.isWellFormed() та toWellFormed(), які становлять користь як захід перед викликом encodeURIComponent або надсиланням даних до API.
  • Де важлива обрізка з урахуванням графем

    Не кожному рядку це потрібно. Для внутрішніх записів журналу або ідентифікаторів, що містять лише ASCII, сегментація графем є зайвою роботою.

    Однак подивіться, де виникали ці помилки: твіти, спільнодоступні документи, поля форм та вивід командного рядка. Кожне з цих місць — це джерело тексту, створеного користувачем, який має певний ліміт та потім повертається користувачам. Саме тут додатковий рядок коду виправдовує себе. Це також ті місця, де помилка ніколи не з’являється під час локальних тестів, оскільки тестові дані є у форматі ASCII, але з’являється в продакшені саме у профілях та повідомленнях, які найчастіше переглядають користувачі.

    Типові випадки для аудиту:

    1. Поля біографії та профілю. Ліміт у „20 символів“ означає 20 видимих символів для користувача, а не 20 одиниць коду UTF-16.
    2. Попередні перегляди коментарів. Попередній перегляд, який закінчується наполовину емодзі, виглядає не так, ніби щось не так, хоча повний коментар працює нормально.
  • Повідомлення. Пошкоджений символ у повідомленні типу push є дуже помітним та робить весь продукт схожим на ненадійний.
  • Спільні редактори. Текст постійно ділиться та об’єднується між користувачами, тому правильна обробка меж безпосередньо впливає на цілісність даних.
  • Вивід через CLI. Інструменти терміналу все частіше відображають справжній контент користувача, тому обробка Unicode більше не є проблемою лише браузера.
  • Практичне правило просте. Там, де рядок може містити текст Unicode, видимий для користувача, замініть .slice(0, n) на обрізку на основі графем та віддавайте перевагу Intl.Segmenter замість методу розтягування. Метод розтягування захищає сурогатні пари; метод сегментації відповідає тому, що людина бачить як один символ.

    Поширені запитання

    Чи працює Intl.Segmenter у Node.js? Так. Він був доступний без додаткових налаштувань з Node.js 16. Якщо ви все ще використовуєте старішу версію, спочатку переконайтеся в цьому, перш ніж припускати необхідність використання polyfill.

    Чи він повільніший за slice()? Дещо, оскільки виконує справжню сегментацію замість простого обрізання за індексом. Для поля з біографією, яке обрізається один раз на відображення, різниця буде незначною. Якщо ви обрізаєте тисячі рядків у циклі, проведіть тестування до та після використання цього інструменту.

    Чи має це значення поза випадком емодзі? Так. Поєднання діакритичних знаків та багатьох писемностей Південної та Південно-Східної Азії, включаючи тайську, деванагарі, тамільську та бенгальську, не призводить до того, що один кодовий пункт відповідає одному видимому символу. Intl.Segmenter правильно обробляє такі випадки; .slice() та інші методи все ще можуть давати помилки.

    Чи варто мені замість цього використовувати twitter-text? Лише якщо ви хочете дотримуватися специфічної політики підрахунку від Twitter, згідно з якою кожен емодзі коштує 2, а URL — 23. Це бізнес-правило, а не спосіб виправлення помилок. Щоб підрахувати те, що бачить користувач, стандартним вбудованим інструментом є Intl.Segmenter.

    Підсумок

    Підхід із використанням кодової одиниці .slice() протягом тривалого часу був стандартною відповіддю у посібниках типу „як обрізати рядок“, і це призвело до неправильного підрахунку ліміту символів у Twitter, до тихого збою синхронізації у спільному редакторі та до проблем, що тривалий час існували у React Native. Рішення не є складним: визначте, яка саме одиниця є предметом вашого ліміту, і коли йдеться про те, що бачать користувачі, розділіть текст на групи графем перед обрізанням. Якщо у вашому кодовому базі є допоміжна функція truncate, знадобиться кілька хвилин, щоб з’ясувати, на якому з трьох рівнів вона працює, а також чотири рядки тестового тексту для підтвердження.