Галоўная / Артыкулы

Адаптаванне строкі в 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-бітнымі ейканамі, а не з символамі ў людскім розумэнні.

Для латынскіх літер, цифр і большайшай часткі знакоў пунктуацыі адна елементарная екранацыя пасвядчае аднаму симвалу, таму разлік невидны. Симвалы, якія знаходзяцца за межами Basic Multilingual Plane, які включае большасць эмодзі, патрабуюць двух елементарных екранацыяў. Гэтае пара называецца surrogate pair: высокі surrogate, які следуе за низкім surrogate, і ў сукупнасці яны кодуюць адну точку коду.

'🐶'.length   // 2

Адна собака на экране, дыяметр 2. Якщо .slice() розрывае ўздоўж двух частак, вы не отрымаеце меншага эмодзі. Вы отрымаеце адзін surrogate, які сам по сабе не ўсунак у Unicode. Браузеры зазвычай атрымоўваюць яго як символ замены, а болей строгія програмы выклекаюць памылку.

Гэта першы слой проблемы. Існуе другі, болей тонкі слой, які практычна розглянуты нижэй. Перш за ўсё, варта зазначыць, што гэта не тэорычная проблема.

Тая ж баг у чатырох рэальных продуктах

14-символьныя флагі Twitter

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

У жанвары 2018 года Twitter змяніў правіла так, што кожны эмодзі абліковаўся як 2 символы, незалежна ад таго, сколькі кодовых пунктав ён мае. Гэты падход даўжыні застаёцца часткай правіла аблікавання дужыні ў яго бібліятэцы з адкрытым кодам twitter-text. Пераканаецеся ў інформацыі пра змяну спосабу аблікавання дужыны эмодзіў і ў даследжэннях, прысвечаных лічбе симвалоў у платформе X.

Гэты прыклад ўжытковы, таму што ён раздзеляе два пытанні, якія лёгка сплутаць:

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

Адказ Твітэра на другы вопыт — это політыка продукту. Це не тое ж самае, што і вимеры таго, што паказваецца на экране.

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? Толькі якщо вы хочацее спецыяльную палітіку пісчыць ад Твітэра, па якой кожны эмодзі коштае 2, а URL-адрэсы — 23. Гэта правіло бізнесу, а не спосаб пасправлення. Чыба пісці тое, што бачыць корыстнік, стандартным вбудованым інструментам ёсць Intl.Segmenter.

    Узагальненне

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