Роутинг мечты, который не ломает архитектуру

Kelin2025

Anton Kosykh

10/27/2021 · Сколько-то там минут

TL;DR: Потрогать роутер можно тут: тык по ссылочке

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

В частности - инлайним пути, внедряя по всему приложению лишнюю ответственность и увеличивая связность; используем react-router, который затаскивает логику в компоненты и не даёт нормально обратиться извне; иногда вообще жёстко подвязываемся на браузерное API и лишаемся возможности использовать тот же код в других средах (SSR или RN, например)

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

Так вот. Я написал такой роутер и готов рассказать о том, как к нему пришёл и почему я принял те или иные решения. Основные идеи

Избавляемся от чистых URL'ов

Во-первых, от инлайн ссылок в приложениях уже давно пора избавиться.

const PostCard = () => {
  return (
    <Card>
      {/* Плохо */}
      <Link to={`/posts/${postId}`}>Перейти к посту</Link>
    </Card>
  );
};

// Тоже плохо
userAuthorized.watch(() => {
  location = '/dashboard';
};

На вопрос "Почему?" ответ простой - с таким подходом у нас нет возможности узнать, где сей роут был задействован.

Если адрес роута поменяется или появится какой-то новый параметр - придётся через Ctrl+F ходить по всей кодовой базе и искать, где же он был использован.

И дай бог, чтобы это был статичный роут, а не с префиксами, параметрами итд - такой и через Ctrl+F не найти.

Посему лучше сделать так:

// Создаём роут
export const homeRoute = createRoute()

// Используем его
<Link to={homeRoute}>Home</Link>

Также мы можем использовать дженерики TypeScript'a и типизировать роут:

// Теперь мы знаем, какие есть параметры
export const postRoute = createRoute<{ postId: string }>()

// И TypeScript может подсказать об ошибках
<Link to={postRoute} params={{ possId: 'foo' }}>Open "Foo"</Link>

Засчёт столь простого действия мы получили сразу кучу плюшек:

  • Знаем, какие в принципе роуты есть в проекте
  • При обращении к роуту будем видеть подсказки о том, какие у него параметры => не нужно лезть в жопу мира, чтобы вспомнить, как там назвали айдишник
  • В случае изменения/удаления роута сборщик выдаст ошибку в местах, где он использовался => точно не забудешь о той странице, которую никто не редачил 5 лет

Пути проставляем отдельно

Теперь нам нужно спуститься на землю и понять, как проставлять пути. Самый простой вариант - дать роутам метод .setPath(path)

// pages/home/route.ts
export const homeRoute = createRoute()

// pages/home/posts.ts
export const postsRoute = createRoute()

// pages/home/single-post.ts
export const singlePostRoute = createRoute()
// app/routes.ts
homeRoute.setPath('/')
postsRoute.setPath('/posts/:page')
singlePostRoute.setPath('/post/:postId')

Но мне такой подход не нравится. С одной стороны, роуты смогут самостоятельно определять, открыты они или нет. С другой стороны, они получают лишнюю логику. А если у нас SSR? А если нам не нужны урлы (Electron-приложение или RN)?

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

const routes = [
  { path: '/bots', route: botsListRoute },
  { path: '/chat-map/new', route: createChatMapRoute },
  { path: '/chat-map/edit/:chatMapId', route: editChatMapRoute },
  { path: '/', route: homeRoute },
]

const router = createHistoryRouter({ routes })

history будем прокидывать отдельным методом, чтобы при сетапе с SSR не пачкать код костылями с env-переменными, а просто вызвать метод в разных энтрипоинтах:

router.setHistory(createBrowserHistory())

/* инициализируем клиент */
export async function render(url: string) {
  const history = createMemoryHistory();
  history.push(url, {});
  
  router.setHistory(history)
  
  /* рендерим html в строку */
}

Интегрируем со стейт-менеджером

Поскольку мы создаём роут функцией, ничто не мешает нам положить туда и и функционал. Чтобы его можно было открыть, покинуть и знать текущие параметры.

К примеру, с Effector это должно выглядеть как-то так:

const postsRoute = createRoute<{ page: number }>();
  
// Состояние роута
postsRoute.$isOpened;    // Открыт оли
postsRoute.$params;      // Текущие параметры
postsRoute.$query;       // Текуший query
  
// Действия
postsRoute.open({ page: 1 }); // Открыть роут
  
// События
postsRoute.opened.watch(console.log);    // Открылся
postsRoute.updated.watch(console.log);   // Апдейтнулся
postsRoute.left.watch(console.log);      // Закрылся

И тогда мы можем красиво работать с роутами, как с любой другой бизнес-логикой:

export const getPostsFx = createEffect<{ page: number }, Post[]>(async () => {
  return api.get(`/posts/${page}`);
});

export const $postsList = restore(getPostsFx, []);

const postsRoute = createRoute<{ page: number }>();

// Когда параметры роута обновились и мы на нём,
// берём параметры и фетчим посты
guard({
  clock: postsRoute.$params,
  filter: postsRoute.$isOpened,
  target: getPostsFx
});
  
// Навешиваем аналитику, не захламяляя остальной код
sample({
  clock: postsRoute.opened,
  target: trackGTM('POSTS_PAGE_VISITED')
});

Бонусный пример - страница для авторизованных юзеров:

const someSecretRoute = createRoute()

// Клонируем эффект с проверкой авторизации
const localAuthCheckFx = attach({ effect: authCheckFx })

// Дёргаем проверку
sample({
  clock: someSecretRoute.opened,
  target: localAuthCheckFx
})

// Если она не прошла, ведём на роут авторизации
sample({
  clock: localAuthCheckFx.failData,
  target: authRoute.open
})

Абстрагируем фичи от лишней ответственности

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

Есть замечательная методология Feature Sliced, которая делит приложение на разные уровни ответственности.

В упрощённом виде это выглядит так:

shared/    # UI-кит, либы и др. штуки, не знающие о предметной области
entities/  # Сущности приложения. Тут лежат всякие PostCard, UserInfo итд
features/  # Фичи. AddToCartButton, LoginForm, EditProfile
pages/     # Страницы. HomePage, PostsPage, LoginPage
app/       # Инициализирующая логика. createRouter, I18nProvider
index.ts   # Энтрипоинт приложения

Одна из важных фишек Feature Sliced - это то, что код импортируется только сверху вниз. Shared не знает ни о чём, сущности не знают о фичах и страницах, фичи не знают о страницах, страницы знают всё. Однако, есть один нюанс:

// entities/post
export const PostCard ({ post }) => {
  return (
    <Card>
      <Card.Title>{post.title}</PostCard.Card>
      <Card.Description>{post.description}</PostCard.Card>
      {/* Упс! Лишняя ответственность! */}
      <Card.Link to={postRoute} params={{ postId: post.id }}>Read more</Card.Link>
    </Card>
  );
};

И вот это, пожалуй, то, ради чего я затеял этот пост - меня дико бесит, что мы научились строить практически во всём вылизанную архитектуру, но по-прежнему подвязываемся на роуты напрямую.

Когда пути были строками, это было не так заметно, но теперь мы видим явное обращение к части приложения уровнем ниже.

Если бы это не было роутом, то мы бы наверняка сделали так:

// entities/post
export const PostCard ({ post }) => {
  return (
    <Card>
      <Card.Title>{post.title}</PostCard.Card>
      <Card.Description>{post.description}</PostCard.Card>
      {/* UPDATE: Вместо явного перехода создаём событие "Я нажалась" */}
      <Card.Link onClick={() => readMorePressed(post)}>Read more</Card.Link>
    </Card>
  );
};

// Где-то уровнями ниже
sample({
  clock: readMorePressed,
  fn: post => ({ postId: post.id }),
  target: postPage.open
})

Но мы печёмся за индексацию ссылок (<a href="/вставь/ссылку_лол">). А это значит, что такой вариант не катит.

НО! Раз наши роуты независимы друг от друга, что мешает нам создать локальный роут в каждой фиче, а затем их соединить?

import { readMoreRoute } from '@/entities/post';
import { postsRoute } from '@/pages/posts';

// Задаём пути нашим роутам
const routes = [
  { path: '/posts', route: readMoreRoute },
  { path: '/posts', route: postsRoute }
];

// Ну или так, можно поиграться шоб красиво было
const routes = [
  { path: '/posts', routes: [readMoreRoute, postsRoute] }
];

Вуаля! Теперь оба роута связаны с /posts и отработают

Подытожим

Что мы имеем в итоге

  • Абстрагировались от инлайн URL'ов и пишем код приложения так, будто их нет. Можем менять адреса хоть каждый день и не париться о том, что мы что-то забудем и что-то отвалится.
  • А если и отвалится, то TypeScript любезно напомнит о том, как всё происходило, пока мы чинили что-то ручками по памяти. Плюс мы знаем о том, какие роуты у нас в принципе есть и какие у них параметры.
  • Также мы имеем отличную интеграцию с Effector, которая позволит строить декларативные связи между переходами, загрузками, проверками итд.
  • И самое главное - теперь части нашего приложения избавились от лишней ответственности и больше не тянут код из страниц.

Потрогать роутер

GitHub: https://github.com/Kelin2025/atomic-router

NPM: https://npmjs.com/kelin2025/atomic-router

Спасибо, что дочитали до этой строки!