Раздел 3 · JavaScript

Строки, regex, Unicode, сериализация

middle~45 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Строки, regex, Unicode, сериализация

Строки в JavaScript выглядят такой же простой штукой, как в Python или Java, ровно до момента, когда в них прилетает emoji или польская “ó”. Тогда str.length начинает врать (на эмодзи возвращает 2, а не 1), for (const c of str) ведёт себя иначе, чем for (let i = 0; i < str.length; i += 1), аккуратный regex вдруг находит совпадение там, где не должен, а сравнение 'ё' < 'й' не сходится с тем, как эти буквы стоят в алфавите.

Причина в том, что строка в JS это последовательность 16-битных кодовых единиц UTF-16, а человек считает по графемам. Большую часть времени эти две модели совпадают, но как только речь заходит о международном тексте, они расходятся, и расхождение нужно понимать сознательно, а не отлавливать постфактум по жалобам пользователей.

Этот урок проходит по теме слой за слоем. Что такое строка в памяти и какие операции на ней действуют. Чем шаблонная строка отличается от обычной и зачем нужен tagged template. Что умеют Intl.Segmenter, Intl.Collator и Intl.NumberFormat. Как устроен regex, включая флаги u и v, lookahead и backreference. И как сериализовать данные в JSON, не теряя по дороге BigInt, Date и Map. Без этого слоя сложно работать с любыми пользовательскими данными в реальном продукте.

1. Строки и шаблоны

Три формы строкового литерала:

'single';
"double";
`template`;

Первые две, обычные строки. Третья, шаблонная строка:

const name = 'Bat';
const greeting = `Hi, ${name}.
This is line 2.`;

${expr} подставляет результат любого выражения. Перевод строки внутри backtick включается в строку дословно. Это убрало необходимость в конкатенации + и в \n-эскейпах.

1.1. Tagged templates

Шаблонная строка может быть вызвана как функция:

const tag = (strings, ...values) => {
  // strings: ['Hi, ', '. You are ', '.']
  // values:  ['Bat', 25]
  return strings.reduce((acc, s, i) => acc + s + (values[i] ?? ''), '');
};

tag`Hi, ${'Bat'}. You are ${25}.`;
// 'Hi, Bat. You are 25.'

tag, обычная функция. Она получает массив “статичных кусков” и массив “значений” отдельно, и сама решает, что с ними делать. Применения:

  • безопасное HTML/SQL (значения прогоняются через escape отдельно от разметки);
  • styled-components: styled.div`color: ${(p) => p.color}`;
  • i18n: t`Hello, ${user.name}` ищет ключ “Hello, %s” в словаре.

В новом коде tagged templates чаще всего используют через библиотеки (graphql, styled-components, lit-html), реже руками.

1.2. Базовые методы

const s = '  Hello, World  ';

s.length;                          // 16
s.trim();                          // 'Hello, World'
s.trimStart();                     // 'Hello, World  '
s.trimEnd();                       // '  Hello, World'

s.toLowerCase();                   // '  hello, world  '
s.toUpperCase();                   // '  HELLO, WORLD  '

s.includes('World');               // true
s.startsWith('  H');                // true
s.endsWith('  ');                   // true

s.indexOf('o');                    // 5
s.lastIndexOf('o');                // 9
s.replaceAll('o', '0');            // '  Hell0, W0rld  '

'ab'.repeat(3);                    // 'ababab'
'5'.padStart(3, '0');              // '005'
'42'.padEnd(5, '.');               // '42...'
'abcde'.at(-1);                    // 'e'

s.split(',');                      // ['  Hello', ' World  ']
s.slice(2, 7);                     // 'Hello'

replaceAll появился в ES2021 (заменил идиому s.split('x').join('y')). До него replace без флага /g заменяет только первое вхождение.

2. UTF-16 в JavaScript

Здесь начинается боль. Строка в JS, последовательность 16-битных code units. Большинство латиницы и кириллицы, по одному code unit на символ. Emoji, астрономические символы, флаги стран, два или больше.

'a'.length;        // 1
'ё'.length;        // 1
'😀'.length;       // 2  (surrogate pair)
'🇱🇹'.length;       // 4  (два regional indicators по 2 code units каждый)
'👨‍👩‍👧'.length;     // 8  (три emoji + два ZWJ)

str.length врёт. Поэтому ни в коем случае не пиши:

str.slice(0, 10);    // может разрезать surrogate pair, получишь "сломанный" emoji
str.length > 280;    // в Twitter не так считают

2.1. Code points

String.prototype итерируется по code points, не по code units:

'😀'.length;            // 2
[...'😀'].length;       // 1, итерация по code points

for (const ch of '😀') console.log(ch);   // '😀' (одно значение)

'😀'.codePointAt(0);    // 128512
String.fromCodePoint(128512); // '😀'

Старые методы (charAt, charCodeAt) работают с code units и оставлены для совместимости. Используй codePointAt и for...of для итерации.

2.2. Graphemes

Code point всё ещё не “видимый символ”. 🇱🇹, два code points (regional indicator L + T), но визуально один флаг. 👨‍👩‍👧, три emoji плюс два zero-width joiner (ZWJ), а в браузере одна “семья”. Это graphemes.

Стандартный способ считать видимые символы, Intl.Segmenter:

const seg = new Intl.Segmenter('en', { granularity: 'grapheme' });
[...seg.segment('🇱🇹')].length;    // 1
[...seg.segment('👨‍👩‍👧')].length; // 1
[...seg.segment('hello')].length; // 5

Intl.Segmenter доступен в Node 16+ и во всех современных браузерах. Это правильный способ считать “длину для пользователя”, резать текст по словам и предложениям.

Grapheme inspector: введи строку или возьми пресет
graphemes0
code points0
UTF-16 code units0
str.length0UTF-16 code units
[...str].length0Unicode code points
Intl.Segmenter0пользовательские символы

str.length в JS считает UTF-16 code units, не символы. [...str] и for...of идут по code points (учитывают пары суррогатов). Графемы режутся только Intl.Segmenter: 🇱🇹 это две регионарные индикаторы, склеенные в один флаг; 👨‍👩‍👧 это три эмодзи через ZWJ. Наведи на бокс графемы, увидишь, какие code points и code units её составляют.

2.3. Normalization

Один и тот же визуальный символ можно записать двумя способами:

const a = 'é';                          // один code point U+00E9
const b = 'é';               // 'e' + combining acute U+0301 = 'é'

a === b;                                // false
a.length;                               // 1
b.length;                               // 2

a.normalize('NFC') === b.normalize('NFC'); // true

NFC, Normalization Form Composed: схлопывает базовый + диакритику в один code point. NFD, обратная (декомпозиция). NFKC/NFKD, “compatibility” формы (объединяют визуально похожие символы, для поиска).

Правило: если сравниваешь строки от пользователей разных платформ (macOS любит NFD, Linux NFC), нормализуй обе через NFC.

Нормализация чаще всего нужна не сама по себе, а внутри ключа поиска: строки от разных пользователей надо свести к одному виду. Собери такой ключ в редакторе ниже, тесты гоняются прямо в браузере.

3. Intl

Intl, стандартный API для всего, что зависит от локали. Уже видели Intl.Segmenter, ещё:

// сортировка с учётом локали
const sort = new Intl.Collator('lt').compare;
['ą', 'b', 'a', 'č'].sort(sort);  // ['a', 'ą', 'b', 'č']

// форматирование чисел
new Intl.NumberFormat('de-DE').format(1234567.89);             // '1.234.567,89'
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' }).format(99.5);
// '€99.50'

// форматирование дат
new Intl.DateTimeFormat('ru-RU', { dateStyle: 'long' }).format(new Date());
// '6 мая 2026 г.'

// относительное время
new Intl.RelativeTimeFormat('en').format(-3, 'day');   // '3 days ago'

// разбиение на слова
const words = new Intl.Segmenter('en', { granularity: 'word' });
[...words.segment('Hello, World!')].filter((s) => s.isWordLike).map((s) => s.segment);
// ['Hello', 'World']

// форматирование списков
new Intl.ListFormat('en', { type: 'conjunction' }).format(['a', 'b', 'c']);
// 'a, b, and c'

Раньше для всего этого тащили moment, numeral, intl-messageformat. Теперь, нативный API без зависимостей. Учти, доступность форматов зависит от ICU-сборки Node (полная сборка, по умолчанию с Node 14+).

дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.