Главная / Статьи

Безопасная обрезка строк с эмодзи в 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. Браузеры обычно отображают его как символ замены, а более строгие приложения выдают ошибку.

Это первый уровень проблемы. Существует ещё второй, более тонкий уровень, о котором рассказано ниже. Прежде всего стоит отметить, что это не теоретическая проблема.

Та же ошибка в четырёх реальных продуктах

Флаги в Twitter длиной 14 символов

В течение многих лет 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 или эмодзи могут занимать две колонки терминала, поэтому интерфейсы командной строки, требующие выравнивания колонок, должны выполнять расчёт ширины помимо сегментации текста.
  • Для обнаружения или исправления строк, уже содержащих одинокие заместители, современные фреймворки предоставляют методы String.prototype.isWellFormed() и toWellFormed(), которые удобны в качестве проверки перед вызовом encodeURIComponent или отправкой данных в API.
  • Где важна обрезка с учётом графем

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

    Однако обратите внимание, где возникали вышеупомянутые ошибки: твиты, общедоступные документы, поля форм и вывод командной строки. В каждом из этих случаев текст, созданный пользователем, подвергается ограничению перед тем, как быть показанным людям. Именно здесь дополнительная строка кода оправдывает себя. Это также те места, где ошибка никогда не проявляется в локальных тестах, поскольку тестовые данные представлены в формате ASCII, но появляется в продакшене именно на тех профилях и сообщениях, которые чаще всего просматриваются пользователями.

    Типичные случаи для аудита:

    1. Поля биографии и профиля. Ограничение в «20 символов» означает 20 видимых символов для пользователя, а не 20 единиц кода UTF-16.
    2. Предварительный просмотр комментариев. Предварительный просмотр, заканчивающийся половиной эмодзи, выглядит некорректно, хотя полный комментарий работает без проблем.
  • Уведомления. Некорректный символ в уведомлении очень заметен, из-за чего весь продукт кажется ненадежным.
  • Совместные редакторы. Текст постоянно делят и объединяют между пользователями, поэтому обработка границ напрямую влияет на целостность данных.
  • Вывод в CLI. Инструменты терминала всё чаще отображают реальный пользовательский контент, поэтому обработка Unicode больше не является проблемой только браузеров.
  • Практическое правило простое: там, где строка может содержать Unicode-текст, видимый для пользователя, замените .slice(0, n) на обрезку на основе графем и предпочитайте Intl.Segmenter вместо метода распространения. Метод распространения защищает суррогатные пары; метод сегментации соответствует тому, что человек воспринимает как один символ.

    Часто задаваемые вопросы

    Работает ли Intl.Segmenter в Node.js? Да. С Node.js 16 он доступен без каких-либо флагов. Если вы всё ещё используете более старую версию среды выполнения, убедитесь в этом, прежде чем предполагать необходимость использования полифилла.

    Является ли он медленнее, чем slice()? До некоторой степени — потому что он выполняет реальную сегментацию вместо простого разрезания по индексу. Для поля с биографией, которое обрезается один раз при каждом отображении, разница будет незаметной. Если вы обрезаете тысячи строк в цикле с высокой частотой, проведите тестирование до и после применения этого метода.

    Имеет ли это значение помимо эмодзи? Да. Сочетание диакритических знаков и многих южно- и юго-восточноазиатских письменностей, включая тайское, деванагари, тамильское и бенгальское, не соответствует принципу одного кодового точка — одного видимого символа. Intl.Segmenter правильно справляется с этим; в то время как .slice() и метод распространения могут давать ошибки.

    Стоит ли использовать twitter-text вместо этого? Только если вы хотите соблюдать специфическую политику подсчёта от Twitter, согласно которой каждый эмодзи стоит 2 единицы, а URL — 23. Это деловое правило, а не способ исправления ошибок. Для подсчёта того, что видит пользователь, стандартным встроенным инструментом является Intl.Segmenter.

    Заключение

    Метод кодовой единицы .slice() долгое время считался стандартным решением в учебниках по теме «как обрезать строку», и это привело к неправильному определению лимита символов в Twitter, к тихой ошибке синхронизации в коллаборативном редакторе и к долгосрочным проблемам в React Native. Решение здесь несложно: определите, какая именно единица является основой для лимита, и если речь идет о том, что видят пользователи, сначала разделите текст на группы графем перед обрезкой. Если в вашей кодовой базе есть вспомогательная функция truncate, потребуется несколько минут, чтобы выяснить, на каком из трех уровней она работает, а также четыре строки тестового текста для подтверждения.