Должен признаться: на протяжении очень долгого времени я испытывал страх перед Unicode. Когда была необходимость в работе с Unicode, я предпочитал искать альтернативные пути решения, поскольку не совсем понимал, что делаю.
Я старался избегать работы с Unicode до тех пор, пока не столкнулся с проблемой, требующей глубокого понимания этого стандарта, а других вариантов решения просто не было.
Приложив определенные усилия, прочитав кучу статей — я постепенно начал понимать что к чему, и это оказалось не так уж трудно. Хотя, некоторые статьи приходилось перечитывать раза по 3.
Как оказалось, Unicode — это универсальный и удобный стандарт, но работать с ним может быть непросто из-за множества абстрактных терминов.
Если у вас есть пробелы в понимании Unicode, то сейчас самое подходящее время их заполнить! Заварите себе вкусный чай или кофе ☕. И давайте погрузимся в удивительный мир абстракций, символов, астралов (astrals) и суррогатов (surrogates).
В этой статье объясняются основные концепции Unicode, которые создадут необходимую базу для работы с ним.
Вы также узнаете, как JavaScript взаимодействует с Unicode и какие трудности могут возникнуть на этом пути.
А также, каким образом новые функции из ECMAScript 2015 могут помочь в решении этих проблем.
Готовы? Давайте начнем!
Начнем с простого вопроса. Как вам удается читать и понимать эту статью? Очень просто: вы знаете значение букв и слов.
Как вы понимаете значение букв? Очень просто: у вас (читателя) и у меня (писателя) есть соглашение относительно связи между графическим символом (тем, что видно на экране) и буквой русского (в оригинале — английского, разумеется) языка (значением).
То же самое происходит и с компьютерами. Разница в том, что компьютеры не понимают значения букв. Для компьютеров буквы — это просто последовательности битов.
Представьте, что Пользователь 1 отправляет сообщение hello
Пользователю 2.
Компьютер Пользователя 1 не понимает значения букв. Он преобразует hello
в последовательность чисел 0x68 0x65 0x6C 0x6C 0x6F
, где каждая буква однозначно соответствует числу: h
— это 0x68
, e
— 0x65
и т.д. Эти числа отправляются на компьютер Пользователя 2.
Когда компьютер Пользователя 2 получает последовательность чисел 0x68 0x65 0x6C 0x6C 0x6F
, он использует то же сопоставление букв и чисел для декодирования и восстанавливает сообщение, а затем выводит на экран hello
.
Соглашение между двумя компьютерами о соответствии между буквами и числами — это то, что стандартизирует Unicode.
С точки зрения Unicode, h
— это абстрактный символ, LATIN SMALL LETTER H. Этому символу соответствует число 0x68
, которое является кодовой точкой (code point) в обозначении U+0068
.
Роль Unicode заключается в предоставлении списка абстрактных символов (набора символов) и присвоении каждому символу уникальной идентификационной кодовой точки (закодированного набора символов).
2. Основные термины Unicode
На сайте www.unicode.org отмечается:
"Unicode предоставляет уникальное число для каждого символа, независимо от платформы, независимо от программы и независимо от языка".
Unicode — это универсальный набор символов, который включает в себя большинство систем письменности и присваивает каждому символу уникальное число (кодовую точку).
Unicode содержит символы большинства современных языков, знаки препинания, диакритические знаки, математические и технические символы, стрелки, эмодзи и многое другое.
Первая версия Unicode 1.0 была опубликована в октябре 1991 года и содержала 7 161 символ. Последняя версия 14.0 (опубликованная в сентябре 2021 г.) содержит кодировки для 144 697 символов.
Благодаря единому и всеобъемлющему подходу, Unicode решает проблему, когда производители создают множество наборов символов и кодировок, с которыми сложно работать.
Раньше было трудно создать приложение, поддерживающее все эти наборы символов и кодировки.
Если кажется, что Unicode усложняет задачу, представьте, насколько сложнее программирование было бы без него.
Я все еще помню, как выбирал кодировки наугад для чтения содержимого файлов. Чистая лотерея!
2.1 Символы и кодовые точки
"Абстрактный символ (или просто символ) (abstract character/character) — это единица информации, используемая для систематизации, управления или представления текстовых данных".
Unicode рассматривает символы как абстрактные понятия. У каждого абстрактного символа есть связанное с ним имя, например, LATIN SMALL LETTER A. Отображаемая форма (глиф — glyph) этого символа — a
.
"Кодовая точка — это число, присвоенное одному символу".
Кодовые точки — это числа в диапазоне от U+0000
до U+10FFFF
.
U+<hex>
— формат кодовых точек, где U+
— префикс, означающий Unicode, а <hex>
— число в шестнадцатеричном формате. Например, U+0041
и U+2603
.
При работе с Unicode важно понимать, что кодовая точка — это просто числовое значение, которое можно рассматривать как индекс элемента в массиве.
Магия Unicode заключается в связи между кодовой точкой и символом. Например, U+0041
соответствует символу с именем LATIN CAPITAL LETTER A (отображается как A
), а кодовая точка U+2603
соответствует символу с именем SNOWMAN (отображается как ☃
).
Не все кодовые точки имеют связанные символы. Всего доступно 1 114 112
кодовых точек (в диапазоне от U+0000
до U+10FFFF
), но только 144 697
из них (на сентябрь 2021 года) имеют связанные символы.
2.2 Плоскости Unicode
"Плоскость (plane) — это диапазон из65 536
кодовых точек Unicode отU+n0000
доU+ffff
, гдеn
может принимать значения от 0 до 10 в шестнадцатеричном формате".
Весь набор кодовых точек Unicode разбит на 17 плоскостей:
- Плоскость 0 содержит кодовые точки от
U+0000
доU+FFFF
, - Плоскость 1 содержит кодовые точки от
U+10000
доU+1FFFF
- ...
- Плоскость 16 содержит кодовые точки от
U+100000
доU+10FFFF
.
Основная многоязычная плоскость
Плоскость 0 — это особая плоскость, называемая основной многоязычной плоскостью (Basic Multilingual Plane, BMP). Она содержит символы большинства современных языков (латиница, кириллица, греческий и т.д.) и большое количество других символов.
Как упоминалось выше, кодовые точки в BMP находятся в диапазоне от U+0000
до U+FFFF
и могут содержать до 4 шестнадцатеричных цифр.
Разработчик чаще всего имеет дело именно с символами из BMP.
Некоторые символы из BMP:
e
— этоU+0065
, LATIN SMALL LETTER E|
—U+007C
, VERTICAL BAR■
—U+25A0
, BLACK SQUARE☂
—U+2602
, UMBRELLA
Астральные плоскости
16 плоскостей после BMP (плоскость 1-16) называются астральными(дополнительными) (astral/supplementary) плоскостями.
Кодовые точки в астральных плоскостях, называются астральными кодовыми точками. Они находятся в диапазоне от U+10000
до U+10FFFF
.
Астральная кодовая точка может содержать 5 или 6 цифр в шестнадцатеричном формате: U+dddddd
или U+ddddddd
.
Некоторые символы из астральных плоскостей:
𝄞
— этоU+1D11E
, MUSICAL SYMBOL G CLEF𝐁
—U+1D401
, MATHEMATICAL BOLD CAPITAL B🀵
—U+1F035
, DOMINO TITLE HORIZONTAL-00-04😀
—U+1F600
, GRINNING FACE
2.3 Кодовые единицы
Итак, символы Unicode, кодовые точки и плоскости являются абстракциями.
Однако на практическом, аппаратном уровне, Unicode реализуется с помощью физического представления кодовых точек — кодовых единиц (code units).
Компьютеры на уровне памяти не оперируют непосредственно кодовыми точками или абстрактными символами. Вместо этого, для представления символов Unicode используются кодовые единицы.
"Кодовая единица — это последовательность битов, используемая для кодирования каждого символа в определенной кодировке".
Кодировка символов (character encoding) отвечает за преобразование абстрактных кодовых точек Unicode в физические биты — кодовые единицы. Иными словами, кодировка символов преобразует кодовые точки Unicode в уникальные последовательности кодовых единиц.
Самыми распространенными кодировками являются UTF-8, UTF-16 и UTF-32.
Большинство движков JavaScript используют кодировку UTF-16, поэтому давайте подробнее рассмотрим ее.
UTF-16 (16-разрядный формат преобразования Unicode) — это кодировка переменной длины (variable-length encoding):
- Кодовые точки из BMP кодируются с использованием одной 16-битной кодовой единицы
- Кодовые точки из астральных плоскостей кодируются с использованием двух кодовых единиц по 16 бит.
Рассмотрим несколько примеров.
Предположим, вы хотите сохранить на жестком диске символ a
(LATIN SMALL LETTER A). Согласно Unicode, этому символу соответствует кодовая точка U+0061
.
Теперь обратимся к кодировке UTF-16 и узнаем, как преобразовать U+0061
. Согласно спецификации кодирования, для кодовой точки из BMP нужно взять ее шестнадцатеричное значение U+0061
и сохранить его в одной 16-битной кодовой единице: 0x0061
.
Как можно заметить, кодовые точки из BMP помещаются в одну 16-битную кодовую единицу.
2.4 Суррогатные пары
Рассмотрим более сложный случай. Предположим, вы хотите закодировать символ 😀
(GRINNING FACE). Этому символу соответствует кодовая точка U+1F600
из астральной плоскости Unicode.
Поскольку для сохранения информации в астральных кодовых точках требуется 21 бит, UTF-16 сообщает, что нужны две кодовые единицы по 16 бит. Кодовая точка U+1F600
разделена на так называемую суррогатную пару: 0xD83D
(верхняя суррогатная кодовая единица — high-surrogate code unit) и 0xDE00
(нижняя суррогатная кодовая единица — low-surrogate code unit).
"Суррогатная пара — это структура для кодирования одного абстрактного символа. Она состоит из последовательности двух 16-битных кодовых единиц, где первое значение пары является верхней суррогатной кодовой единицей, а второе значение — нижней суррогатной кодовой единицей".
Для астральной кодовой точки требуются две кодовые единицы — суррогатная пара. Например, для кодирования U+1F600
(😀
) в UTF-16 используется суррогатная пара: 0xD83D 0xDE00
.
console.log('\uD83D\uDE00'); // => '😀'
Верхняя суррогатная кодовая единица принимает значения в диапазоне от 0xD800
до 0xDBFF
. Нижняя суррогатная кодовая единица принимает значения в диапазоне от 0xDC00
до 0xDFFF
.
Алгоритм преобразования суррогатной пары в астральную кодовую точку и наоборот следующий:
function getSurrogatePair(astralCodePoint) {
let highSurrogate =
Math.floor((astralCodePoint - 0x10000) / 0x400) + 0xD800;
let lowSurrogate = (astralCodePoint - 0x10000) % 0x400 + 0xDC00;
return [highSurrogate, lowSurrogate];
}
getSurrogatePair(0x1F600); // => [0xD83D, 0xDE00]
function getAstralCodePoint(highSurrogate, lowSurrogate) {
return (highSurrogate - 0xD800) * 0x400
+ lowSurrogate - 0xDC00 + 0x10000;
}
getAstralCodePoint(0xD83D, 0xDE00); // => 0x1F600
Суррогатные пары непросты. При работе со строками в JavaScript обрабатывать их следует особым образом, как описано ниже в статье.
Однако UTF-16 имеет преимущество в экономии памяти. 99% используемых символов берутся из BMP и требуют только одну 16-битную кодовую единицу, что позволяет сэкономить значительный объем памяти.
2.5 Комбинирующие знаки
"Графема (grapheme), или символ (symbol), представляет собой минимальную дискретную единицу письма в рамках определенной системы письма".
Графема — это то, как пользователь представляет символ. Реальное изображение графемы, отображаемое на экране, называется глифом (glyph).
Во многих случаях один символ Unicode — это одна графема. Например, U+0066
LATIN SMALL LETTER F — это английское написание f
.
Бывают случаи, когда графема содержит последовательность символов.
Например, å
— это атомарная графема (atomic grapheme) в датской системе письма. Она передается с помощью LATIN SMALL LETTER A U+0061
(отображается как a
) в сочетании со специальным символом U+030A
, COMBINING RING ABOVE (отображается как ◌̊
).
U+030A
изменяет предшествующий символ и называется комбинирующим знаком (combining mark).
console.log('\u0061\u030A'); // => 'å'
console.log('\u0061'); // => 'a'
"Комбинирующий знак — это символ, который добавляется к основному символу для создания новой графемы".
Комбинирующие знаки включают такие символы, как ударения, диакритические знаки, точки на иврите, арабские знаки гласных и акцентов и т.д.
Комбинирующие знаки обычно не используются без основного символа.
Как и суррогатные пары, комбинирующие знаки сложно обрабатывать в JavaScript.
Комбинирующая последовательность символов (основной символ + комбинирующий знак) отображается пользователю как один символ (например, '\u0061\u030A'
— это 'å'
). Но разработчик должен понимать, что для построения å
используются 2 кодовые точки U+0061
и U+030A
.
3. Unicode в JavaScript
В спецификации ES2015 упоминается, что для текста исходного кода должен быть использован Unicode (версии 5.1 и выше). Исходный текст представлен как последовательность кодовых точек от U+0000
до U+10FFFF
. Способ хранения или обмена исходным кодом не определен в ECMAScript, но обычно используется UTF-8 (предпочтительная кодировка для Интернета).
Рекомендуется использовать символы из основной латиницы Unicode (Basic Latin или ASCII) в тексте исходного кода, с экранированием символов, не входящих в ASCII. Такой подход минимизирует возможные проблемы с кодированием.
В ECMAScript 2015 есть определение того, что на внутреннем уровне языка является строкой в JavaScript.
"Строковый тип представляет собой упорядоченную последовательность из нуля или более 16-битных целых значений без знака, известных как "элементы". Максимальная длина строки составляет до 2^53-1 элементов. Строковый тип обычно используется для представления текстовых данных в программе ECMAScript. Каждый элемент строки обрабатывается как значение кодовой единицы UTF-16".
Каждый элемент строки в ECMAScript интерпретируется как кодовая единица. Способ отображения строки не предусматривает детерминированный (однозначный) способ определения содержащихся в ней кодовых точек.
Посмотрим на пример:
console.log('cafe\u0301'); // => 'café'
console.log('café'); // => 'café'
Литералы 'cafe\u0301'
и 'café'
имеют небольшое различие в кодовых единицах, но оба представляют одну и ту же последовательность символов café
.
"Длина строки определяется количеством элементов (т.е. 16-битных значений), содержащихся в ней. [...] В операциях ECMAScript строковые значения интерпретируются таким образом, что каждый элемент рассматривается как отдельная кодовая единица UTF-16".
Как упоминалось ранее, некоторые символы могут быть представлены двумя или более кодовыми единицами. Поэтому при подсчете количества символов или доступе к символам по индексу необходимо принимать во внимание эту особенность:
const smile = '\uD83D\uDE00';
console.log(smile); // => '😀'
console.log(smile.length); // => 2
const letter = 'e\u0301';
console.log(letter); // => 'é'
console.log(letter.length); // => 2
Строка в переменной smile
содержит 2 кодовых единицы: \uD83D
(верхний суррогат) и \uDE00
(нижний суррогат). Поскольку строка представляет собой последовательность кодовых единиц, значение smile.length
равно 2
. Несмотря на то, что отображение smile
содержит только один символ '😀'
.
Тоже самое происходит со строкой в letter
. Комбинирующий знак U+0301
применяется к предыдущему символу, и результат отображения — один символ 'é'
. Однако letter
содержит 2
кодовых единицы, поэтому, letter.length
равно 2
.
Мой совет: необходимо всегда рассматривать строку JavaScript как последовательность кодовых единиц, учитывая, что способ отображения строки на экране не позволяет точно определить содержащиеся в ней кодовые единицы.
Астральные символы и комбинирующие последовательности символов требуют кодирования с использованием 2 или более кодовых единиц, но воспринимаются как одна графема. При наличии суррогатных пар или комбинирующих знаков в строке возможны затруднения при определении длины строки или доступе к символу по индексу.
Большинство методов строк JavaScript не поддерживают Unicode. Поэтому, при наличии составных символов Unicode, рекомендуется аккуратно использовать такие методы, как myString.slice()
, myString.substring()
и т.д.
3.1 Escape-последовательности
Escape-последовательности (escape sequences) в строках используются для выражения кодовых единиц на основе кодовых точек. В языке JavaScript существуют 3 типа escape-последовательностей, один из которых был добавлен в стандарт ECMAScript 2015.
Рассмотрим их более подробно.
Шестнадцатеричная escape-последовательность
Один из типов escape-последовательностей называется шестнадцатеричной escape-последовательностью (hexadecimal escape sequence): \x<hex>
, где \x
— префикс, за которым следует шестнадцатеричное число <hex>
, состоящее из 2 знаков. Например, '\x30'
(символ '0'
) или '\x5B'
(символ '['
).
Шестнадцатеричная escape-последовательность может использоваться в строковых литералах или регулярных выражениях:
const str = '\x4A\x61vaScript';
console.log(str); // => 'JavaScript'
const reg = /\x4A\x61va.*/;
console.log(reg.test('JavaScript')); // => true
Она может экранировать кодовые точки в ограниченном диапазоне: от U+00
до U+FF
, поскольку используются только две шестнадцатеричные цифры. Однако, шестнадцатеричная escape-последовательность привлекательна своей короткой формой.
Escape-последовательность Unicode
Для экранирования кодовых точек из всего диапазона BMP, используется escape-последовательность Unicode (unicode escape sequence). Формат такой escape-последовательности — это \u<hex>
, где \u
— префикс, за которым следует шестнадцатеричное число <hex>
из 4 знаков. Например, '\u0051'
(символ 'Q'
) или '\u222B'
(интегральный символ '∫'
).
Рассмотрим примеры использования escape-последовательностей Unicode:
const str = 'I\u0020learn \u0055nicode';
console.log(str); // => 'I learn Unicode'
const reg = /\u0055ni.*/;
console.log(reg.test('Unicode')); // => true
Escape-последовательность Unicode может экранировать кодовые точки в ограниченном диапазоне: от U+0000
до U+FFFF
(все кодовые точки BMP), поскольку допустимы только 4 знака. В большинстве случаев этого достаточно для представления наиболее распространенных символов.
Чтобы указать астральный символ в строковом литерале JavaScript, нужно использовать две escape-последовательности Unicode (верхний суррогат и нижний суррогат), что создает суррогатную пару:
const str = 'My face \uD83D\uDE00';
console.log(str); // => 'My face 😀'
\uD83D\uDE00
— это суррогатная пара, созданная с использованием двух escape-последовательностей.
Escape-последовательность кодовой точки
В ECMAScript 2015 добавлен новый формат escape-последовательности, который позволяет представлять кодовые точки из всего пространства Unicode: от U+0000
до U+10FFFF
(BMP и астральные плоскости).
Новый формат называется escape-последовательностью кодовой точки (code point escape sequence): \u{<hex>}
, где <hex>
— шестнадцатеричное число переменной длины от 1 до 6 знаков.
Например, '\u{7A}'
(символ 'z'
) или '\u{1F639}'
(символ 😹
).
Рассмотрим примеры использования этих последовательностей:
const str = 'Funny cat \u{1F639}';
console.log(str); // => 'Funny cat 😹'
const reg = /\u{1F639}/u;
console.log(reg.test('Funny cat 😹')); // => true
Регулярное выражение /\u{1F639}/u
включает специальный флаг u
, который активирует дополнительные функции Unicode (подробнее ниже).
Удобно, что escape-последовательность кодовой точки устраняет необходимость использовать суррогатную пару для представления астрального символа. Например, экранируем кодовую точку U+1F607
SMILING FACE WITH HALO:
const niceEmoticon = '\u{1F607}';
console.log(niceEmoticon); // => '😇'
const spNiceEmoticon = '\uD83D\uDE07'
console.log(spNiceEmoticon); // => '😇'
console.log(niceEmoticon === spNiceEmoticon); // => true
Строковый литерал, присвоенный переменной niceEmoticon
, содержит escape-последовательность кодовой точки '\u{1F607}'
, которая представляет астральную кодовую точку U+1F607
.
Однако, при использовании escape-последовательности кодовой точки, создается суррогатная пара из двух кодовых единиц. Переменная spNiceEmoticon
(созданная с использованием суррогатной пары Unicode-экранированных символов '\uD83D\uDE07'
) будет равна niceEmoticon
.
Когда регулярное выражение создается с помощью конструктора RegExp
, необходимо заменить каждый \
на \\
, чтобы указать экранирование в Unicode.
Следующие объекты регулярного выражения эквивалентны:
const reg1 = /\x4A \u0020 \u{1F639}/;
const reg2 = new RegExp('\\x4A \\u0020 \\u{1F639}');
console.log(reg1.source === reg2.source); // => true
3.2 Сравнение строк
Строки в JavaScript — это последовательности кодовых единиц. При сравнении строк можно ожидать сопоставления кодовых единиц: если кодовые единицы обеих строк равны, то считается, что строки равны.
Такой подход быстр и эффективен. Он прекрасно работает с "простыми" строками:
const firstStr = 'hello';
const secondStr = '\u0068ell\u006F';
console.log(firstStr === secondStr); // => true
Строки firstStr
и secondStr
имеют одинаковую последовательность кодовых единиц — они равны.
Однако возникают ситуации, когда нужно сравнить строки, которые при рендеринге выглядят одинаково, но содержат разные последовательности кодовых единиц. В этом случае сравнение может дать неожиданный результат:
const str1 = 'ça va bien';
const str2 = 'c\u0327a va bien';
console.log(str1); // => 'ça va bien'
console.log(str2); // => 'ça va bien'
console.log(str1 === str2); // => false
str1
и str2
визуально выглядят одинаково, но содержат разные кодовые единицы.
Это происходит потому, что графема ç
может быть представлена двумя способами:
- С использованием кодовой единицы
U+00E7
LATIN SMALL LETTER C WITH CEDILLA - С использованием комбинирующей последовательности символов:
U+0063
LATIN SMALL LETTER C плюс комбинирующий знакU+0327
COMBINING CEDILLA.
Для правильного сравнения таких строк рекомендуется использовать нормализацию.
Нормализация
"Нормализация (normalization) приводит все варианты представления символов к одному каноническому виду, что позволяет корректно сравнивать строки независимо от конкретного способа их представления".
Другими словами, когда строка имеет сложную структуру, содержащую комбинирующие последовательности символов или другие составные конструкции, ее можно нормализовать до канонической формы. Нормализованные строки легко сравнивать и выполнять над ними строковые операции, такие как текстовый поиск и др.
Приложение № 15 к стандарту Unicode содержит детальную информацию о нормализации.
В JavaScript для нормализации строки вызывается метод myString.normalize([normForm])
, доступный в ES2015. normForm
является необязательным параметром (по умолчанию 'NFC'
) и может быть одной из следующих форм нормализации:
'NFC'
— каноническая композиция (Normalization Form Canonical Composition)'NFD'
— каноническая декомпозиция (Normalization Form Canonical Decomposition)'NFKC'
— композиция совместимости (Normalization Form Compatibility Composition)'NFKD'
— декомпозиция совместимости (Normalization Form Compatibility Decomposition)
Улучшим предыдущий пример, применив нормализацию строк для их корректного сравнения:
const str1 = 'ça va bien';
const str2 = 'c\u0327a va bien';
console.log(str1.normalize() === str2.normalize()); // => true
console.log(str1 === str2); // => false
При вызове str2.normalize()
возвращается каноническая форма str2
('c\u0327'
заменяется на 'ç'
). Теперь сравнение str1.normalize() === str2.normalize()
возвращает значение true
.
Нормализация не влияет на str1
, поскольку она уже находится в канонической форме.
3.3 Длина строки
Один из распространенных способов определения длины строки — использование свойства myString.length
. Это свойство указывает количество кодовых единиц, содержащихся в строке.
При вычислении длины строки, содержащей кодовые точки из BMP, результат будет ожидаемым:
const color = 'Green';
console.log(color.length); // => 5
Каждая кодовая единица color
соответствует отдельной графеме. Ожидаемая длина строки равна 5
.
Длина строки и суррогатные пары
Когда строка содержит суррогатные пары, представляющие астральные кодовые точки, определение ее длины становится сложным. Поскольку каждая суррогатная пара состоит из 2 кодовых единиц, свойство length
будет иметь большее значение, чем ожидалось.
Рассмотрим пример:
const str = 'cat\u{1F639}';
console.log(str); // => 'cat😹'
console.log(str.length); // => 5
При рендеринге переменной str
мы видим 4 символа cat😹
.
Однако smile.length
вычисляется как 5
, потому что U+1F639
— это астральная кодовая точка, закодированная двумя кодовыми единицами (суррогатной парой).
На данный момент не существует простого и эффективного способа решения этой проблемы.
Однако, в ECMAScript 2015 появились алгоритмы, которые позволяют распознавать астральные символы. Астральный символ считается одним символом, даже если он кодируется с использованием суррогатной пары.
String.prototype[@@iterator]() — это строковый итератор, поддерживающий Unicode. Строку можно преобразовать в массив с помощью spread-оператора [...str]
или функции Array.from(str)
(обе потребляют итератор строки). Затем можно посчитать количество элементов в массиве.
Однако следует отметить, что такое решение может привести к незначительным потерям производительности при интенсивном использовании.
Улучшенный пример с использованием spread-оператора:
const str = 'cat\u{1F639}';
console.log(str); // => 'cat😹'
console.log([...str]); // => ['c', 'a', 't', '😹']
console.log([...str].length); // => 4
[...str]
создает массив из 4 символов/элементов. Суррогатная пара, представляющая U+1F639
CAT FACE WITH TEARS OF JOY 😹, остается нетронутой, так как строковый итератор поддерживает Unicode.
Длина строки и комбинирующие знаки
При комбинировании последовательностей символов, каждый комбинирующий знак считается отдельной кодовой единицей, поэтому здесь могут возникнуть те же трудности, что и с астральными символами.
Проблема решается путем нормализации строки. В лучшем случае комбинирующая последовательность символов будет преобразована в один символ:
const drink = 'cafe\u0301';
console.log(drink); // => 'café'
console.log(drink.length); // => 5
console.log(drink.normalize()) // => 'café'
console.log(drink.normalize().length); // => 4
Строка в переменной drink
содержит 5 кодовых единиц (таким образом, drink.length
равно 5
), но при рендеринге отображается только 4 символа.
При нормализации drink
, комбинирующая последовательность символов 'e\u0301'
имеет каноническую форму 'é'
. Таким образом, drink.normalize().length
содержит ожидаемые 4
символа.
Однако нормализация не всегда является универсальным решением. Проблемы возникают, например, когда имеются длинные комбинирующие последовательности символов, которые не имеют канонических эквивалентов в виде одного символа.
Рассмотрим такой случай:
const drink = 'cafe\u0327\u0301';
console.log(drink); // => 'cafȩ́'
console.log(drink.length); // => 6
console.log(drink.normalize()); // => 'cafȩ́'
console.log(drink.normalize().length); // => 5
drink
содержит 6 кодовых единиц и drink.length
равняется 6
. Однако, при рендеринге drink
имеет только 4 символа.
Функция нормализации drink.normalize()
преобразует комбинирующую последовательность 'e\u0327\u0301'
в каноническую форму из двух символов 'ȩ\u0301'
(удаляя только один комбинирующий знак).
К сожалению, drink.normalize().length
принимает значение 5
и по-прежнему не соответствует визуальному количество символов.
3.4 Позиционирование символов
Поскольку строка представляет собой последовательность кодовых единиц, доступ к символу в строке по индексу может быть проблематичным.
Когда строка содержит только символы BMP, позиционирование символов работает корректно:
const str = 'hello';
console.log(str[0]); // => 'h'
console.log(str[4]); // => 'o'
Каждый символ представлен одной кодовой единицей, и доступ к символу по индексу работает корректно.
Позиционирование символов и суррогатные пары
Все меняется, когда строка содержит астральные символы.
Астральный символ кодируется с использованием 2 кодовых единиц. Из-за этого доступ к символам строки по индексу может вернуть верхний или нижний суррогат, которые сами по себе являются недопустимыми символами.
Рассмотрим пример с астральным символом:
const omega = '\u{1D6C0} is omega';
console.log(omega); // => '𝛀 is omega'
console.log(omega[0]); // => '' (непечатаемый символ)
console.log(omega[1]); // => '' (непечатаемый символ)
Поскольку U+1D6C0
MATHEMATICAL BOLD CAPITAL OMEGA — астральный символ, он кодируется с использованием суррогатной пары из 2 кодовых единиц.
omega[0]
обращается к кодовой единице верхнего суррогата, а omega[1]
— к нижнему суррогату, что в итоге разбивает суррогатную пару.
Существует 2 подхода для правильного доступа к астральным символам в строке:
- Использование строкового итератора, поддерживающего Unicode, и создание массива символов
[...str][index]
- Получение числа кодовой точки с помощью
number = myString.codePointAt(index)
, затем преобразование числа в символ с помощьюString.fromCodePoint(number)
(рекомендуемый вариант).
Рассмотрим оба подхода:
const omega = '\u{1D6C0} is omega';
console.log(omega); // => '𝛀 is omega'
// Вариант 1
console.log([...omega][0]); // => '𝛀'
// Вариант 2
const number = omega.codePointAt(0);
console.log(number.toString(16)); // => '1d6c0'
console.log(String.fromCodePoint(number)); // => '𝛀'
[...omega]
возвращает массив символов, содержащихся в строке omega
. Суррогатные пары вычисляются правильно, поэтому доступ к первому символу работает ожидаемым образом. [...smile][0]
— это '𝛀'
.
Вызов omega.codePointAt(0)
поддерживает Unicode, поэтому он возвращает число астральной кодовой точки 0x1D6C0
, соответствующее первому символу в строке omega
. Функция String.fromCodePoint(number)
преобразует это число в символ '𝛀'
.
Прим. пер.: обратите внимание, что omega.codePointAt(1)
будет работать некорректно, поскольку в методе codePointAt индексация выполняется по кодовым единицам UTF-16, а не по кодовым точкам Юникода. В свою очередь, [...omega][1]
работает правильно и возвращает пробел.
Позиционирование символов и комбинирующие знаки
Проблема позиционирования символов с комбинирующими знаками аналогична проблеме с длиной строки, о которой мы говорили выше.
Доступ к символам по индексу в строке — это доступ к кодовым единицам. Однако при работе с последовательностью комбинирующих знаков, нужно обращаться к ней как к целому, не разделяя ее на отдельные кодовые единицы.
Рассмотрим пример:
const drink = 'cafe\u0301';
console.log(drink); // => 'café'
console.log(drink.length); // => 5
console.log(drink[3]); // => 'e'
console.log(drink[4]); // => ◌́
drink[3]
обращается только к основному символу e
, без комбинирующего знака U+0301
COMBINING ACUTE ACCENT (отображается как ◌́
). drink[4]
обращается к изолированному комбинирующему знаку ◌́
.
В таких случаях следует применять нормализацию строки. Комбинирующая последовательность символов U+0065
LATIN SMALL LETTER E + U+0301
, COMBINING ACUTE ACCENT, может быть заменена на один символ U+00E9
LATIN SMALL LETTER E WITH ACUTE é
.
Улучшим предыдущий пример:
const drink = 'cafe\u0301';
console.log(drink.normalize()); // => 'café'
console.log(drink.normalize().length); // => 4
console.log(drink.normalize()[3]); // => 'é'
Однако стоит отметить, что не все комбинирующие последовательности символов имеют канонические эквиваленты в виде одного символа. Поэтому решение с нормализацией не универсально.
Тем не менее, в большинстве случаев для языков Европы и Северной Америки такое решение работает.
3.5 Соответствие регулярных выражений
Регулярные выражения работают с кодовыми единицами, так же как и строки. Поэтому возникают сложности при обработке суррогатных пар и объединении последовательностей символов с использованием регулярных выражений.
Символы BMP обрабатываются ожидаемо, поскольку каждый символ представлен одной кодовой единицей:
const greetings = 'Hi!';
const regex = /^.{3}$/;
console.log(regex.test(greetings)); // => true
greetings
содержит 3 символа, закодированных 3 кодовыми единицами. Регулярное выражение /.{3}/
, которое ожидает 3 кодовых единицы, совпадает с greetings
.
Однако, при обработке астральных символов (закодированных суррогатными парами) возникают сложности:
const smile = '😀';
const regex = /^.$/;
console.log(regex.test(smile)); // => false
smile
представлен астральным символом U+1F600
GRINNING FACE, который закодирован суррогатной парой 0xD83D
+0xDE00
.
Однако, регулярное выражение /^.$/
, ожидающее одну кодовую единицу, не совпадает со строкой smile
. Это приводит к провалу проверки с помощью regexp.test(smile)
— возвращается false
.
Еще более сложная ситуация возникает при определении классов символов для астральных плоскостей. JavaScript выбрасывает исключение:
const regex = /[😀-😎]/;
// => SyntaxError: Invalid regular expression: /[😀-😎]/:
// Range out of order in character class
Поскольку астральные кодовые точки кодируются в суррогатные пары, JavaScript представляет регулярное выражение, используя кодовые единицы /[\uD83D\uDE00-\uD83D\uDE0E]/
. Каждая кодовая единица рассматривается как отдельный элемент, поэтому регулярное выражение игнорирует концепцию суррогатной пары.
В данном случае, часть \uDE00-\uD83D
класса символов недопустима, так как \uDE00
больше, чем \uD83D
. В результате, возникает ошибка.
Флаг u
в регулярных выражениях
С появлением флага u
в ECMAScript 2015 регулярные выражения стали совместимыми с Unicode. Этот флаг позволяет корректно обрабатывать астральные символы.
Вы можете использовать escape-последовательности Unicode внутри регулярных выражений с помощью /u{1F600}/u
. Эта escape-последовательность короче, чем указание пары с верхним и нижним суррогатами /\uD83D\uDE00/
.
Применим флаг u
и посмотрим, как оператор .
(включая квантификаторы (quantifiers) ?
, +
, *
и {3}
, {3,}
, {2,3}
) соответствует астральному символу:
const smile = '😀';
const regex = /^.$/u;
console.log(regex.test(smile)); // => true
Теперь регулярное выражение /^.$/u
, поддерживающее Unicode благодаря флагу u
, будет соответствовать астральному символу 😀
.
Флаг u
также позволяет корректно обрабатывать астральные символы внутри классов:
const smile = '😀';
const regex = /[😀-😎]/u;
const regexEscape = /[\u{1F600}-\u{1F60E}]/u;
const regexSpEscape = /[\uD83D\uDE00-\uD83D\uDE0E]/u;
console.log(regex.test(smile)); // => true
console.log(regexEscape.test(smile)); // => true
console.log(regexSpEscape.test(smile)); // => true
Теперь [😀-😎]
будет восприниматься как диапазон астральных символов. /[😀-😎]/u
будет совпадать с символом '😀'
.
Регулярные выражения и комбинирующие знаки
К сожалению, с флагом u
или без него, регулярное выражение обрабатывает комбинирующие знаки как отдельные кодовые единицы.
Если необходимо сопоставить комбинирующую последовательность символов, нужно сопоставить основной символ и комбинирующий знак по-отдельности.
Рассмотрим следующий пример:
const drink = 'cafe\u0301';
const regex1 = /^.{4}$/;
const regex2 = /^.{5}$/;
console.log(drink); // => 'café'
console.log(regex1.test(drink)); // => false
console.log(regex2.test(drink)); // => true
Отображаемая строка содержит 4 символа café
.
Тем не менее, регулярное выражение /^.{5}$/
соответствует строке 'cafe\u0301'
как последовательности из 5 элементов.
4. Заключение
Одна из наиболее важных концепций Unicode в JavaScript заключается в том, чтобы рассматривать строки как последовательности кодовых единиц, а не графем или символов, как часто делают разработчики.
Это может вызвать путаницу при работе со строками, содержащими суррогатные пары или комбинирующие символы. Можно столкнуться с такими трудностями при:
- Определении длины строки
- Позиционировании символов
- Сопоставлении регулярных выражений
Важно отметить, что большинство методов строк в JavaScript не поддерживают Unicode: например, myString.indexOf()
, myString.slice()
и др.
Однако, в ECMAScript 2015 появились удобные возможности, такие как escape-последовательности кодовых точек \u{1F600}
в строках и регулярных выражениях.
Флаг регулярного выражения u
позволяет сопоставлять строки с поддержкой Unicode, упрощая сопоставление астральных символов.
Итератор строк String.prototype[@@iterator]()
поддерживает Unicode. Таким образом, можно использовать spread-оператор [...str]
или метод Array.from(str)
для создания массива символов и выполнения операций, таких как определение длины строки или доступ к символам по индексу, не разбивая суррогатные пары. Однако стоит отметить, что эти операции могут негативно сказаться на производительности.
Если вам требуется более продвинутая обработка символов Unicode, вы можете использовать библиотеку punycode или создать специализированные регулярные выражения.
Я надеюсь, что данная статья помогла вам лучше разобраться с Unicode!
Парочка статей для тех, кому хочется больше Юникода:
- Абсолютный минимум об Unicode на 2023 год (всё ещё — никаких оправданий!)
- 1. Введение в Unicode (опять?)