
Привет, Хабр! На связи снова Сергей, ведущий фронтенд-разработчик из Центрального университета. В последнее время я преисполнился URL и опять хочу про него рассказать.
В прошлой статье я рассказал о том, почему неправильно использовать URL API для валидации ссылок. В этот раз буду использовать инструменты по назначению. Речь пойдет про новый URLPattern API для сопоставления URL с шаблонами, который позволит валидировать ссылки без головной боли.
Что за новое API
Последние четыре года WHATWG активно разрабатывает стандарт для URLPattern API, который с сентября 2025 появился во всех основных браузерах, NodeJs и Deno.
Основная идея разработки — создать базовый примитив веб-платформы, который будет удобным инструментом для сопоставления URL с различными шаблонами. Звучит не совсем понятно, поэтому рассмотрим примеры, пробежимся по основным моментам при работе с URLPattern и поймем, для чего же он нужен.
Создадим экземпляр класса URLPattern и передадим в него простую ссылку, в нашем случае это будет http://example.com
, в качестве альтернативы можно передать шаблон в виде объекта.
// шаблон строка
const pattern = new URLPattern("http://example.com");
// шаблон объект
const pattern = new URLPattern({
protocol: "http",
hostname: "example.com",
});
Экземпляр класса состоит из полей URL, но значениями выступает шаблон для сопоставления этой части URL. Вот поля этого класса.
hash: "*"
hostname: "example.com"
password: "*"
pathname: "*"
port: ""
protocol: "http"
search: "*"
username: "*"
hasRegExpGroups: false
Все поля доступны только для чтения. У нас указаны hostname и protocol, которые взяты из наших аргументов. Остальные поля указаны как звездочки — значит, эта часть URL будет сопоставлена с любым значением. Но стоит обратить внимание на port, он указан как пустая строка. Это не баг, а фича! Если не указать конкретный порт в шаблоне, при сопоставлении URL валидными будут ссылки с портом по умолчанию или без порта.
Отдельно выделено поле hasRegExpGroups — это флаг, который показывает, содержится ли у нас хотя бы одна группа захвата регулярных выражений. Мы можем использовать регулярные выражения — не стоит бояться их сложности, во всем разберемся.
Есть два метода — exec() и test(). Первый метод проверяет значения каждого элемента URL, возвращает URL, разбитый на группы, или null, если есть несовпадения. Метод test() использует результаты метода exec(): если результат null, возвращает false, иначе — true.
Как строится шаблон
Мы знаем, из чего состоит экземпляр URLPattern, — осталось понять, что с этим делать. Так как речь идет про сопоставление с шаблоном, разберем, что такое шаблон и как он строится.
Шаблоны — это строки, которые создаются для соответствия определенному набору целевых строк, и их синтаксис напрямую основан на синтаксисе, используемом популярной javascript-библиотекой path-to-regexp. Этот синтаксис похож на используемый в Ruby on Rails или Javascript-фреймворках, таких как Express или Next.js, и используется для сопоставлений url при работе с запросами.
Посмотрим, как работает инструмент URLPattern и для чего он создавался. Самый простой пример — сравнение с URL через метод test().
const pattern = new URLPattern("http://example.com");
pattern.test("http://example.com"); // true, проверяем точно такой же URL
pattern.test("http://user:pass@example.com"); // true, для http можно передать username и password
pattern.test("http://example.com:80/user/25?query=some-query#some-hash"); // true, можно передать любые pathname, query и hash, а также порт по умолчанию
pattern.test("http://test.org"); // false, используем другой домен
pattern.test("https://example.com"); // false, используем неправильный протокол
pattern.test("http://example.com:443"); // false, используем нестандартный порт
В коде есть несколько особенностей. Например, для протокола http мы можем указать user и password, хотя в шаблоне их не указывали. То же касается указания pathname, port, query, hash. Если мы указываем не соответствующие шаблону URL, получаем false.
Далее реализуем сценарий, при котором есть путь, который содержит userId. С помощью метода exec() проверим, соответствует ли часть пути шаблону, и получим из результата сами данные, чтобы потом иметь возможность с ними работать. Для этого создадим именованную группу — поставим двоеточия перед именем. В нашем случае :userId. Для тех, кто работает с роутерами в angular, react, express, запись вида /user/:userId будет до боли знакома, да и работать она будет, по сути, так же.
const pattern = new URLPattern({ pathname: "/user/:userId" });
// получаем результат проверки
const result = pattern.exec({ pathname: "/user/123" });
// получим данные поля pathname
result.pathname;
/*
{
groups: {
userId: "123"
},
input: "/user/123"
}
*/
// получим сам userId
result.pathname.groups.userId; // "123"
Мы использовали метод exec(), который вернул разбитый по группам URL. Видим, что есть группа userId со значением, которое мы можем использовать в будущем по своему усмотрению.
Усложним задачу! Например, есть какие-то ограничения на формат userId — это может быть специфический для нашего приложения тип. Представим userId в формате XXX-XXX, где X — это любая цифра. Для этого нам было бы неплохо добавить проверку, как в регулярном выражении. Хотя почему «как»? Используем регулярное выражение, у нас есть такая возможность!
const pattern = new URLPattern({ pathname: "/user/:userId(\\d{3}-\\d{3})" });
pattern.test({ pathname: "/user/123-456" }); // true
pattern.exec({ pathname: "/user/123-456" }).pathname.groups.userId; // "123-456"
pattern.test({ pathname: "/user/123456" }); // false
pattern.hasRegExpGroup; // true
По сути, все работает так же, как в прошлом примере, за исключением того, что добавилась проверка шаблона на соответствие формата. У поля pattern.hasRegExpGroup будет значение true, так как мы используем регулярное выражение внутри шаблона. А раз мы затронули тему регулярных выражений, получаем невиданную мощь! И теперь наши примеры могут стать еще интереснее.
При работе с регулярными выражениями надо помнить пару основных моментов. Регулярные выражения записываются в круглых скобках. А еще нужно обязательно экранировать обратные слеши. Любые скобки экранируются двумя обратными слешами \\, несмотря на то что в обычном регулярном выражении делать так не надо.
new URLPattern({ pathname: "([()])" }); // Invalid pathname pattern
new URLPattern({ pathname: "([\\(\\)])" }); // валидно
В предыдущих примерах мы знали структуру ссылки, но это бывает не всегда. Представим ситуацию, в которой хотим проверить, что наш URL — это ссылка на изображение любимого котика. Но мы не знаем, насколько глубоко по файловой иерархии может оказаться нужный нам файл. Чтобы проверить, что ссылка ведет на файл-изображение, воспользуемся двумя механизмами.
Первый подстановочный знак * (wildcard) — это сокращение для безымянной захватывающей группы, которая соответствует любому символу ноль или более раз. Его можно разместить в любом месте шаблона. Подстановочный знак является «жадным» (greedy), то есть он будет соответствовать максимально возможной строке.
Второй механизм — разделитель группы (group delimiters), который позволяет выделить часть шаблона в группу. Для этого надо заключить нужную часть в фигурные скобки ({}), они в результат не попадут, но к ним можно будет применять шаблон.
В примере используем wildcard, чтобы указать группу из произвольного набора символов произвольной длины. Но так как у нас папки, нужно указать слеш в качестве разделителя. Получаем конструкцию вида /. Получилась папка произвольной длины, а нам надо их несколько. Объединим их в группу {/}, а чтобы она могла использоваться любое число раз, добавим модификатор (не путать с wildcard). В результате получим конструкцию {/}*.
// подстановочный знак в середине шаблона
const pattern = new URLPattern({
pathname: "{*/}*:imageName([a-zA-Z0-9\\-]+).png"
});
pattern.test("http://example.com/my-cat/cats/folder-with-cat/my-cat.png"); // true
pattern.test("http://example.com/my-cat.png"); //true
// доступ к имени изображения
pattern.exec("http://example.com/my-cat/cats/folder-with-cat/my-cat.png").pathname.groups.imageName; // "my-cat.png"
pattern.test("http://example.com/folder/folder/folder/.png"); // false, нет имени
pattern.test("http://example.com/.png"); // false, нет имени
Вероятна ситуация, когда у нас есть основной домен и несколько поддоменов. Для такого случая можно выделить целевую группу, которую нужно заключить в фигурные скобки {}.
Разделители групп
{}
НЕ могут содержать другие разделители групп, но могут содержать любые другие элементы шаблона: захватывающие группы, регулярные выражения, подстановочные знаки или фиксированный текст.
Если мы просто выделим группу {:subdomain.} в фигурные скобки, это не сделает ее необязательной. Для этого у нас есть модификаторы групп. Они указываются после имени группы или после регулярного выражения, если имя отсутствует. Существует три модификатора:
? — группа становится необязательной;
+ — группа повторяется один или более раз;
* — группа повторяется ноль или более раз.
В нашем случае модификатор будет *, так как он решает нашу задачу.
const pattern = new URLPattern({ hostname: "{:subdomain.}*example.com" });
pattern.test({ hostname: "example.com" }); // true
pattern.test({ hostname: "subsubdomain.subdomain.example.com" }); // true
pattern.test({ hostname: ".example.com" }); // false
Проблему с доменами решили. Посмотрим, где можно применить другие модификаторы. Модификатор необязательности хорошо ложится на кейс, когда мы хотим проверить, является ли протокол http или https.
const pattern = new URLPattern("http{s}?://example.com");
pattern.test("http://example.com"); // true
pattern.test("https://example.com"); // true
Для последнего примера модификатора групп можно представить ссылку, которая отображает иерархию организации. Не всегда известно, сколько может быть отделов по вложенности, но мы знаем, что хотя бы один должен быть. Это чем-то похоже на пример с сопоставлением ссылки на изображение.
const pattern = new URLPattern({ pathname: "/:organization/:departmentName+" });
const result = pattern.exec({ pathname: "/ooo-romashka/sales/b2b" });
result.pathname
/*
groups: {
organization: "ooo-romashka",
departmentName: "sales/b2b"
},
input: "/ooo-romashka/sales/b2b"
*/
Для следующего трюка я не буду использовать примеры из жизни, но расскажу про утверждения просмотра вперед и назад (lookahead и lookbehind). Утверждения просмотра позволяют указать, что определенный текст до или после текущей позиции соответствует заданному шаблону, не захватывая его.
Есть четыре типа утверждений:
(?=...) — положительное утверждение просмотра вперед: последующие символы должны соответствовать шаблону.
(?!...) — отрицательное утверждение просмотра вперед: последующие символы не должны соответствовать шаблону.
(?<=...) — положительное утверждение просмотра назад: предыдущие символы должны соответствовать шаблону.
(?<!...) — отрицательное утверждение просмотра назад: предыдущие символы не должны соответствовать шаблону.
Нужно быть внимательным при использовании утверждений с URLPattern, так как поведение может быть неожиданным. Например, утверждение (?=ahead) не сработает как ожидалось.
const pattern = new URLPattern({ pathname: "(/look(?=ahead))" });
pattern.test("http://example.com/lookahead"); // false
Движок URLPattern сопоставляет строку с шаблоном, сначала находя /look, а затем проверяет, что следующие символы — ahead, но не захватывает их. После этого парсер пытается продолжить сопоставление со строкой ahead, но в шаблоне ничего не осталось, чтобы ее сопоставить, поэтому сопоставление не проходит.
Шаблон должен захватить все символы строки, чтобы сопоставление прошло успешно. Например, можно добавить .* после утверждения:
// положительное утверждение вперед
const pattern = new URLPattern({ pathname: "(/look(?=ahead).*)" });
pattern.test("http://example.com/lookahead"); // true
pattern.test("http://example.com/lookbehind"); // false
Остальные утверждения работают аналогично, подробнее можно изучить в документации по lookahead и lookbehind.
Наследование от базового URL
Все это время мы рассматривали только шаблон в качестве аргумента для конструктора класса URLPattern. Теперь рассмотрим baseURL. Его действие схоже с тем, что можно было увидеть в классе URL, но есть несколько интересных моментов.
Аргумент baseURL можно применять в двух местах, как при создании нового экземпляра класса URLPattern, так и при вызове методов .test() и .exec() для проверки относительных ссылок.
Важно, что при использовании объекта в качестве шаблона baseURL передается внутри него, а не отдельным параметром, как в случае, если шаблон представлен строкой.
const pattern = new URLPattern({
port: "100",
pathname: "/a",
search: "query=123",
hash: "hash",
baseURL: "http://user:pass@example.com:999/b?query=456#bash"
});
Если в baseURL и в отдельных полях есть повторения, то в URLPattern предусмотрен механизм наследования. Если мы не укажем какое-либо значение в нашем шаблоне, оно будет унаследовано из baseURL.
Взглянем на поля нашего объекта pattern: поля protocol и hostname взяты из baseURL, так как его мы не указали в наших шаблонах. Это поведение будет действовать почти на все остальные поля.
// поля экземпляра класса pattern
hash: "hash";
hostname: "example.com";
password: "*";
pathname: "/a";
port: "100";
protocol: "http";
search: "query=123";
username: "*";
У наследования от baseURL есть несколько особенностей:
Имя пользователя и пароль никогда не наследуются из базового URL.
Компоненты URL, которые не указаны в строке, входном объекте или не унаследованы из базового URL, будут иметь значение wildcard ("*") для URLPattern и пустую строку ("") для тестируемого URL.
Базовый URL рассматривается строго как URL и не может содержать какой-либо синтаксис шаблонов.
Вот пример, как использовать baseURL в методах test() и exec().
const pattern = new URLPattern({ hostname: "example.com", pathname: "/product/*" });
pattern.test({ pathname: "/product/docs", baseURL: "https://example.com/some-path"}); // true, так как hostname наследуется из свойства baseURL, а вот pathname переопределили.
// метод exec() принимает те же аргументы, что и test().
const result = pattern.exec("/product/docs", "https://example.com/some-path");
// доступ к данным
result.pathname.input; // "/product/docs"
result.pathname.groups[0]; // "docs"
result.hostname.input; // "example.com"
Особенности работы
Помимо того что уже было описано в статье, есть еще несколько важных особенностей.
Чувствительность к регистру. По умолчанию URLPattern рассматривает некоторые части URL как чувствительные к регистру, тогда как многие клиентские фреймворки и библиотеки используют сопоставление без учета регистра. Чтобы сопоставление было без учета регистра, существует опция ignoreCase. Пока это единственная опция 🙂
// cопоставление по умолчанию
const defaultPattern = new URLPattern("http://example.com/test");
defaultPattern.test("http://example.com/test"); // true
defaultPattern.test("HTTP://ExAmPlE.cOm/test"); // true, protocol и hostname нечувствительные к регистру
defaultPattern.test("http://example.com/TEST"); // false, pathname чувствительный к регистру
// cопоставление без учета регистра
const ignoreCasePattern = new URLPattern("http://example.com/test", {
ignoreCase: true,
});
ignoreCasePattern.test("http://example.com/test"); // true
ignoreCasePattern.test("http://example.com/TEST"); // true
Нормализация шаблонов. При разборе шаблона он автоматически нормализуется до канонической формы. К нормализации относится, например, конвертация имени хоста, написанного на кириллице в кодировку Punycode. Пути, такие как /foo/./bar/, сворачиваются до /foo/bar. Сами конструкции шаблонов тоже проходят нормализацию, например {foo} нормализуется до foo.
Сопоставление pathname. В регулярном выражении pathname всегда начинается с символа /
— если его не включить, сопоставление не состоится.
// отсутствует /
const patternWithoutSlash = new URLPattern({ pathname: "(path.*)" });
patternWithoutSlash.test("http://example.com/path"); // false
patternWithoutSlash.test("http://example.com/pathname"); // false
// начинается с /
const patternWithSlash = new URLPattern({ pathname: "(/path.*)" });
patternWithSlash.test("http://example.com/path"); // true
patternWithSlash.test("http://example.com/pathname"); // true
Завершающие слеши в pathname. Если в pathname указан завершающий слеш, он обязателен для проверки. То же самое — если его не указать.
// pathname с обязательным слэшем
const patternSlash = new URLPattern({ pathname: "/path/" });
patternSlash.test("http://example.com/path/"); // true
patternSlash.test("http://example.com/path"); // false
// pathname без слэшем
const patternNoSlash = new URLPattern({ pathname: "/path" });
patternNoSlash.test("http://example.com/path"); // true
patternNoSlash.test("http://example.com/path/"); // false
// pathname с опциональным слэшем
const patternOptionalSlash = new URLPattern({ pathname: "/path{/}?" });
patternOptionalSlash.test("http://example.com/path"); // true
patternOptionalSlash.test("http://example.com/path/"); // true
Можно использовать разделитель группы, содержащий наш слеш, и модификатор необязательной группы, чтобы URL мог соответствовать обоим вариантам кода. Аналогичный подход мы видели в примере с модификаторами для проверки протоколов http и https.
Автоматическое добавление префикса "/" к группам в pathname. В шаблонах, которые сопоставляются с pathname URL, группам автоматически добавляется префикс "/", если определению группы предшествует слеш. Это полезно для групп с модификаторами, так как позволяет повторяющимся группам работать как ожидается.
// шаблон с необязательной группой, предшествующий слеш
const pattern = new URLPattern("/deparments/:department*", "http://example.com");
pattern.test("http://example.com/deparments/sales/b2b"); // true
pattern.test("http://example.com/deparments/sales"); // true
pattern.test("http://example.com/deparments"); // true
pattern.test("http://example.com/deparments/"); // false
В примере группа :department необязательная, предшествующий ей слеш становится частью этой группы, и он обязателен для проверки, если есть группа.
Если вы не хотите автоматического добавления префикса, можно отключить его, заключив группу в {}.
// отключение добавления префикса сегмента для группы с помощью разделителя группы
const pattern = new URLPattern({ pathname: "/deparments/{:department}*" });
pattern.test("http://example.com/deparments/sales"); // true
pattern.test("http://example.com/deparments/"); // true
pattern.test("http://example.com/deparments"); // false
Якоря начала и конца строки. При использовании регулярных выражений нам не требуется использовать якоря начала строки ^ и конца строки $. Их можно указать, но они не повлияют на результат, так как проставляются неявно для всех частей URL.
// с якорями
const patternWithAnchor = new URLPattern("(^http)://example.com/(path$)");
patternWithAnchor.test("http://example.com/path"); // true
// без якорей
const patternWithoutAnchor = new URLPattern("(http)://example.com/(path)");
patternWithoutAnchor.test("http://example.com/path"); // true
Сравнение с регулярными выражениями
Сравним URLPattern с регулярным выражением. Сперва сформируем простой набор правил, которые хотим проверить:
Протокол http или https.
Один или несколько поддоменов.
Путь начнется с departments.
Неограниченное количество частей пути после departments, назовем их department.
Основной домен example.com.
Вот две реализации проверки — через URLPattern и через регулярные выражения.
const pattern = new URLPattern("http{s}?://{:subdomain.}*example.com/departments/:department*");
// ИЛИ
const pattern = new URLPattern({
protocol: "http{s}?",
hostname: "{:subdomain.}*example.com",
pathname: "/departments/:department*",
});
const pattern = /^https?:\/\/(?:[a-zA-Z0-9-]+:[a-zA-Z0-9-]+@)?(\w+\.)?example\.com\/departments\/[a-zA-Z0-9-]+(\?.*)?$/
// валидные
'https://user:pass@example.com/departments/sales?type=b2b'
'http://sub.example.com/departments/sales/b2b?type=closed#anchor'
'https://example.com/departments/sales'
// невалидные примеры
'http://example.org/departments/sales' // домен не example.com
'http://example.com/department/sales' // pathname начинается не с departments
'https://example.com/departments/' // после / должен быть обязательно department
'ftp://example.com/departments/sales' // протокол ftp
Писать шаблоны с помощью URLPattern гораздо проще, и они легче для чтения и поддержки.
Поддержка URLPattern API постепенно расширяется. Chrome и Edge внедрили его еще в октябре 2021 года, а Opera и Mozilla Firefox добавили поддержку в августе 2025. Safari старается не отставать с внедрением и представила API в последней, 26-й версии браузера в сентябре 2025 года, но пока в статусе Technology Preview.
URLPattern добавили в Node.js начиная с версии 23.8.0 в начале 2025 года, а вот в Deno он появился еще в октябре 2021 по сути, параллельно с движком Chromium. Но для тех, у кого нет возможности обновить свои сервера или есть ограничения на используемые версии браузеров у пользователей, есть хорошая новость — существует полифил.
Выводы
Ребята из WHATWG время зря не теряли и сделали простое, удобное и мощное API для сопоставления URL, которое можно использовать уже сейчас. Где-то нативно, где-то через полифил. Новое API может быть применено в клиентском и серверном роутинге, интерцепторах и валидации.
Позволю себе заглянуть в будущее, которое мы можем получить:
Унификация роутинга в разных инструментах как во фронтенде, так и на бэкенде. Это приведет к упрощению перехода между технологиями и сведет большую часть логики по сопоставлению путей к использованию одной конкретной функции.
Более строгие проверки путей — например, на стадии проверки URL посмотреть формат данных, раньше отобразить ошибку и сэкономить несколько сетевых запросов.
Более выразительное описание шаблонов, что позволит быстрее понимать форматы данных по сравнению с регулярными выражениями.
Массовый переход фреймворков и библиотек на URLPattern приведет к тому, что со временем снизится размер бандлов, так как будет использоваться нативное браузерное/серверное API.