С ростом сложности фронтенда разработчики начали уделять больше внимания архитектуре. Кто-то предпочитает «чистую», кто-то — её производные, например, 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 строк кода. Мы же пройдемся по формальным требованиям и основам.
Интерфейс MatchResult
Начнём с результата, который должен возвращать наш роутер. Это основа всего механизма сопоставления.
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>
)
}
Интерфейс Router-а
Дополним интерфейс роутера одним свойством и приведем в окончательный вид методы 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()
В методе match
появился второй аргумент – component
. Нам он поможет решить сразу несколько задач:
Разрешение (резолвинг) относительных путей
Указывать абсолютный путь для каждой вложенной страницы неудобно. Наше API должно позволять использовать относительные пути (./new
,./{id}/edit
). Компонент служит контекстом для вычисления абсолютного пути на основе пути его родителя.Кеширование
При первом вызовеmatch
для конкретного(pattern, component)
мы проводим "компиляцию" шаблона (создаёмRegExp
, вычисляем абсолютный путь и т.д.). Последующие вызовы должны использовать закешированный результат.Контроль дублей
Нам также важно отбрасывать ошибки в случаях, когда два роута из-за опечатки или невнимания разработчика используют один и тот же паттерн
Этот подход перекликается с классическим синтаксисом, знакомым разработчикам:
// React router
<Route path={path} component={Component}
// match
router.match(pattern, Component)
navigate()
Классический метод для программной навигации, знакомый по всем популярным библиотекам.
routes
Это 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>
и т.д.
It's a match!
Мы закончили. Теперь наш роутер готов показать себя в деле. Главное — он работает одинаково хорошо в разных фреймворках. Я подготовил реализацию одного и того же приложения на трёх разных стеках:
Vue + наш роутер
Не судите строго, это мой первый опыт написания кода на Vue ))
В приложении используются сложные сценарии: динамические роуты с несколькими параметрами, wildcard-ы, fallback-и, компоненты, реагирующие на один паттерн, и нативная навигация без Route
. Это доказывает, что можно иметь единый подход к маршрутизации, не зависящий от фреймворка.
Роутер в ранней альфе, но в ближайшее время появится первая бета версия, а потом и релиз. Если вам интересно следить за его развитием, заглядывайте в телеграм, там будут анонсы.
Комментарии (14)
Vitaly_js
04.09.2025 13:33Попробую дать небольшую обратную связь.
Сразу видны нарушения принципов, которым мы пытаемся следовать при проектировании. Например:
Здесь модуль Router зависит от низкоуровневых модулей (конкретных компонентов)
Само по себе это врядли нарушение. Все зависит от принятых на проекте паттернов. Например, находясь в слайсе app в FSD он и должен зависеть от низкоуровневых модулей. И нарушение принципа тогда не будет.
Чтобы добавить новый роут, например /users/:id/comments, нужно изменить код роутера.
Опять же, если это у нас посредник, который обобщает в себе настройку разных элементов, то он и должен изменяться.
/router.tsx отвечает и за создание роутера, и за рендеринг.
Тут опять можно обратиться к предыдущему пункту. То, что посредник настраивает и вызывает представления не является каким-то криминалом.
Иными словами данный модуль является местом инкапсулирующим способ взаимодействия множества объектов. Именно тут слабо связные объекты настраивают друг для друга.
Ещё один существенный недостаток такой реализации — проблема тестируемости. Мы не можем просто протестировать UserSettings
Почему? Вы даже сами создали отдельный элемент
<UserSettings />
Так же можете его создать в любом другом месте и тестировать совершенно независимо от всего остального.
Для меня лично они существенны — поэтому я хочу предложить другой способ реализации маршрутизации.
Вообще, сейчас Реакт Роутер можно использовать по разному. И вы показали только один способ.
Нет жестких зависимостей
function UserSettings() { if (!router.match('/users/:id/settings')) return; return <div>Settings</div>; }
Получается следующее, у вас некоторая сущность UserSettings во первых, занимается не только собственной поставленной задачей вывода
<div>Settings</div>
, но еще и обслуживает задачи маршрутизации. Что очень похоже на наружение принципа единственной ответственности. Вот для тестирования такого UserSettings действительно нужно поднимать обертку обеспечивающую маршрутизацию. Что вы в тестах и делаете.А во вторых, в данном примере используете хардкод маршрута. Т.е. даже если не меняется задача, которую выполняет UserSettings вам придется лезть и менять этот файл, если поменяются марштуры.
Неплохой результат. Несмотря на использование 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} />)
Данный index.ts как раз и является аналогом изначального файла роутера. Он так же зависим от всех "ниже лежащих" модулей и конфигурирует их зависимости.
И это тоже любопытно:
Скрытый текст
// 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} /> }
Если честно, я не очень понимаю что тут происходит. Например, зачем вам отдельный RealUsersPage? Ваш User в котором захардкожен маршрут, имеет так же захардкоженный RealUsersPage. Иными словами, вы не можете к данном маршруту привязать другой RealUsersPage. Поэтому существование отдельного RealUsersPage просто бессмысленно.
Обратите внимание, так как композиция у нас построена на интерфейсе Page, мы легко можем подменить реализацию не меняя код выше.
Реализацию чего? Что вы решили подменять? У вас все захардкожено на один маршрут /users. Если подменить реализацию, то это будет другой маршрут, а не другая реализация данного маршрута.
По сути мы получили проверенную временем схему, которая обычно используется в бэкенде:
Классический бэкенд:
HTTP Request -> Controller -> Service -> HTTP Response
Во фреймворках обычно контроллеры не имеют связи с маршрутом. Например, если вы будете использовать React Router как фрэймворк. Маршрут у вас будет отдельно от контроллера:
route("/users", "./users.tsx")
Проблема прямых импортов
И избавляемся от прямых импортов:
import { router } from './Router' import { App } from './App' import * as Pages from '@/pages'; const pages = Object.values(Pages); // render(<App router={router} pages={pages} />)
Вот вы пишете:
По хорошему, наши App или Users должны зависеть от интерфейса, а сейчас это не так, у нас прямые импорты страниц.
Не понял. Что не так? Вам не понравилось что index делает импорт страниц и вы сделали их реимпорт из другого места? Но прямо имопорт страниц из модуря pages никуда не делся. У вас index.ts как зависел от этих модулей так и зависит, только теперь добавилась транзитивная зависимость.
По сути, у вас как были захардкожены зависимости на уровне модулей так все и осталось. Просто теперь все будет находится в отдельных файлах и иметь транзитивные зависимости.
Новые страницы автоматически подхватываются при их добавлении в директорию, при условии, что они экспортируются из index.ts. Достаточно создать страницу и экспортировать её — и она "попадает в систему".
То, что вы написали - это называется вручную, а не автоматически. То, что вы вручную добавляете в один файл, а не в другой дело не меняет. Вот если бы у вас работала утилита, которая бы автоматически генерировала index.ts, тогда это было бы автоматически.
Типобезопасность на уровне композиции
Какой композиции? Вы реэкспорт из index файла называете композицией?
Упрощение рефакторинга
Если автор Users решит изменить название компонента на MyCoolUsersPage, это никого вокруг не затронет.
Например, в FSD так же есть реэкспорты из index. Поэтому пока публичный апи не изменится все будет локализовано в пределах слайса.
Частично соблюдаем Open/Closed Principle
Вот в этом плане вообще ничего не изменилось, по моему.
-----
Я посмотрел финальный результат.
Там так все наворочено, что разобраться просто читая сверху вниз довольно трудно.
Например:
Скрытый текст
const BarNestedStaticRouteStaticRoute = observer( function barNestedStaticRouteStaticRoute() { const result = router.match('./nested', barNestedStaticRouteStaticRoute); if (!result) return null; return ( <div className="route"> <div> It's a match for <code>./nested/</code> relative to{' '} <code>./static/*</code> relative to <code>/bar/*</code> </div> </div> ); } ); const BarNestedStaticRoute = observer(function barNestedStaticRoute() { if (!router.match('./static/*', barNestedStaticRoute)) return null; return ( <div className="route"> <div> It's a match for <code>./static/*</code> relative to <code>/bar/*</code> </div> <BarNestedStaticRouteStaticRoute /> </div> ); });
У меня даже слов нету...
nihil-pro Автор
04.09.2025 13:33Спасибо за комментарий, попробую ответить также развернуто.
Само по себе это врядли нарушение. Все зависит от принятых на проекте паттернов. Например, находясь в слайсе app в FSD он и должен зависеть от низкоуровневых модулей. И нарушение принципа тогда не будет.
Если вы в проекте придумали свои паттерны, следуете им, для вас они понятны и приемлемы, то вы, безусловно, правы. В статье речь про принципы SOLID и Clean Architecture, а они как раз нарушаются.
Опять же, если это у нас посредник, который обобщает в себе настройку разных элементов, то он и должен изменяться.
Это не посредник, а сервис-локатор. Кроме того, это нарушает OCP.
В моей реализации новая страница сама включается в систему через автодискавери, как в бэкенде (например, FastAPI с автозагрузкой роутов), а не через внесение изменений в роутер.Тут опять можно обратиться к предыдущему пункту. То, что посредник настраивает и вызывает представления не является каким-то криминалом.
Иными словами данный модуль является местом инкапсулирующим способ взаимодействия множества объектов. Именно тут слабо связные объекты настраивают друг для друга.
Эти объекты слабо связаны друг с другом, и очень жестко с роутером.
Почему? Вы даже сами создали отдельный элемент
<UserSettings />
Так же можете его создать в любом другом месте и тестировать совершенно независимо от всего остального.
Это не так. Чтобы протестировать этот компонент с react-router, нужно смоделировать весь стек роутинга:
RouterProvider
,createBrowserRouter
и т.д. Если ваш компонент использует useMatch, например, то без провайдера он просто упадет с ошибкой.Вообще, сейчас Реакт Роутер можно использовать по разному. И вы показали только один способ.
В некотором смысле да, но любой способ требует обернуть приложение в RouterProvider.
Получается следующее, у вас некоторая сущность UserSettings во первых, занимается не только собственной поставленной задачей вывода
<div>Settings</div>
, но еще и обслуживает задачи маршрутизации. Что очень похоже на наружение принципа единственной ответственности. Вот для тестирования такого UserSettings действительно нужно поднимать обертку обеспечивающую маршрутизацию. Что вы в тестах и делаете.Тут могу ответить цитатой из статьи:
Роль Page-компонента – быть контроллером. Он определяет с помощьюrouter.match()
, должен ли «обработать этот запрос» (текущий URL), и если да, то делегирует выполнение бизнес-логики и рендеринг нижележащим компонентам (PageImpl). Эти нижележащие компоненты и выступают в роли сервисов, непосредственно занимаясь получением данных и их преобразованием в UI (рендером).
Так получилось, что в реакте у нас все компонент, но если абстрагироваться и вспомнить что это в первую очередь функция, то все встает на свои места.А во вторых, в данном примере используете хардкод маршрута. Т.е. даже если не меняется задача, которую выполняет UserSettings вам придется лезть и менять этот файл, если поменяются марштуры.
Конечно, это же просто пример. Хардкод пути в
match
, ничем не хуже хардкода пути в<Route path="..." />
, и ничто не мешает вынести его в константу.Данный index.ts как раз и является аналогом изначального файла роутера. Он так же зависим от всех "ниже лежащих" модулей и конфигурирует их зависимости.
В некотором смысле да, и поэтому ниже предлагается решение. Но даже без этого решения есть разница. Здесь App импортирует только модули первого уровня (Home, Users), но не UsersSettings, UserProfile.
Если честно, я не очень понимаю что тут происходит. Например, зачем вам отдельный RealUsersPage? Ваш User в котором захардкожен маршрут, имеет так же захардкоженный RealUsersPage. Иными словами, вы не можете к данном маршруту привязать другой RealUsersPage. Поэтому существование отдельного RealUsersPage просто бессмысленно.
Page интерфейс. App рендерит верхний модуль Users, но у Users есть вложенные страницы, которые он принимает пропсами. Можно передать их из App, но тогда App начинает зависеть от внутренней реализации Users. В этом примере мы из Users возвращаем другую реализацию Users в которою передаем вложенные страницы.
Это также позволяет делать что-то такое:const isExperimental = localStorage.getItem('layout') === 'new'; return isExperimental ? <NewUsersPage pages={nestedPages} router={router} /> : <OldUsersPage pages={nestedPages} router={router} />;
Во фреймворках обычно контроллеры не имеют связи с маршрутом.
// классический контроллер в Java @RequestMapping("instances") public class InstanceController { @GetMapping("{id}") public InstanceDto getById(@PathVariable UUID id) { return service.get(id); } }
Не понял. Что не так? Вам не понравилось что index делает импорт страниц и вы сделали их реимпорт из другого места? Но прямо имопорт страниц из модуря pages никуда не делся. У вас index.ts как зависел от этих модулей так и зависит, только теперь добавилась транзитивная зависимость.
Это не совсем так. Было так:
import { HomePage } from '@/pages/home'; import { UsersPage } from '@/pages/users';
Стало так:
import * as Pages from '@/pages';
То есть, теперь мы зависим от интерфейса ESM модуля, доступного по алиасу pages. Это можно читать как:
type Pages = Record<string, Page>
То, что вы написали - это называется вручную, а не автоматически. То, что вы вручную добавляете в один файл, а не в другой дело не меняет.
Меняет. В моем случае добавление новой страницы выглядит так: Создать в директории pages новую страницу, включить ее в index.ts. В случае с react-router тоже самое + импортировать ее в router.
Какой композиции? Вы реэкспорт из index файла называете композицией?
render( <App router={router} // тут будет ошибка если в экспорт попало не то что нужно pages={pages} />)
Вот в этом плане вообще ничего не изменилось, по моему.
Изменилось. Нам больше не нужно редактировать файл App.tsx при добавлении новой страницы.
Я посмотрел финальный результат.
Там так все наворочено, что разобраться просто читая сверху вниз довольно трудно.
В этом и смысл. Это «децентрализованный» роутер, у него нет файла конфигурации с описанием роутов, а пример демонстрирует, что даже в запутанном графе компонентов все роуты корректно определяются и регистрируются, в том числе относительные, с учетом скоупа.
Еще раз спасибо за развернутый комментарий.
Vitaly_js
04.09.2025 13:33Честно говоря, яснее не стало.
В статье речь про принципы SOLID и Clean Architecture, а они как раз нарушаются.
Если вы про Чистую архитектуру от гугла, то стоит тогда для примеров использовать патерны гугла, что бы было понятно где нарушается архитектура. Если про какую то другую чистую архитектуру, то тоже стоит ясно указать слои и нарушение между слоями.
Солид тоже сам по себе не нарушается. У вас всегда при разработке по Солиду будут места, которые и предназначены для настройки конкретной конфигурации объектов. А значит будут иметь зависимости от конкретных классов.
В вашем подходе все ровно тоже самое. Т.е. найдутся модули, которые будут иметь зависимости от конкрентных классов.
Это не посредник, а сервис-локатор. Кроме того, это нарушает OCP.
Что такое "сервис-локатор"? Какой у него шаблон? Почему нарушается OCP? Иными словами, вот у вас есть задача создать не переиспользуемый модуль. Шаблона у вас нет. Почему тогда нарушается OCP? Дальше вы так же будете создавать не переиспользуемые модули.
Эти объекты слабо связаны друг с другом, и очень жестко с роутером.
Нет, это модуль роутера жестко с ними жестко связан, а они с ним вообще не связаны. Им глубоко безразлично где их будут подключать к приложению.
Это не так. Чтобы протестировать этот компонент с react-router, нужно смоделировать весь стек роутинга: RouterProvider, createBrowserRouter и т.д. Если ваш компонент использует useMatch, например, то без провайдера он просто упадет с ошибкой.
А это зачем? Вы что собрались тестировать? Судя по названию UserSettings - это компонент отвечающий за какую-то бизнес логику. Если он содержит в себе какие-то вложенные маршруты, то нужна обертка, а если нет, то обертка не нужна.
В некотором смысле да, но любой способ требует обернуть приложение в RouterProvider.
И что из этого следует?
Так получилось, что в реакте у нас все компонент, но если абстрагироваться и вспомнить что это в первую очередь функция, то все встает на свои места.
Подождите. Вы рассматриваете приложение как систему из конфигурации компонентов. У компонентов есть назначение. Это назначение определяет место компонента в архитектуре приложения. Поэтому причем тут функция? Компонент нужно рассматривать по назначению в архитектуре приложения.
Вот дали вы компоненту имя UserSettings. Это тот же термин который вы использовали раньше. В общем-то все равно, класс это, функция, модуль и т.д. и т.п. Но это какой-то строительный блок. Ваш строительный блок отвечает и за маррутизацию, и за какую-то задачу. Вот об этом речь. При этом вся реализация захардкожена.
Поэтому я и отослался на то, что контроллеры обычно не такие.
Конечно, это же просто пример. Хардкод пути в match, ничем не хуже хардкода пути в <Route path="..." />, и ничто не мешает вынести его в константу.
Так в этом и есть суть вопроса. Что в начальном, что в вашем случае у вас есть место, где все зависит от конкретных реализаций. Литерал это или экспортируемая константа суть не меняет. Но в первом случае нечто UserSettings не знает о ней, а в вашем случае знает.
В некотором смысле да, и поэтому ниже предлагается решение. Но даже без этого решения есть разница. Здесь App импортирует только модули первого уровня (Home, Users), но не UsersSettings, UserProfile.
Так и в первом случае вам ничто не мешало раскидать все по разным файлам. Т.е. вообще ничего не меняете, а просто вырезаете куски и кидаете по разным модулям.
Page интерфейс. App рендерит верхний модуль Users, но у Users есть вложенные страницы, которые он принимает пропсами. Можно передать их из App, но тогда App начинает зависеть от внутренней реализации Users. В этом примере мы из Users возвращаем другую реализацию Users в которою передаем вложенные страницы.
То, что вы написали означает, что App транзитивно связан со вложенными страницами Users. Иными словами, это ровно тоже самое что было в начале, если бы вы просто вынесли какие-то маршруты в отдельный файл. Вы обсолютно так же в этом отдельном файле без избыточного интерфейса Page можете условно выбирать любые вложенные страницы.
Вы нигде не разрушили связь. У вас всегда все связано на уровне модулей.
@RequestMapping("instances") public class InstanceController { @GetMapping("{id}") public InstanceDto getById(@PathVariable UUID id) { return service.get(id); } }
Вы имете в виду, что тут маршрут /instances? Это из какого-то фреймворка?
То есть, теперь мы зависим от интерфейса ESM модуля, доступного по алиасу pages. Это можно читать как:
Смотреть на это можно и под таким углом. Но так как вы знакомы с Солид, то ситуация тут такая, что один модуль завязан на реализацию другого модуля. Т.е. у нас тут жесткие связи между модулями.
Какой композиции? Вы реэкспорт из index файла называете композицией?
render( <App router={router} // тут будет ошибка если в экспорт попало не то что нужно pages={pages} />)
Где тут композиция? По факту, вы создаете элемент App и передаете ему на вход pages. Само по себе, это не композиция. Просто у вас один модуль жестко связан с другими модулями.
Вот если бы вы в App импортировали компоненты Pages и тут же декларативно указывали как должно и где создаваться элементы - это была бы композиция.
Изменилось. Нам больше не нужно редактировать файл App.tsx при добавлении новой страницы.
Так и раньше можно было легко взять и раскидать все по разным файлам. Никто этому не мешал.
В этом и смысл. Это «децентрализованный» роутер, у него нет файла конфигурации с описанием роутов, а пример демонстрирует, что даже в запутанном графе компонентов все роуты корректно определяются и регистрируются, в том числе относительные, с учетом скоупа.
Конкретно тут вы привели пример централизованного роутера. То, что все это можно раскидать по файлам, не существенно в том плане, что ровно так же и раньше можно было все просто раскидать по файлам.
nihil-pro Автор
04.09.2025 13:33Интересно, что вы сначала уверено утверждаете:
Во фреймворках обычно контроллеры не имеют связи с маршрутом.
А в ответ на предоставленный пример, спрашиваете:
Вы имете в виду, что тут маршрут /instances? Это из какого-то фреймворка?
Это из любого.
Java
@RequestMapping("instances") public class InstanceController { @GetMapping("{id}") public InstanceDto getById(@PathVariable UUID id) { return service.get(id); } }
Nest.js
@Controller('instances') export class InstanceController { constructor(private readonly service: InstanceService) {} @Get(':id') getById(@Param('id') id: string): InstanceDto { const parsedId = Number(id); // or use UUID lib: uuid.validate(id) const instance = this.service.get(parsedId); if (!instance) { throw new NotFoundException('Instance not found'); } return instance; } }
.NET
[Route("instances")] [ApiController] public class InstanceController : ControllerBase { private readonly IInstanceService _service; public InstanceController(IInstanceService service) { _service = service; } [HttpGet("{id}")] public ActionResult<InstanceDto> GetById(Guid id) { var instance = _service.Get(id); return Ok(instance); } }
GO
func controller() { service := &InstanceService{} instanceGroup := app.Group("/instances") instanceGroup.Get("/:id", func(c *fiber.Ctx) error { idParam := c.Params("id") id, err := uuid.Parse(idParam) if err != nil { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "error": "Invalid UUID", }) } instance := service.Get(id) return c.JSON(instance) }) }
Вы уверено аргументируете, но я не вижу наличия экспертизы.
totsamiynixon
04.09.2025 13:33Рискну утвердить, что Вы слишком глубоко погрузились в идеи SOLID, Clean Architecture и в целом в Энтерпрайз паттерны. У Вас в руках появился молоток, и теперь все вокруг кажется гвоздями.
Это не посредник, а сервис-локатор. Кроме того, это нарушает OCP.
По Dependency Inversion верхнеуровневый модуль здесь это
react-router
. Код в файлеrouter
это низкоуровневый код, по сути конфигурация роутера. Именноreact-router
обеспечивает OCP за счёт возможности модифицировать его поведение, внедрять хуки и ТД. Это необходимо, потому что роутер используется в миллионах приложений и надо сделать его максимально генерик. Ваше предложение не генерик - это вполне конкретное приложение с вполне конкретной конфигурацией. А конфигурация должна быть простой и достаточно часто она централизована (примеров этому масса в разных фреймворках).Вы пытаетесь написать свой роутер. У которого своим "правильным" образом реализован DI, OCP и тд. И уже по результатам видно, что прямо кардинально лучше и чище конфигурация роутинга не стала. Можете просто сравнить число строк в вашей имплементации против
react-router
на вашем реальном боевом приложении. Да, теперь конфигурация децентрализованная. Ну так и у next js она децентрализованная. И dev experience получше у next js.Если Вам нравится экспериментировать и искать более лучшее генерик решение и за счёт этого прокачиваться в кодинга - тогда ок. Но будьте осторожны с тем, чтобы тянуть такие эксперименты в боевые проекты, особенно долгоживущие.
Vitaly_js
04.09.2025 13:33По Dependency Inversion верхнеуровневый модуль здесь это
react-router
По идее, верхнеуровневый модуль - это тот который использует react-router и находясь на самом верху имеет связи с нижестоящими модулями. Поэтому внутри router наоборот самый высокоуровневый код. В данном конкретном примере из статьи.
totsamiynixon
04.09.2025 13:33Ноу, верхнеуровневый модуль это тот, который управляет низкоуровневым. Для простоты понимания -
react-router
определяет интерфейс: как задавать роуты, какие есть параметры, какой жизненный цикл и тд.В контексте использования роутера из кода приложения - это отношение use, т.е. прямая зависимость. Не инверсированная.
nihil-pro Автор
04.09.2025 13:33У Вас в руках появился молоток, и теперь все вокруг кажется гвоздями.
Кажется что в статье моя позиция достаточно обоснована. Я не слепо пытаюсь следовать каким-то принципам, а описываю реальные проблемы. Если для тестирования отдельного компонента мне нужно запускать приложение с корня, настраивать контексты и т.д., то это мне создает реальные проблемы.
По Dependency Inversion верхнеуровневый модуль здесь это
react-router
.Модуль не становится верхнеуровневым, только из-за того, что вы физически расположили выше в иерархии ваших компонентов. В классической чистой архитектуре, верхнеуровневый модуль это бизнес-логика.
Вы пытаетесь написать свой роутер. У которого своим "правильным" образом реализован DI, OCP и тд. И уже по результатам видно, что прямо кардинально лучше и чище конфигурация роутинга не стала.
Реализация своего роутера это следствие того, чего я пытаюсь достичь. Из-за компонентной модели реакта, это действительно кажется необычным. Суть же в другом. С помощью этого достигается то, что реальный компонент отвечающий за отображение, например, пользователя, вообще не знает о существовании роутера и о том, что мы приняли решение отобразить его в браузере по ссылке /users/1, мы это контролируем в слое выше, в данном случая на основе pathname. Можно себе представить другой интерфейс, например чат-бот, и тогда функцию pathname будет выполнять запрос пользователя.
Компоненты типаPage
это способ зарегистрировать контроллеры которые декларируют какие пути они обрабатывают, а обработав пути вызывают соответствующие сервисы.totsamiynixon
04.09.2025 13:33Модуль не становится верхнеуровневым, только из-за того, что вы физически расположили выше в иерархии ваших компонентов. В классической чистой архитектуре, верхнеуровневый модуль это бизнес-логика.
Ответил выше.
С помощью этого достигается то, что реальный компонент отвечающий за отображение, например, пользователя, вообще не знает о существовании роутера и о том, что мы приняли решение отобразить его в браузере по ссылке /users/1, мы это контролируем в слое выше, в данном случая на основе pathname.
Весь этот код расположен в роутере. Поэтому страница в общем случае не знает, по какому пути она расположена. Об этом знает роутер.
Получается страницу больше беспокоят ее параметры, чем конкретный путь / роут. Возможно так же страницу беспокоит, параметр пришел из пути запроса или квери стринги - может повлиять на локальное кеширование.
Можно себе представить другой интерфейс, например чат-бот, и тогда функцию pathname будет выполнять запрос пользователя.
Во-первых, в статье я не видел, чтобы были озвучены такие залачи, чтобы роутер работал и для чат бота и для web UI и для голосового управления.
Во-вторых, в таком случае просто необходимы отдельные роутеры для чат бота и для web UI и для мобилки и для десктопа. Тут встаёт вопрос внутренней навигации между страницами. В таком (и только в таком) случае, имеет смысл уже делать какую-то абстракцию, помня, что любая абстракция более ограниченна, чем любая конкретная реализация. И тогда будет верхнеуровневый интерфейс роутера, в который будет передаваться ключ роута и его параметры (желательно это типизировать как-то). Далее к этой новой абстракции будет написано несколько адаптеров - один на
react-router
для web UI, другой для чат-бота и тд. Соответственно будет 2 точки входа (в самом приложении может быть одна, но конфигурации будет 2, соответственно 2 ручки для запуска с разными конфигурациями и корнями композиции). И тогда уже можно говорить, что такой роутер имеет смысл для решения вашей конкретной задачи с вашими контрактными ограничениями. Но абстракция этого роутера будет ограничена - будет просто ключ роута и параметры. Но не будет возможности определить, параметры пришли из пути запроса или из квери стринги или из deep link и тд (пока не произойдет приведения более общего роутера к более частному, что сломает абстракцию, и все равно привяжет имплементацию к частному).Если смотреть с такой точки зрения - тогда всё ещё Ваше решение слишком сложное. Достаточно сделать `IAppRouter.navigate(key, params)` и forward/back/refresh/currentRoute и сделать разные адаптеры под разные контексты. И потом в страницу делать инъекцию роутера (по интерфейсу) и давать смотреть параметры текущего роута. Выбор адаптера происходит либо в build time в зависимости от конфигурации билда или в runtime в зависимости от переданной конфигурации. И ещё до кучи допилить свои интерфейсы аналоги
react-router
вродеLink routeKey, routeParams
и тд.Далее делаете один мок для
IAppRouter
и проверяете, что страница себя ведёт так, как ожидается, в зависимости отcurrentRoute
, а так же, что вызываются соответствующие методы, когда Вы того ожидаете, вродеnavigate,back,forward
при нажатии на ту или иную кнопку. Все тестируется и не требует долго разбираться в Вашем уникальном подходе.И OCP будет заключаться не в том, как легко добавлять новые страницы страницы, а в том, как легко добавлять новые типы роутеров.
nihil-pro Автор
04.09.2025 13:33Ответил выше.
Это ваша собственная интерпретация чистой архитектуры, которую вы выдаете за правду в последней инстанции.
totsamiynixon
04.09.2025 13:33Это интерпретация принципа Dependency Inversion. А точнее констатация.
Чистая архитектура это про отделение инфраструктуры от аппликейшена, нужна для того, чтобы можно было менять инфраструктуру не меняя код бизнес логики. Или разрабатывать и тестировать бизнес логику в отрыве от инфраструктуры (не важно какой сервер, сеть, диск, база данных и ТД), а потом подключать адаптерами ту инфраструктуру, которая лучше подходит для обеспечения бизнес логики.
UI (весь) это только один из адаптеров к входящим портам бизнес логики: web UI, консоль, HTTP API, RPC, чат бот. Может ли быть полезным проектировать адаптеры по принципам чистой архитектуры? Может, если Вы проектируете фреймворк, на котором потом будет реализовываться различные адаптеры.
В вашем случае архитектура библиотеки может быть и чистая, но это не значит, что у реакт роутера она не чистая. Поэтому вопрос здесь только в удобстве реализации адаптера, и реакт роутер пока выглядит лучше. Как минимум "гейм ченжинг" этот подход точно не назовешь.
Даже синтаксически, я бы вместо того, чтобы добавлять код проверки роута внутрь самого компонента и если не матч вернуть null - сделал бы что-то вроде
const Page = () => some code Page.matchRoute = (context) => return true/false.
Будем считать это псевдокодом. И в билд тайме обходил бы дерево компонентов и генерировал бы граф страниц, подкидывал их в роутер в билд тайме опять же и потом в рантайме на каждую навигацию вызывал matchRoute для каждого компонента, у которого нашлась такая функция. И рендерил бы первый компонент, для которого matchRoute вернул тру.
DDeenis
04.09.2025 13:33Довольно странный аргумент насчет тестирования. Я так понимаю, имеються ввиду ситуации, когда необходимо протестировать компонент который использует хуки из react-router. Однако, специально для таких случаев, в react-router есть
createRoutesStub
, а в более ранних версияхMemoryRouter
nihil-pro Автор
04.09.2025 13:33Однако, специально для таких случаев, в react-router есть
createRoutesStub
, а в более ранних версияхMemoryRouter
Вы назвали мой аргумент странным, но приводите в качестве контр-аргумента тот факт, что проблема с тестированием из-за неправильной архитектуры настолько существенна, что сама библиотека вынуждена выпустить костыль чтобы хоть как-то ее решить.
CloudlyNosound
Как говорит один мой знакомый: "с ростом сложности архитектуры бэка, разработчики стали уделять больше времени фронту.". %)