Как написать свой собственный Typesafe React Router в 500 строк

Как написать свой собственный Typesafe React Router в 500 строк

11 марта 2024 г.

Когда я изучал React около 6 лет назад, React-router была одной из первых сторонних библиотек, которые я взял на вооружение. Я имею в виду, что это имеет смысл: маршрутизация является одним из ключевых аспектов современного одностраничного приложения. Я принял это как должное.

Для меня React-Router был волшебной коробочкой, я понятия не имел, как он работает внутри. Итак, когда через какое-то время я естественным образом разобрался, как вообще работает маршрутизация, стало немного грустно. Никакого волшебства, да :(

Но я рад, что оставил позади концепцию «волшебных черных ящиков». Я думаю, что это очень вредная идея. Понимание того, что каждая технология когда-либо была создана инженерами, как и мы с вами, меня очень вдохновляет, и я надеюсь, что то же самое произойдет и с вами.

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

Функции и ограничения

Позвольте мне описать, какие функции нашего маршрутизатора будут рассмотрены в этой статье.

Нашей убийственной особенностью будет типобезопасность. Это означает, что вам нужно будет заранее определить свои маршруты, и TypeScript проверит, чтобы вы не передали какую-то ерунду вместо URL-адреса и не попытались получить параметры, отсутствующие в текущем маршруте. Это потребует некоторой гимнастики, но не волнуйтесь, я вам все расскажу.

Кроме того, наш маршрутизатор будет поддерживать все, что вы ожидаете от обычного маршрутизатора: навигацию по URL-адресам, сопоставление маршрутов, анализ параметров маршрута, навигацию вперед/назад и блокировку навигации.

Теперь об ограничениях. Наш маршрутизатор будет работать только в браузере (извините, React Native!) и не будет поддерживать SRR. Поддержка SSR должна быть относительно простой, но этот пост уже большой, поэтому я не буду его освещать.

Терминология

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

В нашей библиотеке будет два типа маршрутов: необработанные маршруты и проанализированные маршруты. Необработанный маршрут — это просто строка, которая выглядит как /user/:id/info или /login; это шаблон для URL-адресов. Необработанный маршрут может содержать параметры, которые представляют собой разделы, начинающиеся с двоеточия, например :id.

Этот формат удобен для разработчиков, но не для программ; мы преобразуем эти маршруты в более удобный для машин формат и назовем его разобранным маршрутом.

Но пользователи не открывают маршруты, они открывают URL-адреса. А URL — это идентификатор ресурса (в нашем случае страницы внутри приложения); это может выглядеть так: http://example.app/user/42/info?anonymous=1#bio. Наш маршрутизатор в основном заботится о второй части URL-адреса (/user/42/info?anonymous=1#bio), которую мы назовем path.

Путь состоит из имя пути (/user/42/info), параметров поиска (?anonymous=1) и хэш (#bio). Объект, в котором эти компоненты хранятся в отдельных полях, будет называться location.

Общий обзор API

При создании библиотек React я предпочитаю выполнять три этапа. Прежде всего, императивный API. Его функции можно вызывать из любого места, и они обычно вообще не привязаны к React. В случае маршрутизатора это будут такие функции, как navigate, getLocation или subscribe.

Затем на основе этих функций создаются перехватчики, такие как useLocation или useCurrentRoute. А затем эти функции и хуки используются в качестве основы для создания компонентов. По моему опыту, это работает исключительно хорошо и позволяет создать легко расширяемую и универсальную библиотеку.

API маршрутизатора начинается с функции defineRoutes. Предполагается, что пользователь передает карту всех необработанных маршрутов функции, которая анализирует маршруты и возвращает отображение той же формы. Все API-интерфейсы для разработчиков, такие как создание URL-адресов или сопоставление маршрутов, будут принимать проанализированные, а не необработанные маршруты.

const routes = defineRoutes({
    login: '/login',
    user: {
        me: '/user/me',
        byId: '/user/:id/info',
    }
});

Следующий шаг — передать проанализированные маршруты в функцию createRouter. Это суть нашего роутера. Эта функция создаст все функции, хуки и компоненты. Это может показаться необычным, но такая структура позволяет нам адаптировать типы принимаемых аргументов и свойств к определенному набору маршрутов, определенных в routes, гарантируя типобезопасность (и улучшая DX).

const { 
    Link, 
    Route, 
    useCurrentRoute, 
    navigate,
    /* etc... */ 
} = createRouter(routes);

createRouter будет возвращать функции, которые можно использовать в любом месте вашего приложения (императивный API), перехватчики, позволяющие вашим компонентам реагировать на изменения местоположения, и три компонента: Link, . >Маршрут и NotFound. Этого будет достаточно, чтобы охватить большинство случаев использования, и вы сможете создавать свои собственные компоненты на основе этих API.

Программирование на уровне типа для удовольствия и прибыли

Начнем с рассмотрения части нашей презентации, посвященной типобезопасности. Как я уже упоминал ранее, при использовании типобезопасного маршрутизатора TypeScript заранее предупредит вас о такой ситуации:

<Link href="/logim" />

Или вот так:

const { userld } = useRoute(routes.user.byId);

И если вы не можете сразу понять, что в них не так, вам обязательно нужен типобезопасный маршрутизатор :)

Система типов в TypeScript очень мощная. Я имею в виду, что вы можете создать шахматный движок, приключенческая игра или даже база данных SQL с использованием программирования на уровне типов.< /п>

Вы уже знакомы с «программированием на уровне значений», при котором вы манипулируете значениями, например объединяя две строки:

function concat(a, b) {
    return a + b;
}
concat('Hello, ', 'World!'); // 'Hello, World!'

Но вы можете сделать это и с типами!

type Concat<A extends string, B extends string> = `${A}${B}`;

type X = Concat<'Hello, ', 'World!'>;
//   ^? type X = "Hello, World!"

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

Мы будем использовать программирование на уровне типов для извлечения параметров из необработанных маршрутов и создания новых типов, которые смогут проверять, не пытается ли разработчик передать неправильные значения в Link или в функцию, создающую URL-адрес.

Программирование на уровне типов с помощью TS может быстро превратиться в нечитаемый беспорядок. К счастью для нас, уже существует множество проектов, которые скрывают всю эту сложность и позволяют нам писать такой чистый код:

export type RouteParam<
    Route extends RawRoute,
> = Pipe<
    Route,
    [
        Strings.Split<"/">,
        Tuples.Filter<Strings.StartsWith<":">>,
        Tuples.Map<Strings.TrimLeft<":">>,
        Tuples.ToUnion
    ]
>;

Довольно аккуратно, да? Это, кстати, весь код, необходимый для анализа параметров необработанного маршрута. В этом проекте мы будем использовать библиотеку hotscript — она поможет нам снизить сложность и объем кода на уровне типа.

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

Если вы собираетесь следовать инструкциям, я рекомендую вам создать новый проект React, используя ваш любимый стартовый вариант (я использую Вите) и начните программировать там. Таким образом, вы сможете сразу протестировать свой маршрутизатор.

Обратите внимание, что такие фреймворки, как Next.js, предоставляют собственную маршрутизацию, которая может помешать работе этого проекта, и вместо этого используют «ванильный» React. Если у вас возникнут трудности, вы можете найти полный код здесь.

Начните с установки сторонних пакетов: hotscript для утилит уровня типа и regexparam для анализа параметров URL/необработанного маршрута.

npm install hotscript regexparam

Первым строительным кирпичиком наших типов является необработанный маршрут. Необработанный маршрут должен начинаться с /; как бы вы это закодировали в TS? Вот так:

export type RawRoute = `/${string}`;

Легко, правда? Но defineRoutes не принимает одиночный необработанный маршрут, он принимает сопоставление, возможно, вложенное, поэтому давайте его закодируем. У вас может возникнуть соблазн написать что-то вроде этого:

export type RawRoutesMap = {
    [key: string]: RawRoute | RawRoutesMap
};

Это сработает. Однако этот тип может быть бесконечно глубоким, и в некоторых случаях TS будет сложно его вычислить. Чтобы облегчить жизнь TS, мы ограничим уровень разрешенной вложенности. 20 уровней вложенности должно быть достаточно для всех приложений, и TS легко с этим справится.

Но как ограничить глубину рекурсивных типов? Я научился этому трюку из этого ТАК-ответ; вот версия, модифицированная под наши требования:

export type RecursiveMap<T, MaxDepth extends number> = {
    [key: string]: RecursiveMap_<T, MaxDepth, []>;
};

type RecursiveMap_<T, MaxDepth extends number, Stack extends unknown[]> = 
    MaxDepth extends Stack["length"]
        ? T
        : T | { [key: string]: RecursiveMap_<T, MaxDepth, [1, ...Stack]> };

Это наш первый сложный тип, поэтому позвольте мне его объяснить. Здесь у нас есть два типа: RecursiveMap работает как точка входа и вызывает RecursiveMap_, передавая ему дополнительный параметр кортежа. Этот кортеж используется для отслеживания глубины отображения, при каждом вызове мы добавляем в этот массив один элемент.

И мы продолжаем его вызывать до тех пор, пока длина этого кортежа не станет равна MaxDepth. В TS, когда extends используется с конкретными значениями, также называемыми литералами (например, 42, а не number) ), это означает «равный».

А поскольку и MaxDepth, и Stack["length"] являются конкретными числами, этот код можно прочитать как MaxDepth === Stack["length"]. Вы увидите, что эта конструкция часто используется.

Зачем использовать кортеж вместо простого сложения чисел? Что ж, сложить два числа в TypeScript не так-то просто! Для этого существует целая библиотека, а Hotscript тоже может добавлять числа, но для этого требуется много кода (даже если вы не вижу его), что может замедлить работу вашего сервера TS и редактора кода при чрезмерном использовании.

Поэтому мое практическое правило — избегать, насколько это возможно, сложных типов.

С помощью этого типа утилиты мы можем определить наше сопоставление так просто:

export type RawRoutesMap = RecursiveMap<RawRoute, 20>;

Это все, что касается необработанных типов маршрутов. Следующим в очереди идет разобранный маршрут. Разобранный маршрут — это просто объект JavaScript с несколькими дополнительными полями и одной функцией; вот как это выглядит:

export type ParsedRoute<R extends RawRoute> = {
    keys: RouteParam<R>[];
    build(...params: PathConstructorParams<R>): Path<R>;
    raw: R;
    ambiguousness: number,
    pattern: RegExp;
};

Давайте начнем распаковку с поля keys. Это просто массив параметров, необходимых для этого маршрута. Вот как это делается:

import { Pipe, Strings, Tuples } from "hotscript";

export type RouteParam<
    Route extends RawRoute,
> = Pipe<
    Route,
    [
        Strings.Split<"/">,
        Tuples.Filter<Strings.StartsWith<":">>,
        Tuples.Map<Strings.TrimLeft<":">>,
        Tuples.ToUnion
    ]
>;

В Hotscript существует два способа вызова функции: Call или Pipe. Call полезен, когда вам нужно вызвать одну функцию, но в нашем случае их 4! Pipe принимает входные данные и передает их в первую функцию предоставленного кортежа.

Возвращаемое значение передается в качестве входных данных во вторую функцию и так далее. В нашем случае, если бы у нас был, например, необработанный маршрут /user/:userId/posts/:postId, он был бы преобразован следующим образом:

export type Beep = Pipe<
    "/user/:userId/posts/:postId",
    [
        Strings.Split<"/">, // ["user", ":userId", "posts", ":postId"]
        Tuples.Filter<Strings.StartsWith<":">>, // [":userId", ":postId"]
        Tuples.Map<Strings.TrimLeft<":">>, // ["userId", "postId"]
        Tuples.ToUnion // "userId" | "postId"
    ]
>;

Видеть? Это магия программирования на уровне типов! Теперь давайте займемся этой функцией build. Он принимает параметры маршрута (например, userId и postId) и необязательные параметры поиска/хеш и объединяет их в путь. Взгляните на реализацию PathConstructorParams:

// Allows us to also accept number and 
// any other type which can be converted into string
export type StringLike = { toString: () => string };

export type SearchAndHashPathConstructorParams = {
    hash?: string,
    search?: string | {
        [key: string]: string,
    }
};

export type RouteParamsMap<
    Route extends RawRoute,
    Val extends string | StringLike = string,
> = { [key in RouteParam<Route>]: Val };

export type PathConstructorParams<R extends RawRoute> = 
    | [RouteParamsMap<R, StringLike>] 
    | [RouteParamsMap<R, StringLike>, SearchAndHashPathConstructorParams];

Параметры функции определяются как массив (который позже...распространяется в определении

build function), где первый элемент — RouteParamsMap, а второй — необязательный SearchAndHashPathConstructorParams. А как насчет возврата значения build? Мы уже установили его путь, но как его описать с помощью TypeScript?

Что ж, этот очень похож на RouteParam, но требует немного больше гимнастики с типами!

import { Fn } from "hotscript";

interface ReplaceParam extends Fn {
    return: this["arg0"] extends `:${string}` ? string : this["arg0"];
}

// Leading slashes will be removed by Split<"/">, so we need to 
// add them back after our manipulations
type Pathname<
    Route extends RawRoute,
> = `/${Pipe<
    Route,
    [
        Strings.Split<"/">,
        Tuples.Map<ReplaceParam>,
        Tuples.Join<"/">
    ]
>}${Route extends `${string}/` ? '/' : ''}`;

export type Path<
    Route extends RawRoute,
> = Pathname<Route> | `${Pathname<Route>}?${string}` | `${Pathname<Route>}#${string}`;

Здесь мы разбиваем наш маршрут на сегменты, сопоставляем каждый сегмент и вызываем нашу пользовательскую функцию ReplaceParam для каждого. Он проверяет, является ли текущий сегмент параметром, и заменяет его на string или возвращает сегмент как есть. ReplaceParam «функция» может выглядеть немного странно, но именно так вы определяете пользовательские функции с помощью Hotscript.

Мы явно указываем, что путь состоит либо из простого пути, пути, за которым следует вопросительный знак (это касается URL-адресов с параметрами поиска и хеша), либо символа решетки (это касается URL-адресов без параметров поиска, но с хешем).

Нам также понадобится тип для описания сопоставленного маршрута, т. е. проанализированного маршрута с параметрами, полученными из URL:

// Interface (and not type) because we need to use `this`
export interface RouteWithParams<R extends RawRoute> {
    route: ParsedRoute<R>,
    params: RouteParamsMap<R>,
    // TS can't properly infer type of route object with simple 
    // check like currentRoute.route === routes.user.byId, so we
    // need our custom type guard
    matches: <T extends RawRoute>(route: ParsedRoute<T>) => this is RouteWithParams<T>,
}

Последний тип — ParsedRoutesMap; он похож на RawRoutesMap, но для анализируемых маршрутов.

// This accepts RawRoutesMap and transforms it into 
// mapping of parsed routes of same shape
export type ParsedRoutesMap<RM extends RawRoutesMap> = {
    [Key in keyof RM]: RM[Key] extends RawRoute
        ? ParsedRoute<RM[Key]>
        : RM[Key] extends RawRoutesMap
            ? ParsedRoutesMap<RM[Key]>
            : never;
};

И на этой ноте мы заканчиваем с типами. Будет еще несколько, но они проще, и мы рассмотрим их по мере реализации. Если вам хотелось бы больше попробовать программирование на уровне типа, вы можете ознакомиться с Typescript на уровне типа, чтобы узнать больше. и попробуйте решить type-challenges (у них есть хороший также список ресурсов).

Парсер маршрутов

Наконец, мы вернулись к обычному кодированию на уровне значений. Давайте начнем с реализации defineRoutes.

export const typedKeys = <const T extends {}> (obj: T) => {
    return Object.keys(obj) as Array<keyof T>;
};

export const defineRoutes = <const T extends RawRoutesMap>(routesMap: T): ParsedRoutesMap<T> => {
    const entries = typedKeys(routesMap).map((key) => {
        const entry = routesMap[key];
        if (typeof entry === 'string') {
            return [key, parseRoute(entry)] as const;
        } else {
            // Nested map
            return [key, defineRoutes(entry)] as const;
        }
    });

    return Object.fromEntries(entries);
};

Здесь нет ничего сложного; давайте углубимся в функцию parseRoute.

import { parse, inject, type RouteParams as RegexRouteParams } from "regexparam";

export class InvalidRoute extends Error { };

export class InvalidRouteParams extends Error { };

const parseRoute = <const R extends RawRoute>(route: R): ParsedRoute<R> => {
    if (!route.startsWith('/')) {
        throw new InvalidRoute('route should start with slash (/)')
    }

    const { keys, pattern } = parse(route);
    const hasRequiredParams = keys.length > 0;
    const parsedRoute: ParsedRoute<R> = {
        build(...args) {
            const params = (
                hasRequiredParams ? args[0] : undefined
            ) as RouteParamsMap<R, StringLike> | undefined;

            const searchAndHash = (
                hasRequiredParams ? args[1] : args[0]
            ) as SearchAndHashPathConstructorParams | undefined;

            if (hasRequiredParams) {
                if (!params) {
                    throw new InvalidRouteParams(
                        `Parameters for route ${route} weren't provided`
                    );
                }
                const missingKeys = keys.filter(k => !(k in params));
                if (missingKeys.length) {
                    throw new InvalidRouteParams(
                        `Missing parameters for route ${route}: ${missingKeys.join(', ')}`
                    );
                }
            } else if (args.length > 1) {
                throw new InvalidRouteParams(
                    `Route ${route} doesn't accept any parameters, received ${args[0]}`
                );
            }

            let path = hasRequiredParams ? inject(route, params as RegexRouteParams<R>) : route;
            if (searchAndHash && searchAndHash.search) {
                if (typeof searchAndHash.search === 'string') {
                    path += searchAndHash.search.startsWith('?') 
                        ? searchAndHash.search 
                        : '?' + searchAndHash.search;
                } else {
                    path += '?' + new URLSearchParams(searchAndHash.search).toString();
                }
            }
            if (searchAndHash && searchAndHash.hash) {
                path += searchAndHash.hash.startsWith('#') 
                    ? searchAndHash.hash 
                    : '#' + searchAndHash.hash;
            }

            return path as Path<R>;
        },
        raw: route,
        keys: keys as RouteParam<R>[] || [],
        ambiguousness: keys.length,
        pattern: pattern,
    };

    return parsedRoute;
};

parseRoute также очень прост, хотя и заметно длиннее. Для анализа маршрута и извлечения параметров мы используем библиотеку regexparam. Это позволяет нам получить массив параметров, необходимых для маршрута, и генерирует регулярное выражение, которое мы позже будем использовать для сопоставления URL-адреса с маршрутом.

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

Оболочка истории

Каждый маршрутизатор должен где-то хранить свое состояние. В случае приложений в браузере это на самом деле сводится к 4 вариантам: в памяти (либо в переменной состояния внутри корневого компонента, либо в переменной вне дерева компонентов), History API или хэш-часть URL-адреса.

Маршрутизация в памяти может быть вашим выбором, если вы вообще не хотите показывать пользователю, что у вас есть маршруты, например, если вы кодируете игру в React. Хранение маршрута в хеше может быть удобно, если ваше приложение React представляет собой всего лишь одну страницу в более крупном приложении, и вы не можете просто изменить URL-адрес по своему усмотрению.

Но в большинстве случаев использование History API будет лучшим вариантом. Он совместим с SSR (другие варианты не поддерживаются), следует шаблонам поведения, к которым привык пользователь, и выглядит максимально чисто. В этом проекте мы тоже будем его использовать. Однако у него есть один заметный недостаток: его практически невозможно использовать без дополнительных оберток.

С помощью History AP вы можете подписаться на событие popstate, и браузер сообщит вам, когда URL-адрес изменится. Но только если изменение инициировано пользователем, например, нажав кнопку «Назад». Если изменение URL-адреса инициируется из кода, вам необходимо отслеживать его самостоятельно.

Большинство маршрутизаторов, которые я изучал, используют собственную оболочку: реакции-маршрутизаторы и шиканы используют history пакет NPM, маршрутизатор TanStack имеет свой собственная реализация, а у wouter нет полноценной оболочки, но она все равно должна история обновлений обезьян.

Итак, давайте реализуем нашу собственную оболочку.

export type HistoryLocation = Pick<Location, 
    | 'origin' 
    | 'href' 
    | 'hash' 
    | 'search' 
    | 'pathname'
>;

export type NavigationBlocker = (isSoftNavigation: boolean) => boolean;

export const createHistory = () => {
    const winHistory = window.history;
    const winLocation = window.location;

    const getLocation = (): HistoryLocation => {
        return { 
            origin: winLocation.origin,
            href: winLocation.href,
            pathname: winLocation.pathname,
            search: winLocation.search,
            hash: winLocation.hash,
         };
    };

    /* Some magic code */

    return /* something... */;
};

Мы будем использовать два типа: HistoryLocation и NavigationBlocker. Во-первых, это немного ограниченная версия встроенного типа Location (это тип window.location), а второй будет рассмотрен, когда мы доберемся до блокировки навигации. . Весь дальнейший код из этой главы будет находиться внутри функции createHistory.

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

const subscribers: Set<VoidFunction> = new Set();

const onChange = () => {
    subscribers.forEach(fn => {
        try {
            fn();
        } catch (err) {
            console.error('Error while handling location update', err);
        }
    })
};

const subscribe = (listener: VoidFunction) => {
    subscribers.add(listener);
    return () => {
        subscribers.delete(listener);
    };
};

Следующий шаг — реагировать на изменения местоположения, в том числе на изменения, внесенные программно. Как бы вы это сделали? Конечно, с помощью monkey-patch. Это может показаться немного грязным (и это действительно так), но, к сожалению, у нас нет лучшего варианта.

const origPushState = winHistory.pushState.bind(winHistory);
const origReplaceState = winHistory.replaceState.bind(winHistory);

winHistory.pushState = (data, unused, url) => {
    // tryNavigate will be covered later
    tryNavigate(() => {
        origPushState(data, unused, url);
        onChange();
    });
};
winHistory.replaceState = (data, unused, url) => {
    tryNavigate(() => {
        origReplaceState(data, unused, url);
        onChange();
    });
};

// This event is emmited when user initiates navigation 
// or when calling history.go, history.back and history.forward
window.addEventListener('popstate', onChange);

И последний важный недостающий элемент в нашей истории реализации — это блокировка навигации: функция, которая позволяет перехватывать запрос навигации и условно отменять его. Каноническим примером блокировки навигации может быть предотвращение потери пользователем прогресса в форме.

let blockers: NavigationBlocker[] = [];

const beforeUnloadHandler = (event: Event) => {
    const blocked = blockers.some(blocker => blocker(false));
    if (blocked) {
        event.preventDefault();
        // @ts-ignore For older browsers
        event.returnValue = '';
        return '';
    }
};

const tryNavigate = (cb: VoidFunction) => {
    const blocked = blockers.some(blocker => blocker(true));
    if (blocked) return;
    cb();
};

const addBlocker = (blocker: NavigationBlocker) => {
    blockers.push(blocker);
    if (blockers.length === 1) {
        addEventListener('beforeunload', beforeUnloadHandler, { capture: true });
    }

    return () => {
        blockers = blockers.filter(b => b !== blocker);
        if (blockers.length === 0) {
            removeEventListener('beforeunload', beforeUnloadHandler, { capture: true });
        }
    }
};

В нашей реализации блокировщик — это функция, которая возвращает логическое значение, указывающее, нужно ли нам заблокировать эту навигацию. Что касается блокировки навигации, существует два типа навигации, и нам придется обращаться с ними по-разному.

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

С другой стороны, есть жесткая навигация — когда пользователь переходит на другой сайт или вообще закрывает вкладку. Браузер не может позволить JavaScript решать, следует ли выполнять эту навигацию, поскольку это будет угрозой безопасности. Но браузер позволяет JavaScript указать, хотим ли мы показать пользователю дополнительное диалоговое окно подтверждения.

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

Когда наша история вызывает функцию блокировки навигации, она предоставляет логическое значение, указывающее, выполняем ли мы мягкую навигацию.

И при всем при этом нам просто нужно вернуть наш объект истории:

return {
    subscribe,
    getLocation,
    push: winHistory.pushState,
    replace: winHistory.replaceState,
    go: (distance: number) => tryNavigate(() => winHistory.go.call(winHistory, distance)),
    back: () => tryNavigate(() => winHistory.back.call(winHistory)),
    forward: () => tryNavigate(() => winHistory.forward.call(winHistory)),
    addBlocker,
};

Шаг 1. Обязательный API

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

Нам нужны как утилита типа (которая преобразует ParsedRoutesMap в объединение ParsedRoute), так и функция (которая преобразует routesMap в массив проанализированных маршрутов). . Начнем с типа:

export type Values<T extends {}> = T[keyof T];

type FlattenRouteMap<T> = T extends ParsedRoute<any> | RawRoute
    ? T
    : T extends ParsedRoutesMap<RawRoutesMap> | RawRoutesMap
        ? AllRoutesFromMap<T>
        : never;

export type AllRoutesFromMap<
    RM extends ParsedRoutesMap<RawRoutesMap> | RawRoutesMap
> = FlattenRouteMap<Values<RM>>;

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

Для функции уровня значения нам также понадобится защита типа, чтобы проверить, является ли переданное значение проанализированным маршрутом.

export const isParsedRoute = <T extends `/${string}` = `/${string}`>(
    route: any
): route is ParsedRoute<T> => {
    return !!route 
        && typeof route === 'object' 
        && typeof route.raw === 'string' 
        && typeof route.build === 'function';
}

export const getAllRoutes = <T extends RawRoutesMap>(
    routesMap: ParsedRoutesMap<T>
): ParsedRoute<AllRoutesFromMap<T>>[] => {
    type PossibleRawRoute = AllRoutesFromMap<T>;
    return typedKeys(routesMap).flatMap((k) => {
        const val = routesMap[k];
        if (isParsedRoute<PossibleRawRoute>(val)) {
            return [val] as const;
        }
        // At this point we know that val isn't ParsedRoute, so it has to be map of routes
        // but TS can't infer that, so we help him a little by casting val to correct type 
        return getAllRoutes(val as ParsedRoutesMap<T>);
    });
};

Теперь приступим к реализации нашего маршрутизатора. Как и в истории, в этой и следующих двух главах весь код будет передан в функцию createRouter, если не указано иное.

import { 
    useSyncExternalStore, ComponentType, useMemo, MouseEventHandler, ComponentProps, useEffect 
} from 'react';

export const createRouter = <T extends RawRoutesMap>(routesMap: ParsedRoutesMap<T>) => {
    // Type for any possible route from passed routesMap
    type RouteType = AllRoutesFromMap<T>;
    // Similar to above, but for matched routes, i.e. includes URL parameters
    type BindedRouteWithParams = RouteWithParams<RouteType>;
    // Some of our functions will accept route filter, 
    // which can be single parsed route, array or object
    type RouteFilter<T extends RouteType> =
        | ParsedRoute<T>
        | ParsedRoute<T>[]
        | Record<string, ParsedRoute<T>>;

    const history = createHistory();
    const routes = getAllRoutes(routesMap);

};

Прежде всего, давайте научим наш маршрутизатор сопоставлять текущее местоположение с одним из известных маршрутов. Несколько утилит могут работать в глобальном масштабе или в отдельных файлах, а не внутри функции createRouter:

export const filterOutFalsy = <T>(obj: T[]): Exclude<T, undefined>[] => {
    return obj.filter(Boolean) as Exclude<T, undefined>[];
};

export class RouteMatchingConflict extends Error { };

// This will be used later
export class RouteMismatch extends Error { };

И этот код попадает в функцию createRouter.

const extractRouteParams = <T extends RawRoute>(
    pathname: string, 
    parsedRoute: ParsedRoute<T>
) => {
    const match = parsedRoute.pattern.exec(pathname);
    if (!match) return undefined;

    // Extract all route parameters from match array 
    // and construct object from them
    return Object.fromEntries(parsedRoute.keys.map((key, index) => {
        return [key, match[index + 1]];
    })) as RouteParamsMap<T>;
};

const findMatchingRoute = (
    location: HistoryLocation
): BindedRouteWithParams | undefined => {
    const matchingRoutes = filterOutFalsy(routes.map(route => {
        const params = extractRouteParams<RawRoute>(
            location.pathname, 
            route
        );
        if (!params) return undefined;
        return {
            route,
            params,
            matches<T extends RawRoute>(r: ParsedRoute<T>) {
                return route === r;
            },
        };
    }));

    if (matchingRoutes.length === 0) return undefined;
    if (matchingRoutes.length === 1) return matchingRoutes[0];

    // At this point we have multiple matching routes :/ 
    // Gotta decide which one we prefer
    let lowestAmbiguousnessLevel = Infinity;
    let lowestAmbiguousnessMatches: BindedRouteWithParams[] = [];
    matchingRoutes.forEach((match) => {
        if (match.route.ambiguousness === lowestAmbiguousnessLevel) {
            lowestAmbiguousnessMatches.push(match);
        } else if (match.route.ambiguousness < lowestAmbiguousnessLevel) {
            lowestAmbiguousnessLevel = match.route.ambiguousness;
            lowestAmbiguousnessMatches = [match];
        }
    });
    if (lowestAmbiguousnessMatches.length !== 1) {
        throw new RouteMatchingConflict(
            `Multiple routes with same ambiguousness level matched pathname ${location.pathname}: ${lowestAmbiguousnessMatches.map(m => m.route.raw).join(', ')}`
        );
    }

    return lowestAmbiguousnessMatches[0];
};

let currentRoute = findMatchingRoute(history.getLocation());

// This function will be later returned from createRouter function
const getCurrentRoute = () => currentRoute;

Здесь мы проходим все известные маршруты и пытаемся сопоставить каждый из них с текущим местоположением. Если регулярное выражение маршрута соответствует URL — мы получаем параметры маршрута из URL, в противном случае мы получаем null. Для каждого сопоставленного маршрута мы создаем объект RouteWithParams и сохраняем его в массив. Теперь, если у нас есть 0 или 1 совпадающих маршрутов, всё просто.

Однако если текущему местоположению соответствует более одного маршрута, нам придется решить, какой из них имеет более высокий приоритет. Чтобы решить эту проблему, мы используем поле ambiguousness. Как вы, возможно, помните, этот маршрут имеет ряд параметров, и приоритет имеет маршрут с наименьшей неоднозначностью.

Например, если бы у нас было два маршрута /app/dashboard и /app/:section, местоположение http://example.com/app/dashboard будет соответствовать обоим маршрутам. Но совершенно очевидно, что этот URL-адрес должен соответствовать маршруту /app/dashboard, а не /app/:section.

Однако этот алгоритм не является пуленепробиваемым. Например, маршруты /app/:user/settings/:section и /app/dashboard/:section/:region будут соответствовать URL-адресу http:// example.com/app/dashboard/settings/asia. А поскольку они имеют одинаковый уровень неоднозначности, наш маршрутизатор не сможет решить, какой из них должен быть приоритетным.

Теперь нам нужно склеить этот код, чтобы он реагировал на изменения местоположения и обновлял переменную currentRoute;

const areRoutesEqual = <A extends RawRoute, B extends RawRoute>(
    a: RouteWithParams<A> | undefined, 
    b: RouteWithParams<B> | undefined
): boolean => {
    if (!a && !b) return true; // Both are undefined
    if ((!a && b) || (a && !b)) return false; // Only one is undefined
    if (!a!.matches(b!.route)) return false; // Different routes
    // Same routes, but maybe parameters are different?
    const allParamsMatch = a.route.keys.every(key => a.params[key] === b!.params[key]);
    return allParamsMatch;
};

history.subscribe(() => {
    const newRoute = findMatchingRoute(history.getLocation());
    if (!areRoutesEqual(newRoute, currentRoute)) {
        currentRoute = newRoute;
        notifyRouteChange(); // Will be covered later
    }
});

Теперь наш роутер реагирует на изменение местоположения, и пользователь всегда может узнать текущий маршрут, ура! Но без возможности подписываться на изменения маршрутов это не очень полезно, поэтому давайте добавим это. Этот подход очень похож на тот, который мы использовали в обертке истории.

const subscribers: Set<VoidFunction> = new Set();

const subscribe = (cb: VoidFunction) => {
    subscribers.add(cb);
    return () => void subscribers.delete(cb);
};

const notifyRouteChange = () => {
    subscribers.forEach(cb => {
        try {
            cb();
        } catch (err) {
            console.error('Error in route change subscriber', err);
        }
    });
};

Для выполнения навигации мы предоставим функции navigate и navigateUnsafe, которые представляют собой простую оболочку history.push и history.replace. :

// This function accepts any string path (no type-safety)
const navigateUnsafe = (
    path: string, 
    { action = 'push' }: { action?: 'push' | 'replace' } = {}
) => {
    history[action]({}, '', path)
};

// And this function accepts only paths that correspond to one of routes
const navigate = (
    path: Path<RouteType>, 
    options: { action?: 'push' | 'replace' } = {}
) => {
    navigateUnsafe(path, options);
};

Ну вот это настоящий роутер! Очень примитивно, но тем не менее работает. Нам еще нужно реализовать некоторые хуки и компоненты, но дальше все станет намного проще.

Шаг 2. Крючки

Что касается хуков, мы можем начать с простых, которые возвращают текущее местоположение и текущий маршрут. Сами по себе они довольно просты, но useSyncExternalStore превращает их в однострочные. То, как мы ранее разработали наш императивный API, позволило нам значительно сократить код для этих перехватчиков.

const useLocation = () => {
    return useSyncExternalStore(history.subscribe, history.getLocation);
};

const useCurrentRoute = () => {
    return useSyncExternalStore(subscribe, getCurrentRoute);
};

При кодировании компонентов, которые должны отображаться только на определенном маршруте/наборе маршрутов, вы можете использовать useCurrentRoute, чтобы получить текущий маршрут, проверить, соответствует ли он критериям, а затем использовать его параметры (или выдать ошибку).

Но это настолько распространенная задача, что заставлять наших пользователей писать для нее собственный хук будет преступлением — наш маршрутизатор должен обеспечивать это «из коробки».

function useRoute<T extends RouteType>(filter: RouteFilter<T>, strict?: true): RouteWithParams<T>;
function useRoute<T extends RouteType>(filter: RouteFilter<T>, strict: false): RouteWithParams<T> | undefined;
function useRoute<T extends RouteType>(filter: RouteFilter<T>, strict?: boolean): RouteWithParams<T> | undefined {
    const currentRoute = useCurrentRoute();
    const normalizedFilter = Array.isArray(filter)
        ? filter
        : isParsedRoute(filter)
            ? [filter]
            : Object.values(filter);
    const isMatching = !!currentRoute 
        && normalizedFilter.some(route => currentRoute.matches(route));
    if (isMatching) return currentRoute as RouteWithParams<T>;
    else {
        if (strict === false) return undefined;
        throw new RouteMismatch(
            `Current route doesn't match provided filter(s)`
        );
    }
}

Этот крючок имеет два варианта: строгий и расслабленный. Если пользователь передает true в качестве второго параметра (или ничего не передает, поскольку true является значением по умолчанию), этот перехват выдаст ошибку, если текущий маршрут не передает t соответствует одному из предоставленных фильтров.

Таким образом, вы можете быть уверены, что перехватчик вернет соответствующий маршрут или не вернется вообще. Если второй параметр имеет значение false, то вместо того, чтобы генерировать исключение, перехватчик просто вернет значение undefine, если текущий маршрут не соответствует фильтрам.

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

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

const useSearchParams = () => {
    const location = useLocation();
    return useMemo(() => {
        return Object.fromEntries(
            (new URLSearchParams(location.search)).entries()
        );
    }, [location.search]);
};

И последний хук в этом разделе — useNavigationBlocker, который также довольно прост: он просто принимает обратный вызов и оборачивает вызовы history.addBlocker в эффект, чтобы повторно присоединить блокировщик, если он изменится.

const useNavigationBlocker = (cb: NavigationBlocker) => {
    useEffect(() => {
        return history.addBlocker(cb);
    }, [cb]);
};

Теперь перейдем к компонентам!

Шаг 3. Компоненты

Какой компонент первым приходит на ум при упоминании библиотек маршрутизации? Могу поспорить, что это Route или, по крайней мере, что-то подобное. Как вы видели ранее, наши перехватчики были очень простыми благодаря хорошо продуманному императивному API, который выполнял всю тяжелую работу.

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

type RouteProps = { component: ComponentType, match: RouteFilter<RouteType> };

const Route = ({ component: Component, match }: RouteProps) => {
    const matchedRoute = useRoute(match, false);
    if (!matchedRoute) return null;
    return (<Component />);
};

Ну, это было легко! Хотите угадать, как мы реализуем компонент NotFound? :)

type NotFoundProps = { component: ComponentType };

const NotFound = ({ component: Component }: NotFoundProps) => {
    const currentRoute = useCurrentRoute();
    if (currentRoute) return null;
    return (<Component />);
};

И последний компонент, необходимый для нашего маршрутизатора, — это Link, который немного сложнее. Вы не можете просто использовать <a href="/app/dashboard" />, поскольку он всегда инициирует жесткую навигацию и не обеспечивает никакой безопасности типов. Итак, давайте разберемся с этими вопросами:

type LinkProps = Omit<ComponentProps<"a">, 'href'>
    & (
        // Our link accepts either type-strict href
        // or relaxed unsafeHref
        { href: Path<RouteType>, unsafeHref?: undefined }
        | { href?: undefined, unsafeHref: string }
    ) & { action?: 'push' | 'replace' };

const Link = ({ 
    action = 'push', onClick, href, unsafeHref, ...props 
}: LinkProps) => {
    const hrefToUse = (href ?? unsafeHref)!;
    const targetsCurrentTab = props.target !== '_blank';

    const localOnClick: MouseEventHandler<HTMLAnchorElement> = (event) => {
        if (onClick) {
            onClick(event);
            if (event.isDefaultPrevented()) {
                // User-defined click handler cacnelled navigation, we should exit too
                return;
            }
        }

        const inNewTab = !targetsCurrentTab 
            || event.ctrlKey 
            || event.shiftKey 
            || event.metaKey 
            || event.button === 1;
        if (!isExternal && !inNewTab) {
            event.preventDefault();
            navigateUnsafe(hrefToUse, { action });
        }
    };
    const isExternal = useMemo(() => {
        if (!hrefToUse) return false;
        return new URL(hrefToUse, window.location.href).origin !== location.origin;
    }, [hrefToUse]);

    return <a {...props} href={hrefToUse} onClick={localOnClick} />
};

Подобно функции navigate, тип компонента Link проверяет URL-адрес, который вы ему передаете, но также позволяет вам предоставить произвольную строку URL-адреса (в качестве аварийного люка или для внешних ссылок). . Чтобы переопределить поведение <a>, мы подключаем наш собственный прослушиватель onClick, внутри которого нам нужно будет вызвать исходный onClick ( передается нашему компоненту Link).

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

И теперь нам просто нужно вернуть все наши функции, перехватчики и компоненты (вместе с несколькими функциями, реэкспортированными непосредственно из истории) из функции createRouter.

return {
    // Directly re-exported from history
    go: history.go,
    back: history.back,
    forward: history.forward,
    addBlocker: history.addBlocker,
    getLocation: history.getLocation,
    subscribeToLocation: history.subscribe,
    // Imperative API
    subscribe,
    getCurrentRoute,
    navigate,
    navigateUnsafe,
    // Hooks
    useLocation,
    useCurrentRoute,
    useRoute,
    useSearchParams,
    useNavigationBlocker,
    // Components
    Link,
    Route,
    NotFound,
};

На этом наш роутер готов. Теперь мы можем создать наше крошечное приложение, чтобы продемонстрировать только что созданный нами крошечный маршрутизатор! Кстати, вы можете найти полный код этого маршрутизатора (включая код примера приложения) здесь.

Собираем кусочки головоломки

Итак, как же весь этот код связан вместе? Очень аккуратно, если я так говорю! Представьте, что вы создаете приложение для заметок. Во-первых, вы должны начать с определения маршрутов и создания маршрутизатора следующим образом:

export const routes = defineRoutes({
    // Yep, this will be very minimal app
    root: '/',
    newNote: '/new',
    note: '/note/:noteId',
});

const router = createRouter(routes);

// Export functions and component you will be using
export const { 
    navigate, Link, Route, NotFound, useRoute, useNavigationBlocker 
} = router;

Затем вы связываете определенные вами маршруты с компонентами страницы:

function App() {
    return (
        <div className="app">
            <div className="links">
                {/* This is how you build URL for Link */}
                <Link href={routes.root.build({})}>View all notes</Link>
                <Link href={routes.newNote.build({})}>Create new</Link>
            </div>
            <Route match={routes.root} component={NotesListPage} />
            <Route match={routes.newNote} component={NewNotePage} />
            <Route match={routes.note} component={NoteDetailsPage} />
            <NotFound component={NotFoundPage} />
        </div>
    )
}

В NoteDetailsPage вам необходимо получить идентификатор заметки из URL-адреса, поэтому вы используете перехватчик useRoute:

export const NoteDetailsPage = () => {
    const { getNote } = useNotes();
    const { params } = useRoute(routes.note);
    const note = getNote(params.noteId);

    return note ? (<>
        <h1>{note.title}</h1>
        <div>{note.text}</div>
    </>) : (<h1>Not found</h1>);
};

И при создании новой заметки вы, вероятно, захотите подтвердить намерение пользователя, если он уйдет, не сохранив заметку:

export const NewNotePage = () => {
    const saveNote = () => {
        isSubmittingNoteRef.current = true;
        const note = createNote(title, text);
        // And this is programmatic redirect
        navigate(routes.note.build({ noteId: note.id }));
        isSubmittingNoteRef.current = false;
    };

    const [title, setTitle] = useState('');
    const [text, setText] = useState('');
    const { createNote } = useNotes();
    const isSubmittingNoteRef = useRef(false);

    useNavigationBlocker((isSoftNavigation) => {
        const dirty = title !== '' || text !== '';
        if (!dirty || isSubmittingNoteRef.current) return false;
        if (isSoftNavigation) {
            const confirmation = confirm('Do you want to leave?');
            return !confirmation;
        } else {
            return true;
        }
    });

    return <>
        <h1>New note</h1>
        <input placeholder="Title" value={title} onChange={e => setTitle(e.target.value)} />
        <textarea placeholder="Text" value={text} onChange={e => setText(e.target.value)} />

        <button onClick={saveNote}>Save</button>
    </>;
};

Возможные улучшения

Хотя наш маршрутизатор действительно выполняет маршрутизацию, он не может сравниться с готовыми к использованию решениями, такими как маршрутизатор TanStack, маршрутизатор React-Router или маршрутизатор Next.js. Я имею в виду, что это всего около 500 строк кода, это немного. Но чего именно не хватает?

Прежде всего, рендеринг на стороне сервера. Сегодня не всем приложениям может потребоваться SSR, но ожидается, что все библиотеки маршрутизации будут его поддерживать. Добавление рендеринга на стороне сервера в строку (не потоковая передача SSR!) потребует создания другой истории, которая будет хранить текущее местоположение в памяти (поскольку на сервере нет API истории) и подключения ее к функция createRouter.

Я не знаю, насколько сложно будет реализовать потоковую SSR, но предполагаю, что это будет сильно связано с поддержкой Suspense.

Во-вторых, этот маршрутизатор плохо интегрируется с параллельным рендерингом. В основном из-за того, что мы используем useSyncExternalStore, поскольку он несовместим с неблокирующими переходами. Это работает таким образом, чтобы избежать разрывов: ситуация, когда часть пользовательского интерфейса отображается с определенным значением хранилища, а остальная часть пользовательского интерфейса отображается с другим.

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

Но даже несмотря на эти недостатки, я надеюсь, что эта статья показалась вам интересной и вы создали свой собственный маршрутизатор :)


Оригинал