Эмодзи и составные символы

Эмодзи в 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

Intl.Segmenter и составные символы

Проблема усложняется тем, что многие эмодзи состоят не из одной кодовой точки, а из нескольких.

Например:

?‍?‍?‍?

Это семейный эмодзи, собранный из нескольких символов и специальных соединителей.

Обычная итерация покажет несколько отдельных элементов:

const family = "?‍?‍?‍?";

console.log([...family]);

Результат:

[
  "?",
  "‍",
  "?",
  "‍",
  "?",
  "‍",
  "?"
]

С точки зрения пользователя это один символ, но технически — последовательность.

Для правильной работы используется Intl.Segmenter.


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

Правильный вариант через Segmenter

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

Zero Width Joiner (ZWJ)

Многие сложные эмодзи строятся с помощью специального символа:

U+200D

Он называется Zero Width Joiner.

Пример:

? + ❤️ + ?

После соединения:

?‍❤️‍?

Проверка состава:

const emoji = "?‍❤️‍?";

console.log([...emoji]);

Результат:

[
  "?",
  "‍",
  "❤",
  "️",
  "‍",
  "?"
]

Variation Selector

Некоторые символы могут отображаться как текст или как эмодзи.

Например:

Для принудительного 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 point и grapheme

Важно различать:

Понятие Описание
Code Unit 16-битный элемент UTF-16
Code Point Unicode-символ
Grapheme Визуальный пользовательский символ

Пример:

?‍?‍?‍?
Уровень Количество
UTF-16 code units 11
Code points 7
Graphemes 1

Нормализация Unicode

Один и тот же символ может быть представлен несколькими способами.

Пример:

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 Property Escapes

Поддерживаются различные 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

Intl и интернационализация эмодзи

Хотя эмодзи являются Unicode-символами, Intl помогает правильно работать с текстом разных языков и культур.

Intl.Segmenter особенно важен для:

  • азиатских языков;
  • комбинируемых символов;
  • emoji-последовательностей;
  • пользовательских интерфейсов;
  • редакторов текста;
  • систем подсчёта символов.

Производительность 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 поддерживается:

  • современными браузерами;
  • Node.js 16+;
  • Deno;
  • Bun.

Проверка поддержки:

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("???")
);