paint-brush
Как написать свой собственный Typesafe React Router в 500 строкк@olegwock
549 чтения
549 чтения

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

к OlegWock39m2024/03/11
Read on Terminal Reader

Слишком долго; Читать

Итак, присоединяйтесь ко мне в этом посте, где мы с нуля создадим собственный типобезопасный маршрутизатор, чтобы открыть этот черный ящик и понять его внутреннюю работу. В этой статье предполагается, что вы уже знаете React и умеете работать с TypeScript.
featured image - Как написать свой собственный Typesafe React Router в 500 строк
OlegWock HackerNoon profile picture
0-item

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


Для меня 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 , Route и 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, используя ваш любимый стартер (я использую Vite ), и начать писать код там. Таким образом, вы сможете сразу протестировать свой маршрутизатор.


Обратите внимание, что такие платформы, как 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 легко с этим справится.


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

 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 ), где первый элемент — 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 на уровне типа , чтобы узнать больше и попытаться решить проблемы с типами (у них также есть хороший список ресурсов ).

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

Наконец, мы вернулись к обычному кодированию на уровне значений. Давайте начнем с реализации 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-адреса инициируется из кода, вам необходимо отслеживать его самостоятельно.


Большинство маршрутизаторов, которые я изучал, используют свою собственную оболочку: пакет NPM для истории использования React-Router и Chicane, маршрутизатор 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); }; };


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

 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, ie 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 . Как вы, наверное, помните, этот маршрут имеет ряд параметров, и приоритет имеет маршрут с наименьшей 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: Крючки

Что касается хуков, мы можем начать с простых, которые возвращают текущее местоположение и текущий маршрут. Сами по себе они довольно просты, но использованиеSyncExternalStore превращает их в однострочные. То, как мы ранее разработали наш императивный 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 — значение по умолчанию), этот перехват выдаст ошибку, если текущий маршрут не соответствует одному из предоставленных фильтров.


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


Чтобы описать это поведение в 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!) потребует создания другой history , которая будет хранить текущее местоположение в памяти (поскольку на сервере нет API истории) и подключения ее к функции createRouter .


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


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


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


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