It's a match
- пятница, 5 сентября 2025 г. в 00:00:05
С ростом сложности фронтенда разработчики начали уделять больше внимания архитектуре. Кто-то предпочитает «чистую», кто-то — её производные, например, FSD. В той или иной степени этот вопрос волнует многих. В данной статье я предлагаю присмотреться повнимательнее к аспекту, который часто остаётся в тени при обсуждении архитектуры, — к маршрутизации.
Давайте вспомним, как мы строим роутинг в наших приложениях. В примере ниже — react-router-dom
, но в других фреймворках/библиотеках всё примерно так же:
// src/app/router/router.tsx
import { Layout } from '@/widgets/layout';
import { HomePage } from '@/pages/home';
import { UsersPage } from '@/pages/users';
import { UserProfile } from '@/features/users/profile/ui/UserProfile';
import { UserSettings } from '@/features/users/settings/ui/UserSettings';
export const router = createBrowserRouter(
createRoutesFromElements(
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="users" element={<Users />}>
<Route path=":id" element={<UserProfile />}>
<Route path="settings" element={<UserSettings />} />
</Route>
</Route>
</Route>
)
);
import { router } from './router';
// Главный компонент
export function App() {
return <RouterProvider router={router} />;
}
Сразу видны нарушения принципов, которым мы пытаемся следовать при проектировании. Например:
Dependency Inversion Principle
Здесь модуль Router
зависит от низкоуровневых модулей (конкретных компонентов).
Open/Closed Principle
Чтобы добавить новый роут, например /users/:id/comments
, нужно изменить код роутера.
Single Responsibility Principle /router.tsx
отвечает и за создание роутера, и за рендеринг.
Однако мы можем нарушить этот принцип ещё сильнее — Router нам это позволяет:
// Пример с https://reactrouter.com/start/data/route-object#loader
async function action({ request }) {
// Это должен быть application service
const data = await request.formData();
const todo = await fakeDb.addItem({
title: data.get("title"),
});
return { ok: true };
}
// Проблема: бизнес-логика в маршрутизации
async function loader() {
// Это должен быть useCase, а не часть роутера
const items = await fakeDb.getItems();
return { items };
}
<Route
path="users"
element={<Users />}
loader={loader} // DI?
action={action} // загрузка каких-то данных
errorElement={<ErrorComponent />} // Обработка ошибок
/>
Таким образом, мы создаём God object на ровном месте.
Ещё один существенный недостаток такой реализации — проблема тестируемости. Мы не можем просто протестировать UserSettings
— нам приходится запускать Router
, монтировать родительские компоненты (Layout
, Users
, UserProfile
) и обеспечивать все зависимости вышестоящих слоёв. Это превращает юнит-тесты в тяжёлые интеграционные тесты, полностью нивелируя преимущества модульного подхода.
Ещё раз отмечу, что существенной разницы между Vue Router, SolidJS Router или React Router нет, поэтому описанные выше недостатки присущи всем.
У рассматриваемых библиотек на троих более 25 миллионов скачиваний в неделю из npm, из которых около 20 миллионов приходится на React Router. Да, скачивания — это не прямой показатель количества приложений: один проект может пересобираться тысячи раз, а зависимость может подтягиваться транзитивно. Тем не менее такие цифры говорят о том, что React Router — это de-facto стандарт.
Возможно, сотни тысяч приложений построены на хрупком фундаменте. Или я ошибаюсь, и эти недостатки на практике не создают проблем. Для меня лично они существенны — поэтому я хочу предложить другой способ реализации маршрутизации.
Если традиционный роутинг создает столько проблем, давайте попробуем кардинально изменить подход. Попробуем привести код к виду, где роутер перестает нарушать принципы проектирования, и становится инструментом навигации, а не архитектурным каркасом.
Нам нужен минимальный контракт для роутера (полная реализация и песочницы с примерами в конце статьи):
interface MatchResult {}
export interface Router {
match(pattern: string): MatchResult | null;
// реализуем позднее
navigate(): void
}
Метод match
возвращает MatchResult
если текущий адрес страницы соответствует паттерну, или null
.
Нам также нужен минимальный контракт для страниц:
interface WithRouter {
router: Router;
}
interface WithNestedPages {
pages?: Page[]
}
export type Page = FC<WithRouter & WithNestedPages>;
То есть, страница принимает в качестве аргумента роутер и необязательный список вложенных страниц (nested routes).
// пример
export const UsersPage: FC<Page> = function({ router, pages }) {
if (!router.match('/users')) return;
return (
<div>
{pages.map(Page => <Page router={router} />)}
</div>
)
}
Инверсия зависимостей
Теперь роутер зависит от абстракции (интерфейса Router
), а компоненты – от абстракции роутера, а не наоборот.
Нет жестких зависимостей
// Раньше: жесткая привязка в роутере
<Route path="users/:id/settings" element={<UserSettings />} />
// Сейчас: компонент сам решает когда появляться
function UserSettings() {
if (!router.match('/users/:id/settings')) return;
return <div>Settings</div>;
}
Проще тестировать
// Теперь можно тестировать UserSettings изолированно
test('UserSettings renders correctly', () => {
const mockRouter = { match: mock.fn() };
// Симулируем совпадение
mockRouter.match.mockReturnValue({});
// Тестируем компонент без всего приложения
render(<UserSettings router={mockRouter} />);
});
Неплохой результат. Несмотря на использование JSX, мы достигли слабой связанности на уровне бизнес-логики и зависимостей. Сейчас наше приложение выглядит так:
// index.ts
import { router } from './Router'
import { HomePage } from '@/pages/home';
import { UsersPage } from '@/pages/users';
import { App } from './App'
const pages = [HomePage, UsersPage];
render(<App router={router} pages={pages} />)
// App.tsx
export const App: Page = function({ router, pages }) {
return (
<Layout>
{pages.map(Page => <Page router={router} />)}
</Layout>
)
}
// Users.tsx
import { UsersList } from './UsersList';
import { UserProfile } from './UserProfile';
import { UserSettings } from './UserSettings';
const nestedPages = [UsersList, UserProfile, UserSettings]
const RealUsersPage: Page = function({ router, pages }) {
return (
<Layout>
{pages.map(Page => <Page router={router} />)}
</Layout>
)
}
export const Users: Page = function({ router }) {
if (!router.match('/users')) return null;
return <RealUsersPage pages={pages} router={router} />
}
Обратите внимание, так как композиция у нас построена на интерфейсе Page
, мы легко можем подменить реализацию не меняя код выше. Здесь мы из Users
возвращаем другую реализацию – RealUsersPage
, куда передаем nestedPages
.
Роль Page-компонента – быть контроллером. Он определяет с помощью router.match()
, должен ли «обработать этот запрос» (текущий URL), и если да, то делегирует выполнение бизнес-логики и рендеринг нижележащим компонентам (PageImpl). Эти нижележащие компоненты и выступают в роли сервисов, непосредственно занимаясь получением данных и их преобразованием в UI (рендером).
По сути мы получили проверенную временем схему, которая обычно используется в бэкенде:
Классический бэкенд:HTTP Request
-> Controller
-> Service
-> HTTP Response
Наша реализация:location.pathname
-> Page
-> PageImpl
-> Render
В свою очередь PageImpl волен заниматься рендером так, как посчитает нужным, например, загружать свои компоненты лениво (lazy imports).
function PageImpl() {
return (
<Suspense fallback={<Loader />}>
<Component props={props} />
</Suspense>
)
}
По хорошему, наши App или Users должны зависеть от интерфейса, а сейчас это не так, у нас прямые импорты страниц. Есть разные способы это исправить, я же покажу самый, на мой взгляд, необычный. Для этого способа нужна дисциплина в команде, но он хорош!
Итак, у нас есть директория с основными страницами:
/pages
/Home
index.tsx
/Users
index.tsx
...
index.tsx
Экспортируем страницы из соответствующих директорий:
//pages/Home/index.tsx
export const Home: Page = function() {
return <></>
}
В корневом index.ts формируем наш модуль и собираем в нем страницы:
//pages/index.ts
export * from '/Home'
export * from '/Users'
И избавляемся от прямых импортов:
// index.ts
import { router } from './Router'
import { App } from './App'
import * as Pages from '@/pages';
const pages = Object.values(Pages); //
render(<App router={router} pages={pages} />)
Повторяем трюк для других модулей (в нашем случае для Users
) у которых есть вложенные страницы.
Плюсы такого метода:
Autodiscovery
Новые страницы автоматически подхватываются при их добавлении в директорию, при условии, что они экспортируются из index.ts
. Достаточно создать страницу и экспортировать её — и она "попадает в систему".
Типобезопасность на уровне композиции
Если в экспорт попало что-то лишнее (например, утилита, строка или компонент с другим API), TypeScript сразу выдаст ошибку в момент передачи pages
в <App />
. Если тайпскрипта нет – упадут тесты при первом запуске.
Упрощение рефакторинга
Если автор Users решит изменить название компонента на MyCoolUsersPage, это никого вокруг не затронет.
Частично соблюдаем Open/Closed Principle
Теперь наше приложение это композиция страниц и секций, а роутер – инструмент навигации, а не архитектурный каркас. Осталось заставить эту конструкцию работать, потому что очевидно – это еще не match.
Статья получилась бы слишком длинной, если бы я включил весь код в нее. Кому это интересно, предлагаю перейти на GitHub и ознакомиться, там всего 400 строк кода. Мы же пройдемся по формальным требованиям и основам.
Начнём с результата, который должен возвращать наш роутер. Это основа всего механизма сопоставления.
Path Variables
Для динамических маршрутов нам критически необходим механизм извлечения переменных из URL. Динамические сегменты будем обозначать фигурными скобками {}
, что интуитивно понятно и соответствует стилю многих REST API.
Пример:
Pattern: /users/{id}/edit
location.pathname: /users/1/edit
MatchResult.pathVariables: { id: 1 }
На первом этапе обойдёмся без строгой валидации типов параметров, ограничившись строковыми значениями.
Search Params
Также пока не будем усложнять валидацию query-параметров. Воспользуемся нативным и отлично работающим URLSearchParams
.
Hash
Просто сохраним значение хеша, если оно присутствует в URL.
Path
Это свойство крайне полезно для отладки и логирования, так как показывает, с каким именно шаблоном совпал текущий путь.
Итоговый интерфейс
interface MatchResult {
pathVariables: Record<string, string>;
searchParams: URLSearchParams;
hash: string;
path: string;
}
Для реализации сложных сценариев маршрутизации наш роутер поддерживает два специальных типа паттернов:
Wildcard /*
Паттерн со звездочкой работает как префиксный матчер — он совпадает с любым URL, который начинается с указанного префикса. Это создает своеобразный «контекст» или «область видимости» для вложенных компонентов.
Fallback /**
Специальный паттерн для обработки ненайденных маршрутов внутри Wildcard
. Компонент с таким паттерном будет отображаться, когда ни один другой маршрут внутри текущего wildcard (кроме самого wildcard) не совпал с текущим location.pathname.
Пример:
const Fallback: Page = function({ router }) {
if (!router.match('./**')) return null;
return <div>404</div>
}
const Users: Page = function({ router }) {
if (!router.match('/users/*')) return null;
return (
<div>
<UserProfile /> // if match ./{id}
<Fallback /> // if don't match ./{id}
</div>
)
}
Дополним интерфейс роутера одним свойством и приведем в окончательный вид методы match
и navigate
:
interface NavigateOptions {
replace?: boolean
state?: any
}
export interface Router {
match(pattern: string, component: Function): MatchResult | null;
navigate(to: string, options?: NavigateOptions): void
get routes(): Record<string, Route>;
}
В методе match
появился второй аргумент – component
. Нам он поможет решить сразу несколько задач:
Разрешение (резолвинг) относительных путей
Указывать абсолютный путь для каждой вложенной страницы неудобно. Наше API должно позволять использовать относительные пути (./new
, ./{id}/edit
). Компонент служит контекстом для вычисления абсолютного пути на основе пути его родителя.
Кеширование
При первом вызове match
для конкретного (pattern, component)
мы проводим "компиляцию" шаблона (создаём RegExp
, вычисляем абсолютный путь и т.д.). Последующие вызовы должны использовать закешированный результат.
Контроль дублей
Нам также важно отбрасывать ошибки в случаях, когда два роута из-за опечатки или невнимания разработчика используют один и тот же паттерн
Этот подход перекликается с классическим синтаксисом, знакомым разработчикам:
// React router
<Route path={path} component={Component}
// match
router.match(pattern, Component)
Классический метод для программной навигации, знакомый по всем популярным библиотекам.
Это readonly свойство необходимо в первую очередь для отладки и разработки. Оно предоставляет доступ для introspection — просмотра дерева всех зарегистрированных маршрутов, их состояний и взаимосвязей.
Ключевое требование — гранулярная реактивность.Критически важно, чтобы изменение URL (как через навигацию, так и через кнопки браузера) вызывало перерисовку только тех компонентов, для которых результат match()
изменился с null
на MatchResult
или наоборот.
Для этого нам потребуется реактивная система, которая:
При вызове match('/foo/bar')
точечно подпишет компонент на изменение результата совпадения для паттерна '/foo/bar'
.
При изменении этого результата вызовет ре-рендер только этого компонента.
Как скромный человек я возьму свое же решение – Observable. Об этой системе реактивности я рассказывал здесь, но наш роутер спроектирован так, что позволяет использовать любую совместимую систему реактивности, например MobX.
Напоследок научим роутер перехватывать события pushstate
, popstate
и replacestate
, и самое главное — обрабатывать клики по ссылкам.
Мы проектируем фреймворк-агностик роутер, и наличие специального компонента Link
в эту концепцию не укладывается. В нашей реализации переход по ссылкам будет автоматически обрабатываться нашим роутером, за исключением некоторых случаев:
window.addEventListener('click', event => {
if (!event.target || !(event.target instanceof HTMLElement)) return;
const a = event.target.closest('a');
if (!a) return;
if (a.origin !== location.origin) return;
if (event.ctrlKey || event.metaKey || event.button === 1) return;
if (a.target || a.download) return;
if (a.hasAttribute('data-no-spa'))
ev.preventDefault();
router.navigate(a.href);
});
То есть, мы обрабатываем все переходы по ссылкам, кроме случаев когда:
Ссылка ведёт на другой ресурс (другой origin)
Ссылка собирается открыться в другом окне/вкладке
Ссылка с атрибутом download
Пользователь намеренно открывает ссылку в новом окне/вкладке (независимо от атрибута target
)
Разработчик явно указал, что роутер не должен перехватывать переход по этой ссылке (атрибут data-no-spa
)
Благодаря этому, у нас нет необходимости в компоненте Link
, мы просто используем нативную семантику ссылок (теги <a>)
, и привычное поведение браузера:
<a href="/foo">Foo</a>
<a href="../">Foo</a>
<a href="./">Foo</a>
и т.д.
Мы закончили. Теперь наш роутер готов показать себя в деле. Главное — он работает одинаково хорошо в разных фреймворках. Я подготовил реализацию одного и того же приложения на трёх разных стеках:
Vue + наш роутер
Не судите строго, это мой первый опыт написания кода на Vue ))
В приложении используются сложные сценарии: динамические роуты с несколькими параметрами, wildcard-ы, fallback-и, компоненты, реагирующие на один паттерн, и нативная навигация без Route
. Это доказывает, что можно иметь единый подход к маршрутизации, не зависящий от фреймворка.
Роутер в ранней альфе, но в ближайшее время появится первая бета версия, а потом и релиз. Если вам интересно следить за его развитием, заглядывайте в телеграм, там будут анонсы.