
Автор Стивен Левитан (Steven Levithan). Перевод статьи "Regexes Got Good: The History And Future Of Regular Expressions In JavaScript".
Современные регулярные выражения JavaScript прошли долгий путь со времени своего появления. И они могут стать потрясающим средством поиска и замены текста, хотя у них до сих пор репутация сложного для написания и понимания инструмента.
И особенно это актуально для среды JavaScript, где регулярные выражения много лет оставались в тени, будучи сравнительно менее мощными по сравнению с их более современными аналогами в PCRE, Perl, .NET, Java, Ruby, C++ и Python. Но, наконец, те времена прошли.
В этой статье я расскажу об истории улучшений регулярных выражений в JavaScript (спойлер: ES2018 и ES2024 серьезно изменили правила игры), покажу примеры современных регулярных выражений в действии, познакомлю вас с облегчённой библиотекой JavaScript, которая позволяет JavaScript конкурировать с другими современными инструментами регулярных выражений или превосходить их, и в конце предложу обзор активно обсуждаемых предложений, которые позволят улучшить регулярные выражения в будущих версиях JavaScript (некоторые из них уже работают в вашем браузере сегодня).
История регулярных выражений в JavaScript
В стандарте ECMAScript 3, принятом в 1999 году, в язык JavaScript были добавлены регулярные выражения, вдохновлённые Perl. Несмотря на то, что там было достаточно всего необходимого, чтобы сделать регулярные выражения довольно полезными (и в основном совместимыми с другими языками), тогда уже были некоторые существенные недостатки. И пока JavaScript ждал 10 лет до выхода следующей стандартизированной версии ES5, другие языки программирования и реализации регулярных выражений добавили полезные функции, которые сделали их регулярные выражения более мощными и читаемыми.
Но это было тогда.
А вы знали, что почти в каждой новой версии JavaScript есть как минимум небольшие улучшения и для регулярных выражений?
Давайте же рассмотрим их.
Не волнуйтесь, если вам будет что-то из следующего сложно понять. Позже мы более подробно рассмотрим некоторые ключевые функции.
- В ES5 (2009) было исправлено неинтуитивное поведение, при котором каждый раз при выполнении литералов регулярных выражений создавался новый объект, а в литералах регулярных выражений можно было использовать прямые косые черты без экранирования внутри классов символов (
/[/]/
). - В ES6/ES2015 были добавлены два новых флага регулярных выражений:
y
(sticky
), который упростил использование регулярных выражений в парсерах, иu
(unicode
), который добавил несколько важных улучшений, связанных с Юникод, а также строгие ошибки. Также были добавлен геттерRegExp.prototype.flags
, поддержка наследования классаRegExp
и возможность копировать регулярное выражение при измении его флагов. - ES2018 - это версия, которая наконец-то сделала регулярные выражения JavaScript достаточно хорошими. В ней появились флаг
s
(dotAll
), ретроспективные проверки, именованные скобочные группы и свойства Юникода (через\p{...}
и\P{...}
, для которых требуется флагu
из ES6). Как мы увидим далее, все эти функции чрезвычайно полезны. - В ES2020 добавлен строковый метод
matchAll
, о котором мы также вскоре поговорим. - В ES2022 добавлен флаг
d
(hasIndices
), который указывает начальную и конечную позиции совпадающих подстрок. - И, наконец, в ES2024 добавлен флаг
v
(unicodeSets
) в качестве обновления флагаu
из ES6. Флагv
добавляет набор многосимвольных "свойств строк" к\p{...}
, многосимвольных элементов в классах символов с помощью\p{...}
и\q{...}
, вложенных классов символов, наборов исключения[A--B]
и пересечения[A&&B]
, а также различные правила экранирования внутри классов символов. Также исправлено сопоставление без учёта регистра для свойств Юникода в отрицаемых наборах[^...]
.
В каждой редакции JavaScript с ES2019 по ES2023 также добавлялись дополнительные свойства Юникода, которые можно использовать с помощью \p{...}
и \P{...}
. А чтобы наш обзор добавлений был полным, стоит сказать, что в ES2021 был добавлен строковый метод replaceAll
. Хотя его единственное отличие от replace
, добавленного в ES3, заключается в том, что он выдаёт ошибку, если не используется флаг g
.
Что же касается того, можно ли безопасно использовать эти фичи уже сегодня, то ответ - да! Последняя из этих фичей, флаг v
, поддерживается в Node.js 20 и браузерах 2023 года. Остальной функционал поддерживается в браузерах 2021 года и более ранних версиях.
Отступление: Что делает регулярные выражение такими хорошими?
Учитывая все эти изменения, как регулярные выражения JavaScript теперь соотносятся с другими вариантами регулярных выражений? Есть несколько способов оценить это, но вот несколько ключевых аспектов:
- Производительность. Это важный аспект, но, вероятно, не главный, поскольку корректно составленные регулярные выражения, как правило, довольно быстрые. JavaScript отличается высокой производительностью регулярных выражений (по крайней мере, если рассматривать движок Irregexp от V8, используемый Node.js, браузерами на базе Chromium и даже Firefox; а также JavaScriptCore, используемый Safari), но он использует механизм обратного отслеживания, в котором отсутствует какой-либо синтаксис для управления отслеживанием, и это серьёзное ограничение, которое делает уязвимость ReDoS более распространённой.
- Поддержка расширенных функций, которые работают с распространенными или важными сценариями использования. В этом плане JavaScript улучшил свои возможности в ES2018 и ES2024. Теперь JavaScript является лучшим в своем классе по таким функциям, как поиск по шаблону (с поддержкой бесконечной длины) и свойства Юникода (с многосимвольными "свойствами строк", исключением и пересечением наборов, а также расширениями систем написания). Эти функции либо не поддерживаются, либо не являются такими же надежными во многих других вариациях регулярных выражений.
- Способность составлять читаемые и легко обслуживаемые шаблоны. Здесь регулярные выражения нативного JavaScript долгое время были худшим из основных вариантов, поскольку у них отсутствует флаг
x
, который позволяет использовать неважные пробелы и комментарии. Кроме того, у них нет подпрограммы регулярных выражений и групп определения подпрограмм (из PCRE и Perl) - мощный набор функций, которые позволяют писать грамматические регулярные выражения, создающие сложные шаблоны путём композиции.
Так что здесь сложилась немного неоднозначная ситуация.
Регулярные выражения JavaScript стали исключительно мощными, но в них по-прежнему отсутствуют ключевые функции, которые могли бы сделать регулярные выражения более безопасными, удобными для чтения и сопровождения (всё это мешает некоторым людям использовать их возможности).
Хорошая новость заключается в том, что все эти пробелы можно заполнить с помощью библиотеки JavaScript, о которой мы поговорим далее в этой статье.
Использование современных функций регулярных выражений JavaScript
Давайте рассмотрим несколько наиболее полезных современных функций регулярных выражений, с которыми вы, возможно, не так хорошо знакомы. Следует предупредить заранее, что это руководство рассчитано на тех, кто уже обладает начальными знаниями в области регулярных выражений. Если вы относительно недавно начали изучать регулярные выражения, то вам стоит немного подтянуть свои знания.
Именованные скобочные группы
Часто нам нужно не просто проверить есть ли в тексте совпадения с регулярным выражением, а извлечь подстроки из найденного совпадения и что-то с ними сделать. Именованные скобочные группы позволяют сделать это таким образом, чтобы ваши регулярные выражения и код были более читаемыми и самодокументируемыми.
Следующий пример сопоставляет запись с двумя полями даты и захватывает их значения:
const record = 'Admitted: 2024-01-01\nReleased: 2024-01-03';
const re = /^Admitted: (?<admitted>\d{4}-\d{2}-\d{2})\nReleased: (?<released>\d{4}-\d{2}-\d{2})$/;
const match = record.match(re);
console.log(match.groups);
/* → {
admitted: '2024-01-01',
released: '2024-01-03'
} */
Не волнуйтесь - хотя это регулярное выражение может показаться сложным для понимания, позже мы рассмотрим способ сделать его более понятным. Ключевыми моментами здесь являются то, что именованные группы используют синтаксис (?<имя>...
), а их результаты сохраняются в объекте найденных совпадений groups
.
Вы также можете использовать именованные обратные ссылки для повторного сопоставления всего, что соответствует именованной группе с помощью \k<имя>
, и использовать эти значения в поиске и замене следующим образом:
// Изменить 'Имя Фамилия' на 'Фамилия, Имя'
const name = 'Shaquille Oatmeal';
name.replace(/(?<first>\w+) (?<last>\w+)/, '$<last>, $<first>');
// → 'Oatmeal, Shaquille'
Для опытных пользователей регулярных выражений, которые хотят использовать именованные обратные ссылки в функции обратного вызова замены, в качестве последнего аргумента предоставляется объект groups
. Вот наглядный пример:
function fahrenheitToCelsius(str) {
const re = /(?<degrees>-?\d+(\.\d+)?)F\b/g;
return str.replace(re, (...args) => {
const groups = args.at(-1);
return Math.round((groups.degrees - 32) * 5/9) + 'C';
});
}
fahrenheitToCelsius('98.6F');
// → '37C'
fahrenheitToCelsius('May 9 high is 40F and low is 21F');
// → 'May 9 high is 4C and low is -6C'
Ретроспективные проверки
Ретроспективная проверка (введённая в ES2018) дополняет опережающую проверку, которая всегда поддерживалась регулярными выражениями JavaScript. Опережающая и ретроспективная проверки - это команды (аналогичные ^
для начала строки или \b
для границ слов), которые не захватывают никаких символов в процессе сопоставления. Ретроспективная проверка выполняется или не выполняется в зависимости от того, можно ли найти его подшаблон непосредственно перед текущей позицией искомого шаблона.
Например, следующее регулярное выражение использует ретроспективную проверку (?<=...)
для поиска слова "cat" (и только слова "cat"), перед которым есть слово "fat":
const re = /(?<=fat )cat/g;
'cat, fat cat, brat cat'.replace(re, 'pigeon');
// → 'cat, fat pigeon, brat cat'
Также можно использовать негативную ретроспективную проверку, которая записывается как (?<!...)
, чтобы инвертировать команду. Это позволит регулярному выражению искать соответствие, например, любой экземпляр слова "cat", перед которым нет слова "fat":
const re = /(?<!fat )cat/g;
'cat, fat cat, brat cat'.replace(re, 'pigeon');
// → 'pigeon, fat cat, brat cat'
Реализация ретроспективной проверки в JavaScript - одна из лучших (уступает только .NET). В то время как в других реализациях регулярных выражений действуют непоследовательные и сложные правила, определяющие, когда и как разрешать использовать шаблоны переменной длины внутри ретроспективной проверки. JavaScript позволяет проводить ретроспективную проверку по любым подшаблонам.
Метод matchAll
Метод String.prototype.matchAll
в JavaScript был добавлен в ES2020 и упрощает работу с результатами поиска регулярных выражений в цикле, когда вам нужны расширенные сведения о найденных совпадениях. Хотя раньше были возможны и другие решения, метод matchAll
зачастую проще и позволяет избежать ошибок, например, необходимости защищаться от бесконечных циклов при переборе результатов поиска регулярных выражений, которые могут возвращать совпадения нулевой длины.
Поскольку метод matchAll
возвращает итератор (а не массив), его легко использовать в цикле for...of
.
const re = /(?<char1>\w)(?<char2>\w)/g;
for (const match of str.matchAll(re)) {
const {char1, char2} = match.groups;
// Вывести каждое совпадение и подшаблоны совпадения
console.log(`Matched "${match[0]}" with "${char1}" and "${char2}"`);
}
Примечание: Чтобы использовать метод matchAll
, в регулярном выражении необходимо установить флаг g
(global
). Кроме того, как и в случае с другими итераторами, вы можете получить все результаты в виде массива, используя Array.from
или расширение массива.
const matches = [...str.matchAll(/./g)];
Свойства Юникода
Свойства Юникода (добавлены в ES2018) позволяют эффективно управлять многоязычным текстом с помощью синтаксиса \p{...}
и его негативной версии \P{...}
. Вы можете сопоставлять сотни различных свойств, которые охватывают широкий спектр категорий Юникода, систем написания, расширений систем написания и двоичных свойств.
Свойства Юникода требуют использования флага u
(unicode
) или v
(unicodeSets
).
Флаг v
Флаг v
(unicodeSets
) был добавлен в ES2024 и является обновлением флага u
. (Нельзя использовать одновременно оба флага.) Следует всегда использовать один из этих флагов, чтобы избежать ошибок, которые могут возникать в режиме по умолчанию без поддержки Юникода. Выбрать, какой флаг использовать, довольно просто. Если вам нужна поддержка среды, работающей с флагом v
(Node.js 20 и браузеры 2023 года), то используйте флаг v
; в противном случае используйте флаг u
.
Флаг v
дает доступ к нескольким дополнительным возможностям регулярных выражений, самой крутой из которых, вероятно, является вычитание и пересечение наборов. Это позволяет использовать синтаксис A--B
(внутри классов символов) для сопоставления строк в A
, но не в B
, или использовать синтаксис A&&B
для сопоставления строк в A
и B
. Например:
// Соответствие со всеми греческими буквами кроме 'π'
/[\p{Script_Extensions=Greek}--π]/v
// Соответствия только с греческими буквами
/[\p{Script_Extensions=Greek}&&\p{Letter}]/v
Несколько слов о сопоставлении эмодзи
Эмодзи - это картинки, вроде 😍🔥😎👌, но то, как эмодзи кодируется в тексте - сложная задача. Если вы пытаетесь выделить их с помощью регулярного выражения, важно помнить, что один эмодзи может состоять из одной или нескольких отдельных кодовых точек Юникода. Многие люди (и библиотеки!) которые создают собственные регулярные выражения для эмодзи, упускают этот момент (или плохо его реализуют) и в итоге получают ошибки.
Следующие сведения об эмодзи "👩🏻🏫" (Женщина-учительница: светлый оттенок кожи) показывают, насколько сложными могут быть эти самые эмодзи:
// Длина кодового элемента
"👩🏻🏫".length;
// → 7
// Каждая астральная кодовая точка (коды выше \uFFFF) делится на старший и младший заменитель
// Длина кодовой точки
[..."👩🏻🏫"].length;
// → 4
// Эти четыре кодовые точки это \u{1F469} \u{1F3FB} \u{200D} \u{1F3EB}
// \u{1F469} скомбинированная с \u{1F3FB} это '👩🏻'
// \u{200D} соединитель нулевой ширины
// \u{1F3EB} это '🏫'
// Длина графемного кластера (воспринимаемые пользователем символы)
[...new Intl.Segmenter().segment("👩🏻🏫")].length;
// → 1
К счастью, в JavaScript появился простой способ сопоставления любого отдельного полного эмодзи с помощью \p{RGI_Emoji}
. Поскольку это необычное "строковое свойство", которое может сопоставлять более одной кодовой точки одновременно, для корректной его работы требуется флаг v
.
Если вы хотите работать с эмодзи в средах без поддержки флага v
, то обратите внимание на отличные библиотеки emoji-regex и emoji-regex-xs.
Делаем регулярные выражения более читабельными, удобными в обслуживании и устойчивыми
Несмотря на все усовершенствования регулярных выражений, которые появлялись с годами, нативные регулярные выражения JavaScript достаточной сложности по-прежнему могут быть невероятно трудными для чтения и поддержки.
Именованные скобочные группы, добавленные в ES2018, стали отличным дополнением, которое сделало регулярные выражения более понятными, а тег String.raw
, добавленный в ES6, позволяет не экранировать все обратные косые черты при использовании конструктора RegExp
. Но по большей части это всё, что касается читабельности.
Однако существует лёгкая и высокопроизводительная библиотека JavaScript под названием regex
, которая значительно повышает читаемость регулярных выражений. Она делает это, добавляя отсутствующие в JavaScript ключевые функции из Perl совместимых регулярных выражений (PCRE) и выводя нативные регулярные выражения JavaScript. Вы также можете использовать её как плагин библиотеки Babel, что означает, что вызовы библиотеки regex
будут транслироваться на этапе сборки, поэтому вы получаете больше возможностей для разработчиков, а пользователи не платят за это временем выполнения.
PCRE - это популярная библиотека C, используемая PHP для поддержки регулярных выражений. Она доступна во множестве других инструментах и языках программирования.
Давайте коротко рассмотрим, как библиотека regex
, которая предоставляет тег шаблона с именем regex
, может помочь вам в написании сложных регулярных выражений, понятных и удобных для поддержки. Обратите внимание, что весь описанный ниже новый синтаксис аналогичным образом работает и в PCRE.
Незначимые пробелы и комментарии
По умолчанию для удобства чтения библиотека regex
позволяет добавлять в регулярные выражения пробелы и строчные комментарии (должны начинаться с символа #
).
import {regex} from 'regex';
const date = regex`
# Соотнести дату в формате ГГГГ-MM-ДД
(?<year> \d{4}) - # Год
(?<month> \d{2}) - # Месяц
(?<day> \d{2}) # День
`;
Это эквивалентно PCRE флагу xx
.
Подпрограммы и группы определения подпрограмм
Подпрограммы записываются в виде \g<имя>
(где имя
относится к именованной группе), и регулярные выражения рассматривают указанную группу как независимый подшаблон, который они пытаются сопоставить в текущей позиции. Это позволяет составлять и повторно использовать подшаблоны, что улучшает читаемость и обслуживаемость кода.
Например, следующее регулярное выражение соотносит адрес IPv4, как "192.168.12.123":
import {regex} from 'regex';
const ipv4 = regex`\b
(?<byte> 25[0-5] | 2[0-4]\d | 1\d\d | [1-9]?\d)
# Соотносит оставшиеся 3 байта, отделенные точкой
(\. \g<byte>){3}
\b`;
Вы можете пойти ещё дальше, определив подшаблоны для использования только по ссылке с помощью групп определения подпрограмм. Вот пример, который улучшает регулярное выражение для записей о приёме на работу, которое мы рассматривали ранее в этой статье:
const record = 'Admitted: 2024-01-01\nReleased: 2024-01-03';
const re = regex`
^ Admitted:\ (?<admitted> \g<date>) \n
Released:\ (?<released> \g<date>) $
(?(DEFINE)
(?<date> \g<year>-\g<month>-\g<day>)
(?<year> \d{4})
(?<month> \d{2})
(?<day> \d{2})
)
`;
const match = record.match(re);
console.log(match.groups);
/* → {
admitted: '2024-01-01',
released: '2024-01-03'
} */
Фундамент современных регулярных выражений
Чтобы не думать о флаге v
, в библиотеке regex
он включен по умолчанию. А в средах без поддержки флага v
, он автоматически переключается на флаг u
. При этом продолжается применение правил экранирования как при флаге v
, поэтому ваши регулярные выражения будут иметь обратную и будущую совместимость.
Библиотека также по умолчанию неявно включает эмулируемые флаги x
(незначимые пробелы и комментарии) и n
(режим "только именованный захват"), поэтому вам не нужно постоянно включать их улучшенные режимы. А поскольку это тег шаблона необработанной строки, вам не нужно экранировать обратную косую черту \\\\
, как в конструкторе RegExp
.
Атомарные группы и сверхжадные квантификаторы могут исключить катастрофический возврат
Атомарные группы и сверхжадные квантификаторы - ещё один мощный набор функций, добавляемых библиотекой regex
. Хотя они в первую очередь предназначены для повышения производительности и устойчивости к катастрофическому возврату (также известному как ReDoS или "отказ в обслуживании регулярных выражений" - серьёзная проблема, при которой поиск некоторых строк с помощью регулярных выражений может длиться вечно, приводя к зависанию интерпретатора JavaScript), они также могут повысить читаемость, позволяя писать более простые шаблоны.
Примечание: Подробнее об этих функциях библиотеки regex
вы можете узнать в ее документации.
Что дальше? Будущие улучшения регулярных выражений в JavaScript
Существует множество активных предложений по улучшению регулярных выражений в JavaScript. Ниже мы рассмотрим три из них, которые, скорее всего, будут включены в будущие версии языка.
Дублирующие именованные скобочные группы
Это предложение на стадии 3 (почти готово к публикации). Более того, в последнее время оно работает во всех основных браузерах.
Когда впервые появились именованные скобочные группы, требовалось, чтобы все (?<name>...
) скобочные группы использовали уникальные имена. Однако бывают случаи, когда есть несколько альтернативных путей в регулярном выражении, и использование одних и тех же имён групп в каждой альтернативе упростило бы код.
Например:
/(?<year>\d{4})-\d\d|\d\d-(?<year>\d{4})/
Это предложение позволяет сделать именно это, предотвращая ошибку "дублирование имени скобочной группы" в этом примере. Обратите внимание, что имена по-прежнему должны быть уникальными в пределах каждого альтернативного пути.
Модификаторы шаблонов (ака Флаговые группы)
Это ещё одно предложение на этапе 3. Оно уже поддерживается в Chrome/Edge 125 и Opera 111, а скоро появится в Firefox. О Safari пока ничего не известно.
Модификаторы шаблонов используют (?ims:...)
, (?-ims:...)
или (?im-s:...)
для включения или выключения флагов i
, m
и s
только для определенных частей регулярного выражения.
Например:
/hello-(?i:world)/
// Соответствует 'hello-WORLD', но не 'HELLO-WORLD'
Экранирование специальных символов при помощи RegExp.escape
Это предложение готовилось в течение долгого времени и недавно достигло 3-й стадии разработки. Оно пока не поддерживается ни в одном из основных браузеров. Предложение делает именно то, что обещает, - предоставляет функцию RegExp.escape(str)
, которая возвращает строку с экранированными специальными символами регулярного выражения, чтобы их можно было сопоставить буквально.
Если вам нужна эта уже функция сегодня, то самый популярный пакет (с более чем 500 миллионами загрузок в месяц) - это escape-string-regexp, сверхлёгкая утилита с одним назначением, которая выполняет минимальное экранирование. Это отлично подходит для большинства случаев, но если вам нужна уверенность в том, что экранированную строку можно безопасно использовать в любой произвольной позиции в регулярном выражении, то стоит обратиться к библиотеке regex
, которую мы уже рассматривали в этой статье. Библиотека regex
использует интерполяцию для экранирования встроенных строк с учётом контекста.
Заключение
Итак, вот оно: прошлое, настоящее и будущее регулярных выражений JavaScript.
Пусть ваш синтаксический анализ будет успешным, а ваши регулярные выражения удобочитаемыми.