Недавно меня спросили: “Зачем я отказался от библиотеки react-router и перешел на свой велосипед?” Честно говоря, вопросы, связанные с моим вариантом роутинга, мне задавали уже раз пять. Последний раз это было пол года назад, и с тех пор я немного подзабыл основные причины моего выбора. Поэтому я решил их вспомнить и написать статью о том почему react-router не подходит для больших проектов, поделится своими видением роутинга и получить критику моих идей от более широкого сообщества.
Итак поехали
На сегодняшний день react-router - это, можно сказать, монополист в мире React. Если в проекте нужен роутинг, то скорее всего выбор падет именно на эту библиотеку. По крайней мере, так было во всех проектах, где я участвовал.
Но каждый раз мне мозолили глаза некоторые нюансы. В основном эти нюансы касаются 5-й версии, но и в 6-й есть на что пожаловаться.
Самый печальный, который рушит мои минималистические взгляды
Обратим внимание на компонент <Route>.
<Route path="/" element={<Dashboard />}>
С одной стороны - это основная киллер фича библиотеки. Человек, работающий с реактом, видит тут привычный интерфейс в виде JSX. Это многих подкупает.
Но такая простая запись работает только в примерах либо в очень простых приложениях.
А в проектах, где я участвовал, поверх компонентов <Route> всегда писалась обертка, которая из конфиг-объекта динамически генерировала набор этих самых <Route>. Может это совпадение, но мне кажется, что это сама специфика роутинга. Нельзя такие вещи описать jsx-ом. Этот конфиг-объект обычно собирается проходя несколько функций обработки: часть данных на фронте, часть прилетает с сервера, какие-то роуты обрезаются правами доступа или просто временно отключаются, в какие-то наоборот добавляется в цикле какая-то общая особенность.
Получаем такие этапы создания роутинга:
Создание единого объекта с конфигурацией роутов
Рекурсивный проход этого объекта, чтобы сгенерировать в jsx набор реакт компонентов <Route/>
И теперь, внимание. Угадайте, что делает библиотека react-router после того как этот jsx рендерится? Правильно, еще раз:
Создание единого внутреннего объекта с конфигурацией роутов.
proof:
Надеюсь вы поняли мою печаль.
Давайте признаемся себе. Компонент <Route/> ничего не рендерит, из него просто берутся пропсы и ложатся в единый объект конфигурации. Излишне вставлять его в jsx, достаточно сразу передать эти данные параметром сразу в основной компонент:
<Router config={config} />
Тогда будет всего один этап (тот который первый). То есть в библиотеке роутинга достаточно просто описать интерфейс конфига и все.
К счастью в шестой версии уже есть хук useRoutes, в который можно передать сразу конфиг. Но мне бы хватило облегченной версии, где я бы конфиг передавал прямо в <Router/>, а не через контекст, и без этих легаси компонентов типа <Route/>.
Самый ограничивающий. Отсутствие модульности
Если на первый недостаток можно закрыть глаза, то из-за второго, я считаю, невозможно построить большое приложение из независимых модулей. И дело вот в чем. На страницах с примерами все просто. Есть условно 5 роутов и пару мест где происходит переход между этими роутами. Пока приложение маленькое, мы можем без проблем держать в уме ссылки на все страницы. Но что если страниц 50? Или 150?
Представьте большой проект который разбит на модули. Один из модулей реализует справочник книг. В нем есть страница со списком книг и страница с информацией про выбранную книгу.
/books
/book/{id}
Сперва решили модуль "книги" сделать подмодулем модуля "магазин". Поэтому ссылка на книгу будет выглядеть так:
shop/{shopId}/book/{bookId}
За месяц эти ссылки разлетелись по остальным модулям: "авторы", 'издатели", "рейтинг книг".
А через месяц понадобилось добавить параметр в ссылку. Например, язык книги. Теперь ссылка на книгу должна выглядеть так:
shop/{shopId}/book/{bookLang}/{bookId}.
В принципе задача решаема, но очень трудоемкая, и ведущая к возможным багам в случае, если не будет учтена хоть одна ссылка в каком-то из модулей. И это не просто поиск/замена строки - скорее всего были написаны функции для генерации ссылок по параметрам. Найти такие места не так уж и просто. Задача становится максимально сложной, если модуль "книги" поставляется как отдельный npm модуль. Нужно уведомить разработчиков всех проектов чтобы они исправили все ссылки на модуль "книги". Все будут очень "рады" бросить свои запланированные задачи чтобы менять ссылки.
Чтобы не возникало таких сложностей, модуль должен предоставлять функции, которые будут генерировать ссылки на свои страницы. Функции должны быть обратно совместимыми, а вот сгенерированные ссылки могут от версии к версии меняться. Тогда в других модулях не надо будет ничего переделывать и ничего не будет ломаться (Да, я про SPA, который не индексируется поисковыми системами, и ссылки действительно могут безболезненно меняться в новых версиях приложения)
То есть в 21-й версии у нас были такие функции для генерации ссылок:
const getBooksLink = () => "/books";
const getBookLink = (id) => "/book/${id}";
А в 22-й их подправили:
const getBooksLink = (lang = "en") => `/books/${lang}`
const getBookLink = (id, lang = "en") => `/book/${lang}/${id}`
Функционал модуля книг увеличился на один параметр, при этом интерфейс функций остался обратно совместим и остальной код останется без изменений.
Но возникает другая проблема. Модуль может генерировать ссылку только относительно своего корня. Он не знает абсолютный путь. Ведь мы можем модуль “книги” поместить в модуль магазина (shop/23/books/en/123) или в модуль дашборда (dashboard/books/en/123). Как модулю узнать корневую директорию для своих ссылок?
Один из вариантов - можно корневую директорию передавать параметром.
Но я бы предпочел, чтобы это разруливала библиотека роутинга. Мы в конфиге передаем какой модуль внутри какого находится. Библиотека роутинга может вычислить из относительного пути абсолютный.
Например конфиг роутов в модуле “книги”:
const booksConfig = {
index: "books/en"
children: {
books: { link: getBooksLink, layout: booksLayout }
book: { link: getBookLink, layout: bookLayout }
}
}
вставляем конфиг модуля “книги” в конфиг ”дашборда”:
const config = {
index: "dashboard",
children: {
"dashboard": {
layout: dashboardLayout,
children: {
books: booksConfig
}
}
}
}
Эти примеры схематичные, чтобы понять суть. Идея в том что, дергая getBooksLink() из любого места в проекте, должна генерироваться абсолютная ссылка относительно дашборда.
Если мы однажды перенесем конфиг в модуль "магазин",
const config = {
index: "shop",
children: {
"shop": {
layout: shopLayout,
children: {
books: booksConfig
}
}
}
}
то все ссылки автоматически будут генерировать относительно модуля магазин. То есть достаточно сделать изменения только в двух файлах: из одного конфига удалить, в другой добавить.
К сожалению этого нет в react-router.
Самый несущественный. Лишние пересоздания компонентов.
Рассмотрим пример:
const Home = () => <><Header title="Home"/><div>home content</div></>
const About = () => <><Header title="About"/><div>about content</div></>
.........
<Route path="/home" element={<Home />}>
<Route path="/about" element={<About />}>
Как думаете будет ли пересоздаваться <Header/>, если мы будем переходить из роута /home на /about и обратно?
Видно невооруженным взглядом что да, т.к. <Home /> и <About /> - это два разных компонента, то они будут каждый раз пересоздаваться вместе с внутренним компонентом <Header/>. Но у <Header/> меняется только один пропс title - полностью его пересоздавать - это явно излишество. Ведь сам компонент Header может быть очень сложным - например, я встречал хеадер, внутри которого была кнопка с полноценным чатом. Рядом с хедером может быть футер, сайдбар меню.
В примерах библиотеки “react-router” это решили очень просто: хеадер, футер и меню создают вне роутера, а роутер переключает только контент.
А дальше разбирайтесь, как говорится, сами. Вставляйте if-чики: если это не дашбоард, то рендерим хеадер, если дашбоард, то не рендерим хеадер. Но, извините, зачем мне тогда роутер нужен, если я компоненты буду рендерить вручную проверяя текущий location.
А теперь как это должно быть по моему мнению. Страницы должны быть не компонентами, а рендер функциями.
const homeLayout = () => <><Header title="Home"/><div>home content</div></>
const aboutLayout = () => <><Header title="About"/><div>about content</div></>
Теперь попробуйте для эксперимента создать компонент:
const Pages = ({page}) => {
const layout = page==="about" ? aboutLayout : homeLayout;
return layout();
}
Будет ли у вас пересоздаваться компонент <Header/> ? Конечно нет, т.к. меняется только функция рендера, а не компонент. Будет только разный title передаваться в пропсы, но никак не пересоздаваться компонент.
Почему в библиотеке так не сделали, для меня загадка. Причем в пятой версии был параметр render, но он все равно пересоздвавал внутренние компоненты. В голову приходит только одно - видимо для большинства это действительно несущественно. Но для меня это и было последней каплей, после чего я написал свой роутер.
Мои причуды. Разделение на бизнес логику и UI.
Если вдруг вы решили (как и я) отделить бизнес логику от UI, то, думаю, будет логично, чтобы UI (который в JSX) только дергал событие “нажата кнопка X”, а уже сам переход делала бизнес логика. То есть UI должен отвечать на вопрос “что сделал пользователь?” а за “что делать с программой?” должна отвечать бизнес логика.
Таким образом компонент <Link /> не имеет смысла в таком подходе.
Вместо этого у нас обычная кнопка или тег <a />:
<button value="user1" onClick={props.onOpenUser} />
и обработчик события:
const onOpenUsers =
({target: {value: userId}}) => history.push(users.getUserLink(userId))
Да, я осознаю, что в этом подходе перестанет работать встроенный функционал браузера "открыть в новом окне", это надо будет отдельно реализовывать, но у всего есть свои плюсы и минусы.
Выводы
Скорее всего под разные проекты нужны разные реализации роутинга.
В моих проектах я буду использовать свою реализацию (если конечно вы меня прям тут не переубедите).
Она более функциональная и исправляет все описанные выше недостатки: помогает по текущему дереву конфига из относительных ссылок делать абсолютные и не пересоздает лишний раз внутренние компоненты.
В тоже время она более урезанная (всего около 100-200 строчек кода): ничего лишнего, условно говоря есть только один компонент <Router config={config} /> и все. На самом деле, то что в ней нет ничего лишнего - это преимущество, так как это не позволяет применять несколько подходов в одном проекте.
Но если вас не смущают описанные в статье недостатки (вполне возможно, это чисто мои заморочки), либо нет времени писать свой роутинг, то можно смело использовать react-router.
Комментарии (23)
skeevy
06.01.2022 11:27+1Поэтому ссылка на книгу будет выглядеть так:
Когда в моем проекте пришёл момент этот, я переписал роты на статичные и все остальное перенёс в query. Жить стало значительно легче
Самый несущественный. Лишние пересоздания кокомпонентове
Если у вас такая проблема, то наверняка вы неверно, особенно с шапкой, организовали роутинг. Как уже сказали выше, ту же шапку можно вынести из роута и всё, а все остальное можно проверить в одном месте, где роутинг конфигурируется, даже тот же location.
mayorovp
06.01.2022 11:27+2Хм, но если вам так хочется использовать функции вместо компонентов, то … почему бы не использовать их?
<Route path="/home" element={Home()}> <Route path="/about" element={About()}>
Неужели роутер каким-то чудом запрещает подобное?
antsam Автор
06.01.2022 12:19Спасибо, вариант рабочий, ничего не пересоздается. Но вы же не предлагаете одновременно создать 50 страниц и держать их в памяти?
mayorovp
06.01.2022 13:38Так вы же держите в памяти не 50 страниц, а их jsx-описания. Если побить каждую на крупные блоки, и не позволять контенту "проникать" наружу — эти 50 "страниц" не займут много места.
Опять-таки, вы можете воспользоваться своим же трюком с вспомогательным компонентом.
Alexandroppolus
06.01.2022 13:27Если в оных компонентах есть хуки, то всё развалится - хуки станут условными и вообще поедут "наружу".
Да и не совсем понятно, насколько такой способ совместим с React.memo или mobx observer
mayorovp
06.01.2022 13:41Этот способ вообще не совместим с React.memo или mobx observer, потому что те работают с компонентами, а не функциями.
SubarYan
06.01.2022 19:53Изо дня в день вижу как программисты жалуются на эту React библиотеку, но продолжают на ней работать. Ёжик плакал, кололся, но продолжал есть кактус.
faiwer
07.01.2022 20:19+3Да, я осознаю, что в этом подходе перестанет работать встроенный функционал браузера "открыть в новом окне", это надо будет отдельно реализовывать, но у всего есть свои плюсы и минусы.
Боюсь что без
<a href/>
ваше решение не имеет смысла даже обсуждать. Это настолько грубое наплевательское отношение к пользователям и вообще основам web-а, что не совсем понятно, зачем вы вообще выбрали тогда фронтенд. Рутинг в обход ссылок большое зло.
faiwer
07.01.2022 20:31Самой большой проблемой
react-router
мне показалось не всё вышеописанное, а то, что URL является дополнительным источником истины. И если в проекте есть другой источник истины, то попытки их "примирение" может быть очень болезненными, т.к. они могут обновляться не своевременно. К примеру вначале обновится router, а потом какой-нибудь внешний store. Или наоборот. И вот это промежуточное состояние может быть очень неконсистентным. Если же попробовать сдружить router с внешним store-ом, то всё равно остаётся проблема самого компонента<Route/>
и егоmatches
, которые уже являются более локальными. В общем это может быть очень проблемной штукой.<Link/>
, кстати довольно странная штука. Он хотя и является ссылкой (иначе всю либу можно было бы сразу выкинуть на свалку), но переходы организует посредством патченногоonClick
. Абстракции построенные поверх абстракций.
ShadowIn
07.01.2022 20:53при всей нелюбви к реакт-роутеру, половина пунктов тут мимо
от того, что "крутые конфиги" - не задача реакт-роутера, как и попытка оптимизировать спички через JSX, до
"Лишние пересоздания компонентов." - тут совсем не проблема реакт-роутера. Если динаммически создавать компоненты без сохранения равенства ссылок, а потом делать React.createElement с ними, то само собой Reconciler будет ре-маунтить его на каждый рендер.
А вот то, что реакт-роутер пишут больные люди - это точно. Уже пару версий подряд не могут удержать API в консистентном виде (проходит 3-4 месяца, и не можешь понять - это я документацию найти не могу, или опять что-то поменялось). Выпускают кучу версий, которые сломаны и юзать их попросту нельзя (особенно на 4.+ Просто ждите, когда npm затянет что-то "совместимое" и все хуки просто не будут работать). Ну и самое интересное - какая-то общая мания с переименованиями (смотрю на тебя exact, чем ты не угодил - не знаю)
nin-jin
07.01.2022 23:33-2"Лишние пересоздания компонентов." - тут совсем не проблема реакт-роутера.
Ну да, это пробема Реакта, но вместо решения проблемы в корне, люди спорят о том, надо ли для каждого симптома делать свои костыли, или же каждый раз надо пафосно превозмогать.
antsam Автор
08.01.2022 00:39+1то само собой Reconciler будет ре-маунтить его на каждый рендер.
Что значит "само собой"? Так говорите как будто нет выхода. Я привел пример как это можно было сделать, чтобы не было пересоздания. И для этого не надо писать что-то сверхъестественное. Достаточно передавать не элемент, а рендер функцию
ShadowIn
08.01.2022 22:24так-то да, использовать рендер-функцию, как и написано в документации
так что и возникает вопрос "почему это проблема Реакт-Роутера?", ведь это дефолтное повидение в Реакте
antsam Автор
09.01.2022 01:17+1Не нашел в документации к 6-й версии чтобы можно было рендер функцию передавать. Можете показать место где про это написано?
Fen1kz
Не сочтите меня ярым фанатиком react-router, но я почти со всем не согласен.
У меня на проекте был товарищ, который применял такой подход ко всему. он делал конфиги, а из них делал jsx. Только вот за этой чехардой он забывал, что jsx это сам по себе конфиг, просто не в json, а в jsx.
То есть считаю
{routeConfig.map((route) => <Route {...route}/>)}
антипаттерном, CMVАбсолютные пути
Не понимаю причем тут библиотека роутинга. Библиотека занимается роутингом, а вы требуете у неё знать о существовании модулей, о том что они друг в друге и прочее.
Вы точно так же можете наворотить своих конфигов, но только для ссылок.
ну дык this is the way. Не, правда, ввинять библиотеке роутинга что она пересоздает компонент потому что вы запихнули его в роут это жестко. Если проблема только в оптимизации, то тут проще Header оптимизировать. Вообще пункт был самый мощный, пока я не пошел изучать v6 и не нашел там в базовом примере (https://reactrouter.com/docs/en/v6/examples/basic):
??? Вообще не понял какое это отношение имеет к разделению БЛ и UI. Переход это БЛ? А почему тогда хендлер знает о юайном событии? Почему он написан сверху этого jsx, "во вью"? Как будто для красного словца ввернули просто.
Если уж "разделять", то как и в п.2:
юай:
<Link to={config.usermodule.getLink(userId)}/>
сложная бизнес логика: megaSuperCallback = () => {
…
history.pushRoutenavigate(config.usermodule.getLink(userId))}
Я понимаю если бы статья была из одной строчки: "меня задрало как они меняют АПИ от версии к версии" — тут нечего сказать, получи плюс и крути педали дальше, но вот ваш рассказ навел на мысль, что вы вместо простого и понятного роутинга хотите чтобы у вас был монстр из конфигов на конфигах который прикрываясь благими паттернами на самом деле замешивал бы весь роутинг, лейаутинг, БЛ и все остальное в одну большую кашу.
antsam Автор
Касательно этой фразы - она не противоречит статье. Полностью с ней согласен и у меня даже была статья на эту тему.
Те конвертации конфига в jsx, с которыми я работал, были не мной написаны. И я как раз против конвертации конфига в JSX. Я за то чтобы либо конфиг либо jsx.
Но мысль в этой статье была про то что роуты не удобно конфигурировать JSX-ом и почему - я аргументировал.
nin-jin
jsx - это программа создающая конфиг (vdom), но не сам конфиг.
antsam Автор
ну имеется ввиду, что мы либо можем придумывать свой конфиг, а потом из него генерить JSX, либо сразу описывать (конфигурировать) программу с помощью jsx.
nin-jin
Либо просто конфигурировать без подражания html.
copperfox777
Jsx это не программа а расширение языка js и да, человек выше прав. Конфигурировать роуты с помощью jsx удобно. Тот же конфиг, но более наглядно. А так абстракция на абстракции
antsam Автор
Я не требую знать о существовании модулей. Я предоставляю библиотеке конфиг с деревом роутов (не важно, через jsx или через useRouter). Этого должно быть достаточно, чтобы она вычислила абсолютный путь к элементу этого дерева.
Могу, но зачем мне писать два конфига и следить чтобы они были консистентными? Чем больше кода, тем дороже его поддерживать.
Видимо тут у нас с вами основное различие во взглядах. Я считаю что каждая страница должна полностью определять весь лейаут страницы. Все остальные решения не универсальны. Вызвать в каждой странице свой лейаут (<Layout1><Page1Content/></Layout1>) практически ничего не усложняет, но увеличивает гибкость до максимума. Мы на любой каприз заказчика можем поменять хеадер, футер или сайдбар, поменяв Layout1 на Layout2.
То что у них в примере есть общий лейаут для внутренних роутов - это не универсально. Всегда есть вероятность, что заказчик попросит сделать одну из внутренних страниц по другому лейоуту. И тогда начнутся костыли.