Роутинг мечты, который не ломает архитектуру
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
Спасибо, что дочитали до этой строки!