JavaScript: суперсила нового RegExp

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

Неудивительно, что JavaScript с корнями в веб-программировании имеет одну из самых надежных на сегодняшний день библиотек регулярных выражений (или, что более точно, regex), и недавнее развитие ECMAScript значительно расширило эту возможность. В этой статье я рассчитываю рассказать о некоторых наиболее любопытных шаблонах разработки регулярных выражений, которые открывает ES2015+, а также о нескольких, которые появятся в ближайшем будущем в среде JavaScript.

Могущество match

Библиотека регулярных выражений в JavaScript начинается с функции регулярного выражения match(). В простейшем случае match() действует как логическое значение, чтобы указать, соответствует ли фрагмент текста регулярному выражению или нет. Его основное применение — определение и валидация — проверка, чтобы удостовериться, что данный блок текста содержит определенный образец. Например:

let text = "This is a test.";
if (text.match(/is/)){return "'is' match successful.";}
==> "'is' match successful."

Однако JavaScript на самом деле действует более ловко: в действительности он возвращает объект, который содержит гораздо больше информации, если найдено совпадение, и null, если совпадение не найдено:

text.match(/is/)
==> {
   0:"is", // the matching string
   groups: undefined,// an array, covered below
   index:2, // the offset from the beginning of the string,
   input:"This is a test.", // the original string being tested
   length:1 // the number of items being matched.
}

Обычно функции регулярного выражения возвращают первое найденное совпадение, но с помощью глобального флага g поиск можно расширить, чтобы охватить все совпадения в строке. Расширяя регулярное выражение для захвата любых слов, содержащих буквы «is», с помощью регулярного выражения /\w*is\w*/g, где параметр \w отображает любой подходящий буквенно-цифровой символ, вывод превращается в массив:

let text = "This is a test, isn't it? Yes, it is.";
text.match(/\w*is\w*/g)
==> ["This","is","isn","is"]

Обратите внимание, что третье условие не захватывает апостроф, так как он не считается буквенно-цифровым символом. Регулярное выражение может быть расширено таким образом

let wordRegex = /\w*is\w*'?\w?/g;
text.match(wordRegex)
==> ["This","is","isn't","is")

чтобы учесть эту ситуацию. Стоит отметить, что при глобальном поиске в этом массив попадают только подходящие строки. В общем это потому, что match() оптимизирована для быстрой работы, а дополнительная информация из запросов может быть более эффективно обработана с помощью функции замены.

Кстати, функция split() (для строк) также может принимать регулярные выражения, возвращая все, кроме того, что выбрано выражениями. Например, чтобы разбить строку на отдельные непустые токены, вы можете использовать split() следующим образом:

let test = "This is a test, isn't it? Yes, it is.";
console.log(text.split(/\s+/));
==> ["This","is","a","test,","isn't","it?","Yes,","it","is."]

Во многих отношениях match() и split() зеркально противоположны: первая находит совпадения с исходным регулярным выражением, вторая возвращает массив того, что осталось после того, как совпадения найдены и удалены.

Класс RegExp

Так же, как JavaScript поддерживает строки c разделителями "" и " и предлагает конструктор String(), он также поддерживает регулярные выражения с разделителями //, одновременно предоставляя класс RegExp() в качестве конструктора. Содержимое, созданное с использованиям формата разделенных значений, в JavaScript принято называть литералами.

Строковый класс предоставляет методы match() и replace(), но есть некоторые преимущества непосредственного использования объекта RegExp. Например, предположим, что вы хотите получить каждый компонент и его смещение от начала строки. Даст вам эту информацию следующий код:

let text = "This is a test, isn't it? Yes, it is.";
let wordRegex = /(\w*is\w*'?)\w?/g;
var arr;
while((arr = wordRegex.exec(text)) !== null){console.log(wordRegex.lastIndex,arr[0])}
==>
4 "This"
7 "is"
21 "isn't"
36 "is"

Метод exec() выполняет ту же роль, что и match(), но обращает строку и объекты RegExp. Выше показано, как можно использовать метод lastIndex() для данного регулярного выражения, чтобы получить индекс каждого из компонентов, которым соответствует регулярное выражение. Код можно преобразовать в функцию:

function concordance(text,regex){
var arr;
let obj = {};
while((arr = regex.exec(text)) !== null){
   if (!obj.hasOwnProperty(arr[0])){obj[arr[0]]=[]};
   obj[arr[0]].push(wordRegex.lastIndex)}
   return obj;
}
let text = "This is a test, isn't it? Yes, it is.";
let wordRegex = /(\w*is\w*'?)\w?/g;
concordance(text,wordRegex)
==>
   "{
      "This": [
         4
      ],
      "is": [
         7,
         36
      ],
     "isn't": [
         21
      ]
}

При наличии строки и регулярного выражения, функция будет перебирать все совпадения и возвращать индекс совпадения, позволяя получить функцию, аналогичную String.indexOf(), но для любой подстроки, которая соответствует регулярному выражению.

Замена, символы и группы

Хотя match() определяет, что конкретно соответствует шаблону регулярного выражения, функция replace() (и ее аналог exec()) делает то, что вы ожидаете — заменяет строку, соответствующую данному шаблону, строкой шаблона, преобразуя выходные данные. Это можно сделать с первым подходящим шаблоном или с каждым шаблоном в большом блоке текста. Если вы углублялись в устройство CMS, большинство движков используют функцию замены регулярных выражений, чтобы волшебным образом преобразовать запутанную разметку в окончательную форму, которую может понять любой браузер.

Метод replace() принимает две строки и заменяет вхождения одной строки другой.

let text = "This is a test, isn't it? Yes, it is.";
text.replace("is","be","g") // "g" indicates a global flag.
==>
"Thbe be a test, ben't it? Yes, it be.";

На самом деле это не так уж полезно, как показывает приведенный выше результат. Однако если replace() принимает регулярное выражение, появляется большая гибкость. Например, предположим, что вы просто хотите найти соответствие слову «is», а не только строковым символам «i» и «s» в последовательности. Это может быть достигнуто с помощью регулярного выражения:

let text = "This is a test, isn't it? Yes, it is.";
console.log(text.replace(/\bis\b/g,"be"));
==>
"This be a test,isn't it? Yes, it be."

Символ \b в регулярном выражении соответствует границе слова, в то время как флаг g после символа регулярного выражения указывает на глобальный поиск.

Библиотека символов соответствия для регулярных выражений JavaScript расширилась за последнее время . В Mozilla MDN for RegExp есть классификация того, что поддерживается, и возможностей наверняка больше, чем кажется большинству. Например, граничные и несимвольные граничные символы (\b и \B соответственно) далеки от стандартных при использовании регулярных выражений, хотя они чрезвычайно полезны.

Группы захвата — также основной элемент регулярных выражений, позволяющий добавлять содержимое непосредственно в выходную строку. Например предположим, что у вас фильтр, который считывает текст и заменяет любую последовательность из десяти символов на ссылки с номерами американских телефонов (очевидно, функционал можно применить и к иностранным номерам телефонов).

let phoneText = "Call me at 123.456.7890.";
let regex = /\(?(\d{3})\)?\S+?\(?(\d{3})\)?\S+?\d{4}/g;
phoneText.replace(regex,'<a href="tel:$1$2$3">($1) $2-$3</a>')
==>
"Call me at <a href="tel:1234567890">(123) 456-7890</a>."

Индикаторы $1, $2, $3 и т. д. хранят значения захваченных групп. В некоторых реализациях регулярных выражений их всего девять, однако в большинстве современных реализаций JavaScript вы можете использовать до 99 таких групп захвата. Таким образом, если у вас двенадцать групп захвата, то значения, которые они содержат, будут находиться в переменных $1, $2, ..., $10, $11, $12 соответственно.

Дополнительно можно указать, чтобы определенные группы в скобках не считались группами захвата (что неудивительно, они известны как группы без захвата). Группы без захвата используют обозначение: (?:...). Например, в телефонном номере регулярное выражение:

regex1 = /(\(?(\d{3})\)?\S+?\(?(\d{3})\)?\S+?(\d{4})?)/;
phoneText.replace(regex1,"$1,$2,$3,$4")
==> "Call me at 123,456,7890,1234567890"
regex2 = /(\(?(\d{3})\)?\S+?\(?(\d{3})\)?\S+?(\d{4})?)/;
phoneText.replace(regex1,"$1,$2,$3,$4")
==> "Call me at 123,456,7890,"

В первом случае сначала проверяются внутренние скобки ($1, $2 и $3), затем движок выходит за их пределы и захватывает полное число ($4). Во втором случае захватываются только первые три выражения, четвертое игнорируется, потому что оно ничего не захватывает.

Сочетание объектов RegExp, стрелочных функций и шаблонных строк

Двумя наиболее мощными конструкциями в обновлении EcmaScript 2015+ для JavaScript являются стрелочные функции и литералы шаблонов. Стрелочная функция (или анонимная функция) состоит из выражения в скобках, которое связывается с телом функции с помощью оператора =>. Например, следующее выражение в скобках создаст новую переменную temp с использованием символа градуса ° (обозначается в HTML с помощью &deg;):

let temp = (value,units)=>value+'&deg;'+units;
temp(25,"C")
==> "25°C"

Может показаться, что функция не вполне анонимная (вы фактически даете ей имя), но эта возможность может иметь огромное значение, если вы работаете с большими блоками текста.

Шаблонный литерал — это способ работать с большими блоками текста. В этом случае вместо использования оператора + вы можете встраивать содержимое в шаблонный литерал (обозначаемый символами обратного хэша ``), заключая переменные в символ ${}.

let temp = (value, units)=>`${value}&deg;${units}`;
temp(25,"C") 
==> "25°C"

Что часто упускают при использовании функции replace(), так это то, что она может принимать второй аргумент — обратный вызов (или стрелочную функцию) с набором параметров. Например, рассмотрим следующее:

let linkRE = /\[(.+?)\|(.+?)\]/g;
let content = `<ul>
    <li>[pageHome.html|Home Page]</li>
    <li>[pageNext.html|Next Page]</li>
</ul>`; // template literals can span multiple lines
content.replace(linkRE,(match,link,label,offset,string)=>
`<a href="${link}">${label}</a>`);
==>
"<ul>
    <li><a href="pageHome.html">Home Page</a></li>
    <li><a href="pageNext.html">Next Page</a></li>
</ul>"

Начальный параметр match — регулярное выражение (и оно может извлекать подгруппы: match. $1, match. $2 и т. д.), в то время как второй и третий параметры соответствуют первой и второй группе захвата, назначенные имена ссылок и метки здесь указывают на их роли. Параметр offset показывает, где в исходной строке совпал шаблон, в то время как string содержит исходную строку.

Рассмотренный шаблон часто можно увидеть в текстовых редакторах для веб, где такие выражения, как [pageHome.html | Home Page], заменяются гипертекстовой ссылкой с использованием второго параметра в качестве текста, отображаемого для ссылки.

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

String.prototype.toTitleCase = function(){
    let regex = /\b(\w+?\'\w)\b|\b(\w+?)\b/g;
    return this.replace(regex,(match,wordForm1,wordForm2)=>{
        let word = wordForm1 || wordForm2;
        return `${word.substr(0,1).toUpperCase()}${word.substr(1)}`;
    });
}
console.log("this is a test. it's only a test.".toTitleCase())
==> "This Is A Test. It's Only A Test.">

Регулярное выражение ищет два шаблона: слово с апострофом или слово без него, затем использует оператор JavaScript || (или), чтобы получить первый непустой шаблон. Затем процедура преобразует первый символ найденного слова в верхний регистр и добавляет остаток слова к этому символу. А потом измененное слово заменяет подходящий шаблон. Такой подход значительно снижает сложность, вносимую регулярными выражениями, что также увеличивает скорость.

Обратные связи и именованные совпадения

Поддержка регулярных выражений в JavaScript неуклонно растет. Две из новых функций, которые будут реализованы, — обратные связи и именованные группы.

Одна из наиболее распространенных проблем, связанных с регулярными выражениями, — использование их для разбора таких языков, как XML. Попробуйте, например, определить теги и внутреннее содержимое элемента, где элемент начинается с тега, подобного следующему: <a>, и заканчивается тем же тегом с </a>. Если вы знаете имя элемента, о котором идет речь, задача довольно тривиальная, но если это не так, знайте, что на самом деле это невозможно сделать с помощью регулярных выражений, если движок не поддерживает обратные ссылки.

Обратная ссылка имеет вид \N, где N равно 1,2,3 и т. д. и соответствует предыдущим группам захвата. Таким образом, чтобы захватить элемент и его содержимое, с помощью обратных ссылок можно сделать следующее:

let html = `<a>This <b>is a <d>text</d>.</b></a>
<c>This is a separate phrase</c>`;
let regex = /<(\w+?)>(.*?)<\/\1>/g;
html.replace(regex,'[$1]$2[/$1]');
=> "[a]This <b>is a <i>text</i>.</b>[/a]
[c]This is a separate phrase[/c]"

Обратите внимание, что в этом случае два внешних элемента <a> и <c> были захвачены (и результаты группы могли быть получены функцией, выполняющей дополнительный анализ), а внутренние — нет. Почему? Поскольку после определения значения группы захвата регулярное выражение будет забирать все, пока не достигнет этого значения в закрывающем теге.

Тем не менее, нетрудно понять, как того же результата можно достигнуть с использованием рекурсии:

let html = `<a>This <b>is a <d>text</d>.</b></a>
<c>This is a separate phrase</c>`;
let regex = /<(\w+?)>(.*?)<\/\1>/g;
let htmlT = null;
while(htmlT != html) {
    htmlT = html;
    html = html.replace(regex,"[$1]$2[/$1]");
}
==> "[a]This [b]is a [i]text[/i].[/b][/a]
[c]This is a separate phrase[/c]"

Я оставляю написание процедуры для преобразования XML-структуры в JSON с использованием регулярных выражений в качестве упражнения для читателя.

Именованная группа позволяет ассоциировать определенную метку с группой захвата, а не просто использовать позиционный номер. Она принимает форму:

(?<label>expr)

где label — имя группы, а expr — содержимое группы захвата регулярного выражения. Например, вместо анализатора ссылок в квадратных скобках, обсуждавшегося ранее:

let linkRE = /\[(.+?)\|(.+?)\]/g;
let content = `<ul>
    <li>[pageHome.html|Home Page]</li>
    <li>[pageNext.html|Next Page]</li>
</ul>`; // template literals can span multiple lines
content.replace(linkRE,(match,link,label,offset,string)=>
`<a href="${link}">${label}</a>`);
==>
"<ul>
    <li><a href="pageHome.html">Home Page</a></li>
    <li><a href="pageNext.html">Next Page</a></li>
</ul>"

можете написать следующее:

let linkRE = /\[(?<link>.+?)\|(?<label>.+?)\]/g;
let content = `<ul>
    <li>[pageHome.html|Home Page]</li>
    <li>[pageNext.html|Next Page]</li>
</ul>`;
content.replace(linkRE,'<a href="$<link>">$<label></a>');
==>
"<ul>
<li><a href="pageHome.html">Home Page</a></li>
<li><a href="pageNext.html">Next Page</a></li>
</ul>"

где (?<link>. +?) — именованная группа захвата «link», а $<link> — соответствующий шаблон замены.

Также стоит отметить, что если вы используете функцию RegExp.exec() (и не задействовали глобальный флаг), результирующий объект будет включать объект groups с именами групп захвата в качестве ключей:

let linkRE = /\[(?<link>.+?)\|(?<label>.+?)\]/;
let content = `[pageHome.html|Home Page]`;
let results = linkRE.exec(context);
console.log(results.groups.link)
==> "pageHome.html"
console.log(results.groups.label)
==> "Home Page"

Пока я это писал, в рабочей группе ECMAScript использование именованных групп подняли до уровня Stage 4, что означает, что оно стабильно и может быть включено в интерпретаторы JavaScript. Функционал также доступен в актуальной версии браузера Chrome (версия 72), хотя еще не попал в Node.js. Следовательно, используйте именованные группы с осторожностью.

Резюме

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


Автор — Курт Кейгл, писатель-контрибьютор Cognitive World (Forbes), живет в Сиэтле, штат Вашингтон. Перевод — Евгений Зятев.