Строки, 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+ и во всех современных браузерах.
Это правильный способ считать “длину для пользователя”, резать текст
по словам и предложениям.
str.length | 0 | UTF-16 code units |
|---|---|---|
[...str].length | 0 | Unicode code points |
Intl.Segmenter | 0 | пользовательские символы |
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.