Привет, Хабр!
Сегодня рассмотрим флаг регулярных выражений v в JavaScript. Флаг поддержан в современных движках и Node 20+, а для старых окружений есть транспиляция через Babel. Начнём с краткой ориентации где это уже работает и почему синтаксис отличается, а потом пойдём в практику.
Что такое v и почему это не просто u++
Флаг v включает режим unicodeSets. Это отдельный вариант интерпретации шаблона: u и v нельзя смешивать одновременно в одном регексе. В v режиме доступны:
свойства строк Юникода через \p{…}, т.е совпадения могут быть не только одиночными кодовыми точками, но и последовательностями;
расширенная запись символьных классов с вложенностью и операциями пересечения и вычитания;
исправленная логика для комплементарных классов с флагом i.
Поддержка по браузерам и Node стабильна: Chrome с 112, Firefox с 116, Safari с 17, Node начиная с 20. Для лего-совместимости в сборке есть плагин @babel/plugin-transform-unicode-sets-regex, он уже входит в preset-env и переписывает v в эквивалент под u, насколько это возможно.
Коротко про синтаксис: классы, пересечения, вычитания
В v режиме можно писать внутри одного символьного класса выражения-множества:
Пересечение:
&&
Вычитание:
--
Юнион: просто перечисление без оператора
Вложенность: разрешена, чтобы группировать операнды
Нельзя на одном уровне смешивать &&
и --
— группируйте вложенными [...]
. И помните: некоторые символы внутри v-классов нельзя ставить как есть из-за конфликта с двойными пунктуаторами, иначе будет SyntaxError.
Комплементарный класс [^…]
в v — это комплемент множества, а не отрицание результата, благодаря чему поведение с флагом i становится ожидаемым и согласованным с \P{…}
.
Далее смотрим что там с кодом.
Пересечение: фильтруем только греческие буквы, а не знаки
// Пересечение Script_Extensions=Greek с Letter
const reGreekLetters = /[\p{Script_Extensions=Greek}&&\p{Letter}]/v;
reGreekLetters.test('π'); // true
reGreekLetters.test(' '); // false (это OGHAM SPACE MARK)
reGreekLetters.test('ᾀ'); // true (греческая буква с диакритикой)
Почему именно Script_Extensions
, а не Script
: первый включает символы, которые принадлежат нескольким скриптам, и его чаще ожидают в валидациях. С пересечением выражаем это без lookahead и без огромных перечислений диапазонов.
Вычитание: все десятичные цифры, кроме ASCII
// Совпадает с любой «десятичной цифрой» Юникода, кроме ASCII 0-9
const reNonAsciiDigit = /[\p{Decimal_Number}--[0-9]]/v;
reNonAsciiDigit.test('٤'); // true (арабско-индийская цифра 4)
reNonAsciiDigit.test('4'); // false
Зачем это: при нормализации пользовательского ввода можно быстро найти все не-ASCII цифры и либо отклонить, либо преобразовать. Это адекватнее, чем пытаться вручную перечислять блоки.
Теперь сделаем функцию нормализации строки с заменой любых десятичных цифр Юникода на ASCII. В JS нет готового API, которое вернёт цифровое значение символа из Юникода, поэтому используем известные диапазоны десятичных цифр.
// Преобразует все десятичные цифры Юникода к ASCII 0-9
export function normalizeDecimalDigits(input) {
// Быстрая проверка: есть ли вообще не-ASCII цифры
if (!/[\p{Decimal_Number}--[0-9]]/v.test(input)) return input;
// Поддержанные диапазоны «нулей» для Decimal_Number
// (добавляйте при необходимости — шаблон легко расширяется)
const zeros = [
0x0660, // Arabic-Indic
0x06F0, // Extended Arabic-Indic
0x07C0, // N'Ko
0x0966, // Devanagari
0x09E6, // Bengali
0x0A66, // Gurmukhi
0x0AE6, // Gujarati
0x0B66, // Oriya
0x0BE6, // Tamil
0x0C66, // Telugu
0x0CE6, // Kannada
0x0D66, // Malayalam
0x0E50, // Thai
0x0ED0, // Lao
0x0F20, // Tibetan
0x1040, // Myanmar
0x17E0, // Khmer
0x1810, // Mongolian
0xFF10 // Fullwidth
];
const mapDigit = (cp) => {
for (const z of zeros) {
const delta = cp - z;
if (delta >= 0 && delta <= 9) return String.fromCharCode(0x30 + delta);
}
return null; // не цифра из поддержанных диапазонов
};
let out = "";
for (let i = 0; i < input.length; ) {
const cp = input.codePointAt(i);
const repl = mapDigit(cp);
out += repl ?? String.fromCodePoint(cp);
i += cp > 0xFFFF ? 2 : 1;
}
return out;
}
// Пример
// "٠١٢٣٤5٦789" => "0123456789"
Регексп не делает замену сам, у него задача выделить класс. Диапазоны взяты из стандартных блоков цифр Юникода — список легко проверить в спецификациях Юникода и адаптировать под свои регионы.
Свойства строк: наконец-то совпадения длиннее одной точки кода
С \p{…}
в режиме u вы уже могли обращаться к свойствам символов. В режиме v те же \p{…}
могут ссылаться на свойства строк. Сейчас это в первую очередь RGI-эмодзи: корректные последовательности с модификаторами, вариационными селекторами, ZWJ и флагами. Шаблон ^\p{RGI_Emoji}$
в v режиме совпадает и с одиночным эмодзи, и с составными последовательностями.
// Ровно один RGI-эмодзи (символ или валидная последовательность)
const reEmoji = /^\p{RGI_Emoji}$/v;
reEmoji.test('⚽'); // true
reEmoji.test('??⚕️'); // true
reEmoji.test('?'); // true
reEmoji.test('A'); // false
Плюс доступен литерал строк внутри класса: \q{…}
. Это даёт возможность делать операции множеств и со строками, не только с одиночными символами:
// Исключим строго один конкретный эмодзи-паттерн из множества RGI
// \q{...} — литерал строки в классе. Можно перечислять через |
const reEmojiExceptEngland = /^[\p{RGI_Emoji_Tag_Sequence}--\q{}]$/v;
reEmojiExceptEngland.test(''); // true — любой другой теговый флаг
reEmojiExceptEngland.test(''); // false — именно England
Список поддержанных свойств строк в спецификации включает RGI_Emoji и его подтипы для кейкапов, флагов и ZWJ-последовательностей. Идея в том, что движок разворачивает свойство в набор альтернатив, упорядоченных от длинных к коротким, чтобы префиксы не съедали более длинные варианты.
Кейсы
1) Валидация логина по правилам: латиница, кириллица, цифры, дефис, без подчёркиваний, длина 3–24
// Разрешаем буквы и цифры любых скриптов ИЛИ дефис.
// Для строгой ASCII-версии пересекаем с \p{ASCII}.
const ALLOWED = /^(?:[\p{Letter}\p{Number}-]{3,24})$/v;
export function isValidLogin(s) {
// Дополнительно запретим ведущий/хвостовой дефис и подряд двойные дефисы
if (!ALLOWED.test(s)) return false;
if (/^-|-$|--/.test(s)) return false;
return true;
}
Если нужна строгая ASCII-версия, замените класс на [\p{Letter}&&\p{ASCII}\p{Number}&&\p{ASCII}-]
с правильной группировкой. Это наглядней, чем ручные диапазоны.
2)Нормализуем пробелы: только ASCII-пробелы, все остальное — в обычный пробел
const reAsciiWhitespace = /[\p{White_Space}&&\p{ASCII}]+/v;
const reAnyWhitespace = /\p{White_Space}+/v;
export function squeezeSpaces(s) {
// Сначала приводим все виды whitespace к пробелу
const step1 = s.replace(reAnyWhitespace, ' ');
// Затем ужимаем группы ASCII-пробелов и тримим
return step1.replace(reAsciiWhitespace, ' ').trim();
}
Подход изолирует ASCII-пробелы от остальных и не трогает нестандартные разделители, если это важно для доменной логики.
3) Анти-подмена цифр: ищем наличие не-ASCII десятичных цифр
// Быстрый чек перед парсингом цены/количества
export function hasNonAsciiDecimalDigits(s) {
return /[\p{Decimal_Number}--[0-9]]/v.test(s);
}
Сценарий встречается в платежных формах и админках: не все пользователи вводят латинские цифры. Выявляем и показываем понятную подсказку, а не неверный формат.
4) Подсчёт эмодзи-токенов в сообщении
// Матчим только RGI-эмодзи, без прочих символов
const reEmojiToken = /\p{RGI_Emoji}/gv;
export function countEmojis(s) {
let c = 0;
for (const _ of s.matchAll(reEmojiToken)) c++;
return c;
}
Свойства строк в v режиме дают гранично точное совпадение RGI-эмодзи, включая флаги и ZWJ-последовательности. В u это приходилось собирать вручную через альтернативы.
Нюансы
Нельзя смешивать операторы на одном уровне. Пишите так: [\p{L}&&[\p{Greek}--[α-ω]] ]
, а не [\p{L}&&\p{Greek}--[α-ω]]
. Иначе SyntaxError.
Экранируйте «двойные пунктуаторы» в классах. В v режиме некоторые символы внутри классов не могут стоять буквально — в частности, последовательности, похожие на --
и &&
. Ошибка диагностируется как invalid character in class. Экранируйте или разбивайте класс.
\P{…}
и свойства строк. В v \p{…}
может описывать свойство строки, а \P{…}
— только комплемент к свойству символов. Для отрицания свойства строки применяйте вычитание или комплементарный класс
Флаг i и комплемент. В v [^\p{X}]
, \P{X}
и [\P{X}]
эквивалентны, поведение стабильно и совпадает по смыслу. В u так не было.
HTML pattern и неожиданная синтаксическая ошибка. Если у вас внезапно сломался клиентский паттерн в форме — проверьте, не компилирует ли браузер его в v-режиме.
Рекомендации по стилю написания регексов с v
Для сложных правил всегда выражайте смысл через множества. Если нужна «латиница без подчёркивания», пишите [\p{Letter}&&\p{ASCII}--[_]]
.
Для работы с эмодзи используйте только \p{RGI_Emoji}
и подсемейства.
Валидации форм в HTML проверяйте в реальных браузерах. Blink компилирует pattern
как v, что отличается от старой логики u.
При транспиляции следите за размером паттернов после развёртки свойств строк. Babel-плагин сделает всё корректно, но итоговый класс может стать крупным.
Поддержка уже есть в актуальных браузерах и Node, транспиляция доступна из коробки. Если вы давно хотели навести порядок в своих регекспах, v — удобный момент заняться этим.
Если вам близка идея «управлять кодом, а не костылями», то, вероятно, есть и другой пробел — базовые интерфейсы. Удивительно, но даже опытные разработчики нередко тратят часы на формы, карточки и интерактивность, решая задачи «в лоб». Приглашаем на серию бесплатных уроков, где разбирем это без магии фреймворков, только на чистом HTML, CSS и JavaScript:
2 сентября, 20:00 — Первый сайт за 60 минут: без ChatGPT и конструкторов
9 сентября, 20:00 — Создадим красивую карточку товара — дизайн и верстка на чистом HTML и CSS
22 сентября, 20:00 — Логика интерфейса: как JavaScript оживляет формы
Актуальный стек технологий для решения задач фронтенда на junior+ уровне можно изучить под руководством экспертов на курсе "JavaScript Developer. Basic".