Привет, Хабр!

Сегодня рассмотрим флаг регулярных выражений v в JavaScript. Флаг поддержан в современных движках и Node 20+, а для старых окружений есть транспиляция через Babel. Начнём с краткой ориентации где это уже работает и почему синтаксис отличается, а потом пойдём в практику.

Что такое v и почему это не просто u++

Флаг v включает режим unicodeSets. Это отдельный вариант интерпретации шаблона: u и v нельзя смешивать одновременно в одном регексе. В v режиме доступны:

  1. свойства строк Юникода через \p{…}, т.е совпадения могут быть не только одиночными кодовыми точками, но и последовательностями;

  2. расширенная запись символьных классов с вложенностью и операциями пересечения и вычитания;

  3. исправленная логика для комплементарных классов с флагом 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:

Актуальный стек технологий для решения задач фронтенда на junior+ уровне можно изучить под руководством экспертов на курсе "JavaScript Developer. Basic".

Комментарии (0)