Недавно меня спросили: “Зачем я отказался от библиотеки 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.
Самый несущественный. Лишние пересоздания компонентов.
(После обсуждения выяснилось, что этот недостаток можно обойти - см. Update1, но все равно, думаю, этот раздел полезно прочитать, т.к. в официальной документации я не увидел чтобы этот подход был описан)
Рассмотрим пример:
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, но он все равно пересоздвавал внутренние компоненты. В голову приходит только одно - видимо для большинства это действительно несущественно. Но для меня это и было последней каплей, после чего я написал свой роутер.
Update 1:
Как правильно прокоментировал @mayorovpне обязательно держать ссылки на реднер функции. Можно их выполнить, и уже результат (ReactElement) передавать в компонент Route в пропс "element".
<Route path="/home" element={homeLayout()}>
<Route path="/about" element={aboutLayout()}>
это равносильно этому:
<Route path="/home" element={<><Header title="Home"/><div>home content</div></>}>
<Route path="/about" element={<><Header title="About"/><div>about content</div></>}>
В таком подходе тоже не происходит пересозданий Header. По памяти и производительности тоже нет никакой разницы.
Таким образом этот недостаток можно считать не актуальным (кроме очень редкого случая, когда вам все таки надо передавать в рендер функцию параметры)
Мои причуды. Разделение на бизнес логику и 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.