Как стать автором
Поиск
Написать публикацию
Обновить

It's a match

Уровень сложностиСредний
Время на прочтение10 мин
Количество просмотров672

С ростом сложности фронтенда разработчики начали уделять больше внимания архитектуре. Кто-то предпочитает «чистую», кто-то — её производные, например, 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, монтировать родительские компоненты (LayoutUsersUserProfile) и обеспечивать все зависимости вышестоящих слоёв. Это превращает юнит-тесты в тяжёлые интеграционные тесты, полностью нивелируя преимущества модульного подхода.

Ещё раз отмечу, что существенной разницы между 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

Начнём с результата, который должен возвращать наш роутер. Это основа всего механизма сопоставления.

  1. Path Variables
    Для динамических маршрутов нам критически необходим механизм извлечения переменных из URL. Динамические сегменты будем обозначать фигурными скобками {}, что интуитивно понятно и соответствует стилю многих REST API.

    Пример:
    Pattern: /users/{id}/edit
    location.pathname: /users/1/edit
    MatchResult.pathVariables: { id: 1 }

    На первом этапе обойдёмся без строгой валидации типов параметров, ограничившись строковыми значениями.

  2. Search Params
    Также пока не будем усложнять валидацию query-параметров. Воспользуемся нативным и отлично работающим URLSearchParams.

  3. Hash
    Просто сохраним значение хеша, если оно присутствует в URL.

  4. 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.

Последние приготовления

Напоследок научим роутер перехватывать события pushstatepopstate и 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!

Мы закончили. Теперь наш роутер готов показать себя в деле. Главное — он работает одинаково хорошо в разных фреймворках. Я подготовил реализацию одного и того же приложения на трёх разных стеках:

  1. React + наш роутер

  2. SolidJS + наш роутер

  3. Vue + наш роутер
    Не судите строго, это мой первый опыт написания кода на Vue ))

В приложении используются сложные сценарии: динамические роуты с несколькими параметрами, wildcard-ы, fallback-и, компоненты, реагирующие на один паттерн, и нативная навигация без Route. Это доказывает, что можно иметь единый подход к маршрутизации, не зависящий от фреймворка.


Роутер в ранней альфе, но в ближайшее время появится первая бета версия, а потом и релиз. Если вам интересно следить за его развитием, заглядывайте в телеграм, там будут анонсы.

Теги:
Хабы:
+4
Комментарии1

Публикации

Ближайшие события