Недавно меня спросили: “Зачем я отказался от библиотеки react-router и перешел на свой велосипед?” Честно говоря, вопросы, связанные с моим вариантом роутинга, мне задавали уже раз пять. Последний раз это было пол года назад, и с тех пор я немного подзабыл основные причины моего выбора. Поэтому я решил их вспомнить и написать статью о том почему react-router не подходит для больших проектов, поделится своими видением роутинга и получить критику моих идей от более широкого сообщества.

Итак поехали

На сегодняшний день react-router - это, можно сказать, монополист в мире React. Если в проекте нужен роутинг, то скорее всего выбор падет именно на эту библиотеку. По крайней мере, так было во всех проектах, где я участвовал.

Но каждый раз мне мозолили глаза некоторые нюансы. В основном эти нюансы касаются 5-й версии, но и в 6-й есть на что пожаловаться.

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

Обратим внимание на компонент <Route>.

<Route path="/" element={<Dashboard />}>

С одной стороны - это основная киллер фича библиотеки. Человек, работающий с реактом, видит тут привычный интерфейс в виде JSX. Это многих подкупает.

Но такая простая запись работает только в примерах либо в очень простых приложениях.

А в проектах, где я участвовал, поверх компонентов <Route> всегда писалась обертка, которая из конфиг-объекта динамически генерировала набор этих самых <Route>. Может это совпадение, но мне кажется, что это сама специфика роутинга. Нельзя такие вещи описать jsx-ом. Этот конфиг-объект обычно собирается проходя несколько функций обработки: часть данных на фронте, часть прилетает с сервера, какие-то роуты обрезаются правами доступа или просто временно отключаются, в какие-то наоборот добавляется в цикле какая-то общая особенность.

Получаем такие этапы создания роутинга:

  1. Создание единого объекта с конфигурацией роутов

  2. Рекурсивный проход этого объекта, чтобы сгенерировать в jsx набор реакт компонентов <Route/>

И теперь, внимание. Угадайте, что делает библиотека react-router после того как этот jsx рендерится? Правильно, еще раз:

  1. Создание единого внутреннего объекта с конфигурацией роутов.

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)


  1. Fen1kz
    06.01.2022 11:18
    +8

    Не сочтите меня ярым фанатиком react-router, но я почти со всем не согласен.


    Получаем такие этапы создания роутинга:
    Создание единого объекта с конфигурацией роутов
    Рекурсивный проход этого объекта, чтобы сгенерировать в jsx набор реакт компонентов <Route/>

    У меня на проекте был товарищ, который применял такой подход ко всему. он делал конфиги, а из них делал jsx. Только вот за этой чехардой он забывал, что jsx это сам по себе конфиг, просто не в json, а в jsx.


    То есть считаю {routeConfig.map((route) => <Route {...route}/>)} антипаттерном, CMV




    Абсолютные пути


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

    Не понимаю причем тут библиотека роутинга. Библиотека занимается роутингом, а вы требуете у неё знать о существовании модулей, о том что они друг в друге и прочее.


    Вы точно так же можете наворотить своих конфигов, но только для ссылок.


    const bookmodule = {
    path: '/book', 
    }
    
    const dashboardmodule = {
    path: '/dashboard', 
    modules: [bookmodule]
    }
    
    const shopmodule= {
    path: '/shop', 
    //modules: [bookmodule]
    }
    
    const config = makeMyOwnConfigForMyApp([dashboardmodule, shopmodule])
    
    config.book.getLink() === '/dashboard/book/'



    Но у <Header/> меняется только один пропс title — полностью его пересоздавать — это явно излишество.

    ну дык this is the way. Не, правда, ввинять библиотеке роутинга что она пересоздает компонент потому что вы запихнули его в роут это жестко. Если проблема только в оптимизации, то тут проще Header оптимизировать. Вообще пункт был самый мощный, пока я не пошел изучать v6 и не нашел там в базовом примере (https://reactrouter.com/docs/en/v6/examples/basic):


    <Routes>
      <Route path="/" element={<Layout />}>
        <Route index element={<Home />} />
        <Route path="about" element={<About />} />
        <Route path="dashboard" element={<Dashboard />} />
        <Route path="*" element={<NoMatch />} />
      </Route>
    
    И тут можно всегда добавить: <Route path="/noheader" element={<NoHeader />} />
    
    и это логично, потому что у вас будет 
    не "каждый компонент обязан знать о хедере и ещё и вставлять его сам", 
    а вменяемое дерево лейаутов.
    </Routes>


    Если вдруг вы решили (как и я) отделить бизнес логику от UI, то, думаю, будет логично, чтобы UI (который в JSX) только дергал событие “нажата кнопка X”, а уже сам переход делала бизнес логика.

    ??? Вообще не понял какое это отношение имеет к разделению БЛ и UI. Переход это БЛ? А почему тогда хендлер знает о юайном событии? Почему он написан сверху этого jsx, "во вью"? Как будто для красного словца ввернули просто.


    Если уж "разделять", то как и в п.2:


    юай: <Link to={config.usermodule.getLink(userId)}/>


    сложная бизнес логика: megaSuperCallback = () => {

    history.pushRoute navigate(config.usermodule.getLink(userId))
    }




    Я понимаю если бы статья была из одной строчки: "меня задрало как они меняют АПИ от версии к версии" — тут нечего сказать, получи плюс и крути педали дальше, но вот ваш рассказ навел на мысль, что вы вместо простого и понятного роутинга хотите чтобы у вас был монстр из конфигов на конфигах который прикрываясь благими паттернами на самом деле замешивал бы весь роутинг, лейаутинг, БЛ и все остальное в одну большую кашу.


    1. antsam Автор
      06.01.2022 12:39

      что jsx это сам по себе конфиг

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

      Но мысль в этой статье была про то что роуты не удобно конфигурировать JSX-ом и почему - я аргументировал.


    1. nin-jin
      06.01.2022 12:52

      jsx - это программа создающая конфиг (vdom), но не сам конфиг.


      1. antsam Автор
        06.01.2022 12:58
        +1

        ну имеется ввиду, что мы либо можем придумывать свой конфиг, а потом из него генерить JSX, либо сразу описывать (конфигурировать) программу с помощью jsx.


        1. nin-jin
          06.01.2022 13:14

          Либо просто конфигурировать без подражания html.


      1. copperfox777
        07.01.2022 22:06

        Jsx это не программа а расширение языка js и да, человек выше прав. Конфигурировать роуты с помощью jsx удобно. Тот же конфиг, но более наглядно. А так абстракция на абстракции


    1. antsam Автор
      07.01.2022 22:38

      Библиотека занимается роутингом, а вы требуете у неё знать о существовании модулей

      Я не требую знать о существовании модулей. Я предоставляю библиотеке конфиг с деревом роутов (не важно, через jsx или через useRouter). Этого должно быть достаточно, чтобы она вычислила абсолютный путь к элементу этого дерева.

      Вы точно так же можете наворотить своих конфигов, но только для ссылок.

      Могу, но зачем мне писать два конфига и следить чтобы они были консистентными? Чем больше кода, тем дороже его поддерживать.

      не "каждый компонент обязан знать о хедере

      Видимо тут у нас с вами основное различие во взглядах. Я считаю что каждая страница должна полностью определять весь лейаут страницы. Все остальные решения не универсальны. Вызвать в каждой странице свой лейаут (<Layout1><Page1Content/></Layout1>) практически ничего не усложняет, но увеличивает гибкость до максимума. Мы на любой каприз заказчика можем поменять хеадер, футер или сайдбар, поменяв Layout1 на Layout2.

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


  1. skeevy
    06.01.2022 11:27
    +1

    Поэтому ссылка на книгу будет выглядеть так: 

    Когда в моем проекте пришёл момент этот, я переписал роты на статичные и все остальное перенёс в query. Жить стало значительно легче

    Самый несущественный. Лишние пересоздания кокомпонентове

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


  1. mayorovp
    06.01.2022 11:27
    +2

    Хм, но если вам так хочется использовать функции вместо компонентов, то … почему бы не использовать их?


    <Route path="/home" element={Home()}>
    <Route path="/about" element={About()}>

    Неужели роутер каким-то чудом запрещает подобное?


    1. antsam Автор
      06.01.2022 12:19

      Спасибо, вариант рабочий, ничего не пересоздается. Но вы же не предлагаете одновременно создать 50 страниц и держать их в памяти?


      1. mayorovp
        06.01.2022 13:38

        Так вы же держите в памяти не 50 страниц, а их jsx-описания. Если побить каждую на крупные блоки, и не позволять контенту "проникать" наружу — эти 50 "страниц" не займут много места.


        Опять-таки, вы можете воспользоваться своим же трюком с вспомогательным компонентом.


    1. Alexandroppolus
      06.01.2022 13:27

      Если в оных компонентах есть хуки, то всё развалится - хуки станут условными и вообще поедут "наружу".

      Да и не совсем понятно, насколько такой способ совместим с React.memo или mobx observer


      1. mayorovp
        06.01.2022 13:41

        Этот способ вообще не совместим с React.memo или mobx observer, потому что те работают с компонентами, а не функциями.


  1. SubarYan
    06.01.2022 19:53

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


  1. faiwer
    07.01.2022 20:19
    +3

    Да, я осознаю, что в этом подходе перестанет работать встроенный функционал браузера "открыть в новом окне", это надо будет отдельно реализовывать, но у всего есть свои плюсы и минусы.

    Боюсь что без <a href/> ваше решение не имеет смысла даже обсуждать. Это настолько грубое наплевательское отношение к пользователям и вообще основам web-а, что не совсем понятно, зачем вы вообще выбрали тогда фронтенд. Рутинг в обход ссылок большое зло.


  1. faiwer
    07.01.2022 20:31

    Самой большой проблемой react-router мне показалось не всё вышеописанное, а то, что URL является дополнительным источником истины. И если в проекте есть другой источник истины, то попытки их "примирение" может быть очень болезненными, т.к. они могут обновляться не своевременно. К примеру вначале обновится router, а потом какой-нибудь внешний store. Или наоборот. И вот это промежуточное состояние может быть очень неконсистентным. Если же попробовать сдружить router с внешним store-ом, то всё равно остаётся проблема самого компонента <Route/> и его matches, которые уже являются более локальными. В общем это может быть очень проблемной штукой.


    <Link/>, кстати довольно странная штука. Он хотя и является ссылкой (иначе всю либу можно было бы сразу выкинуть на свалку), но переходы организует посредством патченного onClick. Абстракции построенные поверх абстракций.


  1. ShadowIn
    07.01.2022 20:53

    при всей нелюбви к реакт-роутеру, половина пунктов тут мимо

    от того, что "крутые конфиги" - не задача реакт-роутера, как и попытка оптимизировать спички через JSX, до

    "Лишние пересоздания компонентов." - тут совсем не проблема реакт-роутера. Если динаммически создавать компоненты без сохранения равенства ссылок, а потом делать React.createElement с ними, то само собой Reconciler будет ре-маунтить его на каждый рендер.

    А вот то, что реакт-роутер пишут больные люди - это точно. Уже пару версий подряд не могут удержать API в консистентном виде (проходит 3-4 месяца, и не можешь понять - это я документацию найти не могу, или опять что-то поменялось). Выпускают кучу версий, которые сломаны и юзать их попросту нельзя (особенно на 4.+ Просто ждите, когда npm затянет что-то "совместимое" и все хуки просто не будут работать). Ну и самое интересное - какая-то общая мания с переименованиями (смотрю на тебя exact, чем ты не угодил - не знаю)


    1. nin-jin
      07.01.2022 23:33
      -2

      "Лишние пересоздания компонентов." - тут совсем не проблема реакт-роутера.

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


      1. antsam Автор
        08.01.2022 00:53

        Видимо мы с вами на разных уровнях. Не каждый готов решить проблему в корне написав свою библиотеку :)


        1. nin-jin
          08.01.2022 01:55

          Зачем писать свою, если можно взять готовую?


    1. antsam Автор
      08.01.2022 00:39
      +1

      то само собой Reconciler будет ре-маунтить его на каждый рендер.

      Что значит "само собой"? Так говорите как будто нет выхода. Я привел пример как это можно было сделать, чтобы не было пересоздания. И для этого не надо писать что-то сверхъестественное. Достаточно передавать не элемент, а рендер функцию


      1. ShadowIn
        08.01.2022 22:24

        так-то да, использовать рендер-функцию, как и написано в документации

        так что и возникает вопрос "почему это проблема Реакт-Роутера?", ведь это дефолтное повидение в Реакте


        1. antsam Автор
          09.01.2022 01:17
          +1

          Не нашел в документации к 6-й версии чтобы можно было рендер функцию передавать. Можете показать место где про это написано?