Эмодзи в JavaScript представляют собой Unicode-символы, многие из которых занимают больше одного 16-битного элемента строки. Из-за этого работа с ними отличается от обработки обычного текста.
Строки в JavaScript основаны на UTF-16. Символы из базовой многоязычной плоскости (BMP) занимают один кодовый элемент, а большинство эмодзи — два.
const smile = "?";
console.log(smile.length); // 2
Хотя визуально это один символ, длина строки равна двум, поскольку эмодзи кодируется суррогатной парой.
Получение кодовых точек:
const smile = "?";
console.log(smile.codePointAt(0).toString(16));
// 1f600
Создание эмодзи по кодовой точке:
const emoji = String.fromCodePoint(0x1F600);
console.log(emoji); // ?
Классические методы работы со строками могут ломать эмодзи.
const emoji = "?";
console.log(emoji[0]); // �
console.log(emoji[1]); // �
Каждый индекс возвращает половину суррогатной пары.
Современный JavaScript умеет итерироваться по Unicode-символам:
const text = "A?B";
for (const char of text) {
console.log(char);
}
Результат:
A
?
B
Проблема усложняется тем, что многие эмодзи состоят не из одной кодовой точки, а из нескольких.
Например:
????
Это семейный эмодзи, собранный из нескольких символов и специальных соединителей.
Обычная итерация покажет несколько отдельных элементов:
const family = "????";
console.log([...family]);
Результат:
[
"?",
"",
"?",
"",
"?",
"",
"?"
]
С точки зрения пользователя это один символ, но технически — последовательность.
Для правильной работы используется Intl.Segmenter.
Intl.Segmenter предназначен для сегментации текста:
Графема — минимальный визуально воспринимаемый символ.
const segmenter = new Intl.Segmenter("ru", {
granularity: "grapheme"
});
const text = "A????B";
const segmenter = new Intl.Segmenter("ru", {
granularity: "grapheme"
});
const segments = [...segmenter.segment(text)];
for (const item of segments) {
console.log(item.segment);
}
Результат:
A
????
B
Теперь семейный эмодзи воспринимается как единый символ.
Каждый сегмент содержит дополнительную информацию.
const segmenter = new Intl.Segmenter("ru", {
granularity: "grapheme"
});
const segments = segmenter.segment("??");
for (const item of segments) {
console.log(item);
}
Пример структуры:
{
segment: "?",
index: 0,
input: "??"
}
Поля:
| Свойство | Описание |
|---|---|
segment |
Текущий сегмент |
index |
Позиция в строке |
input |
Исходная строка |
Обычный length часто бесполезен для Unicode.
const text = "???";
console.log(text.length); // 6
const segmenter = new Intl.Segmenter("ru", {
granularity: "grapheme"
});
const count = [...segmenter.segment("???")].length;
console.log(count); // 3
Unicode поддерживает комбинируемые диакритические знаки.
Пример:
é
Визуально это один символ, но технически:
const text = "e\u0301";
console.log(text);
console.log(text.length); // 2
Intl.Segmenter воспринимает комбинацию как одну
графему.
const segmenter = new Intl.Segmenter("ru", {
granularity: "grapheme"
});
const segments = [...segmenter.segment("e\u0301")];
console.log(segments.length); // 1
Многие сложные эмодзи строятся с помощью специального символа:
U+200D
Он называется Zero Width Joiner.
Пример:
? + ❤️ + ?
После соединения:
?❤️?
Проверка состава:
const emoji = "?❤️?";
console.log([...emoji]);
Результат:
[
"?",
"",
"❤",
"️",
"",
"?"
]
Некоторые символы могут отображаться как текст или как эмодзи.
Например:
♥
Для принудительного emoji-режима используется Variation Selector:
U+FE0F
Пример:
const textHeart = "♥";
const emojiHeart = "♥️";
console.log(textHeart);
console.log(emojiHeart);
Unicode поддерживает модификаторы внешности.
Пример:
??
Это комбинация:
Разбор:
const emoji = "??";
console.log([...emoji]);
Результат:
["?", "?"]
Но Intl.Segmenter воспринимает комбинацию как один
символ.
const segmenter = new Intl.Segmenter("ru", {
granularity: "grapheme"
});
const result = [...segmenter.segment("??")];
console.log(result.length); // 1
Флаги тоже являются составными символами.
Например:
??
Формируются из двух региональных индикаторов.
Проверка:
const flag = "??";
console.log([...flag]);
Результат:
["?", "?"]
С точки зрения Unicode это два символа, но визуально — один флаг.
Intl.Segmenter решает проблему:
const segmenter = new Intl.Segmenter("ru", {
granularity: "grapheme"
});
console.log(
[...segmenter.segment("??")].length
); // 1
Важно различать:
| Понятие | Описание |
|---|---|
| Code Unit | 16-битный элемент UTF-16 |
| Code Point | Unicode-символ |
| Grapheme | Визуальный пользовательский символ |
Пример:
????
| Уровень | Количество |
|---|---|
| UTF-16 code units | 11 |
| Code points | 7 |
| Graphemes | 1 |
Один и тот же символ может быть представлен несколькими способами.
Пример:
const a = "é";
const b = "e\u0301";
console.log(a === b); // false
Нормализация приводит строки к единому виду.
console.log(
a.normalize("NFC") === b.normalize("NFC")
); // true
Форматы нормализации:
| Формат | Описание |
|---|---|
NFC |
Каноническая композиция |
NFD |
Каноническое разложение |
NFKC |
Совместимая композиция |
NFKD |
Совместимое разложение |
Обычное slice() может разорвать эмодзи.
const text = "???";
console.log(text.slice(0, 2));
Результат может содержать повреждённый символ.
function safeSlice(str, count) {
const segmenter = new Intl.Segmenter("ru", {
granularity: "grapheme"
});
const segments = [...segmenter.segment(str)];
return segments
.slice(0, count)
.map(item => item.segment)
.join("");
}
console.log(
safeSlice("???", 2)
);
function countEmoji(str) {
const segmenter = new Intl.Segmenter("ru", {
granularity: "grapheme"
});
return [...segmenter.segment(str)].length;
}
console.log(
countEmoji("?????")
);
Современный JavaScript поддерживает Unicode Property Escapes.
const text = "Привет ? ?";
const cleaned = text.replace(/\p{Emoji}/gu, "");
console.log(cleaned);
function containsEmoji(str) {
return /\p{Emoji}/u.test(str);
}
console.log(
containsEmoji("Hello ?")
); // true
Поддерживаются различные Unicode-категории.
Пример:
/\p{Letter}/u
/\p{Number}/u
/\p{Emoji}/u
Проверка:
const regex = /\p{Letter}/u;
console.log(regex.test("A")); // true
console.log(regex.test("?")); // false
Хотя эмодзи являются Unicode-символами, Intl помогает
правильно работать с текстом разных языков и культур.
Intl.Segmenter особенно важен для:
Создание Intl.Segmenter — относительно дорогая
операция.
Плохой вариант:
function splitText(str) {
const segmenter = new Intl.Segmenter("ru", {
granularity: "grapheme"
});
return [...segmenter.segment(str)];
}
Лучше переиспользовать экземпляр:
const segmenter = new Intl.Segmenter("ru", {
granularity: "grapheme"
});
function splitText(str) {
return [...segmenter.segment(str)];
}
Intl.Segmenter поддерживается:
Проверка поддержки:
if (Intl.Segmenter) {
console.log("Segmenter supported");
}
const segmenter = new Intl.Segmenter("ru", {
granularity: "grapheme"
});
function limitNickname(name, maxLength) {
const graphemes = [...segmenter.segment(name)];
return graphemes
.slice(0, maxLength)
.map(item => item.segment)
.join("");
}
console.log(
limitNickname("?????", 3)
);
Текстовые редакторы должны удалять графемы, а не UTF-16 элементы.
Неправильное удаление:
const text = "??";
console.log(text.slice(0, -1));
Результат может повредить символ.
Правильный вариант:
function removeLastGrapheme(str) {
const segmenter = new Intl.Segmenter("ru", {
granularity: "grapheme"
});
const segments = [...segmenter.segment(str)];
segments.pop();
return segments
.map(item => item.segment)
.join("");
}
console.log(
removeLastGrapheme("???")
);