Если попытаться в двух словах описать, в чем заключается функция роутинга на фронтэнде веб-приложений, то можно придти к выводу, что каждый популярный фреймоворк совершенно по-разному представляет это себе. Даже, сравнивая версии одного и того же фреймоворка, можно придти к выводу, что функции и API роутинга наиболее подвержены изменениям (часто без обратной совместимости). Например 4-я версия роутинга в React была переработана настолько радикально, что некоторые популярные проекты на githab.com так и не перешли на эту версию.

За всем этим просматривается общая тенденция, которая, по моему мнению, заключается в том, что функционал роутинга в многих популярных фронтэнд фрейморках перегружен. В связи с этим, он становится жестко связанным с другими компонентами, которые могли быть выделены из роутинга (например с навигацией, историей, ссылками и т.п.). Поэтому, наверное, многим знакомо то чувство, когда использование роутинга становится неудобным, а его расширение просто невозможным. По сравнению с гибкими и расширяемыми компонентами, роутинг в популярных фронтэнд фрейморках выглядит на порядок менее удобным и совсем не расширяемым. Особенно это относится первым версиям (до 4-й) роутинга в React.

В этом сообщении я рассмотрю некоторые исторические моменты, которые привели к такому положению дел с роутингом, а также использование библиотеки universal-router, совместно с React.

А нужен ли роутинг?


Технически одностраничное веб-приложения может работать без роутинга. Например, как нет роутинга в десктопном приложении. Все работало бы почти хорошо, если бы одностраничное веб-приложение не оставалось все тем же веб-приложением для браузера. То есть, пользователь может в любую минуту обновить страницу нажатием на клавишу F5 или кликом по пиктограмме «Reload» браузера. Или же пользователь может в любой момент прокрутить историю вперед или назад кликом по пиктограмме «Стрелка влево» и «Стрелка вправо», или нажатием на клавишу «Backspace».

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

Почему роутинг такой?


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

Первоначально, url был адресом в сети статического веб-документа, и все было очень просто. Далее началась адаптация архитектуры MVC применительно к вебу: Model 1 и Model 2. Последняя из них имеет в своем составе фронт-контролеер, который впоследствии был еще разделен на две части: роутинг (который выбирает нужный контроллер) и собственно контроллер который работает с моделью и рендерит вью. Как видим, в классическом веб-приложении роутинг определяет действие (контроллер) и, опосредованно (через контроллер), определяет вью которое должно быть отренедрено на сервере.

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

Что предлагает библиотека universal-router?


Библиотека universal-router предлагает отбросить все лишнее и оставить только ту часть, которая может быть использована с любым фреймворком или без него, при рендеринге как на клиенте, так и на стороне веб-сервера (в универсальных/изоморфных веб-приложениях).

Отбросив все напластования времен, universal-router предлагает всего лишь одну четко обозначенную функцию. На основании строки (еще раз подчеркиваю строки а не объекта history, location и т.п.) вызвать асинхронную функцию, которой передать виде фактических параметров разобранную строку url. Вот и все. Как это могло бы выглядеть в React:

import React from 'react';
import UniversalRouter from 'universal-router';
import App from './App';
import Link from './Link';

const routes =
    {
        path: '/',
        async action({next}) {
            const children = await next();
            return (
                <App>
                    {children}
                </App>
            );
        },
        children: [
            {
                path: '',
                async action() {
                    return (
                        <div>Root route go to <Link href='/test'>Test</Link></div>
                    );
                },
            },
            {
                path: '/test',
                async action({next}) {
                    const children = await next();
                    return (
                        <App>
                            {children}
                        </App>
                    );
                },
                children: [
                    {
                        path: '',
                        async action() {
                            return (
                                <div>Test route return to <Link href='/'>Root</Link></div>
                            );
                        },
                    },
                ]
            },
        ],
    };

export const basename = '';

const router = new UniversalRouter(routes, {
    baseUrl: basename
});

export default router;

Вложенные роуты также поддерживаются. Они определяются в поле children, а получить их можно вызовом асинхронной функции next().

И как же это работает с React?


Определим метод navigate() для history, хотя во многих случаях достаточно использовать нативный метод push()

import { createBrowserHistory } from 'history'
import parse from 'url-parse'
import deepEqual from 'deep-equal'
const isNode = new Function('try {return this===global;}catch(e){return false;}') //eslint-disable-line
let history

if (!isNode()) {
  history = createBrowserHistory()
  history.navigate = function (path, state) {
    const parsedPath = parse(path)
    const location = history.location
    if (parsedPath.pathname === location.pathname &&
      parsedPath.query === location.search &&
      parsedPath.hash === location.hash &&
      deepEqual(state, location.state)) {
      return
    }
    const args = Array.from(arguments)
    args.splice(0, 2)
    return history.push(...[path, state, ...args])
  }
} else {
  history = {}
  history.navigate = function () {}
}

export default history

Также создадим компонент Link, который будет вызывать навигацию:

import React from 'react';
import {basename} from './router';
import history from './history';

function noOp(){};

const createOnClickAnchor = (callback) => {
    return (e) => {
        e.preventDefault();
        history.navigate(e.currentTarget.getAttribute('href'));
        callback(e);
    };
};

export default ({href, onClick = noOp, children, ...rest}) => (
    <a
        href={basename + href}
        onClick={createOnClickAnchor(onClick)}
        {...rest}
    >
        {children}
    </a>
);

Теперь все готово для рендеринга компонента:

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
import history from './history';
import router from './router';


const render = async (location) => {
    const element = await router.resolve(location);
    ReactDOM.render(
            element,
        document.getElementById('root'),
    );
};

render(history.location);
history.listen(render);



// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

Код проекта

Полезные ссылки

1. medium.com/@ippei.tanaka/universal-router-history-react-97ec79464573