@budarin/use-route 1.3.1 → 1.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/LICENSE +21 -21
- package/README.md +631 -596
- package/demo/node_modules/.bin/browserslist +2 -2
- package/demo/node_modules/.bin/browserslist.CMD +12 -0
- package/demo/node_modules/.bin/browserslist.ps1 +41 -0
- package/demo/node_modules/.bin/tsc +2 -2
- package/demo/node_modules/.bin/tsc.CMD +12 -0
- package/demo/node_modules/.bin/tsc.ps1 +41 -0
- package/demo/node_modules/.bin/tsserver +2 -2
- package/demo/node_modules/.bin/tsserver.CMD +12 -0
- package/demo/node_modules/.bin/tsserver.ps1 +41 -0
- package/demo/node_modules/.bin/vite +2 -2
- package/demo/node_modules/.bin/vite.CMD +12 -0
- package/demo/node_modules/.bin/vite.ps1 +41 -0
- package/demo/node_modules/.vite/deps/@budarin_use-route.js +25 -71
- package/demo/node_modules/.vite/deps/@budarin_use-route.js.map +3 -3
- package/demo/node_modules/.vite/deps/_metadata.json +15 -15
- package/demo/node_modules/.vite/deps/{chunk-DBBEQ5LR.js → chunk-3SNVYWQ3.js} +3 -16
- package/demo/node_modules/.vite/deps/{chunk-DBBEQ5LR.js.map → chunk-3SNVYWQ3.js.map} +1 -1
- package/demo/node_modules/.vite/deps/{chunk-4BQM3FN6.js → chunk-OTZU4T7N.js} +3 -16
- package/demo/node_modules/.vite/deps/{chunk-4BQM3FN6.js.map → chunk-OTZU4T7N.js.map} +1 -1
- package/demo/node_modules/.vite/deps/react-dom.js +2 -2
- package/demo/node_modules/.vite/deps/react-dom_client.js +10 -34
- package/demo/node_modules/.vite/deps/react-dom_client.js.map +1 -1
- package/demo/node_modules/.vite/deps/react.js +1 -1
- package/demo/node_modules/.vite/deps/react_jsx-dev-runtime.js +2 -15
- package/demo/node_modules/.vite/deps/react_jsx-dev-runtime.js.map +1 -1
- package/demo/node_modules/.vite/deps/react_jsx-runtime.js +2 -15
- package/demo/node_modules/.vite/deps/react_jsx-runtime.js.map +1 -1
- package/demo/package.json +9 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +34 -2
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +4 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +2 -1
- package/demo/dist/assets/index-CehTkyXl.css +0 -1
- package/demo/dist/assets/index-wDy-y7oj.js +0 -49
- package/demo/dist/index.html +0 -13
- package/demo/tsconfig.tsbuildinfo +0 -1
- package/tsconfig.tsbuildinfo +0 -1
package/README.md
CHANGED
|
@@ -1,596 +1,631 @@
|
|
|
1
|
-
# @budarin/use-route
|
|
2
|
-
|
|
3
|
-
**Минимум кода. Максимум SPA-навигации.**
|
|
4
|
-
|
|
5
|
-
Инфраструктурный хук для React.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
- **
|
|
14
|
-
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
[. Без провайдеров, без контекста, без бизнес-логики.
|
|
7
|
+
|
|
8
|
+
**Сферы применения:**
|
|
9
|
+
|
|
10
|
+
- **Чистая архитектура** — загрузка данных в use cases и сервисах, не в роутере; хук даёт только состояние маршрута и навигацию, без встроенной загрузки данных, без бизнес-логики.
|
|
11
|
+
- **Динамическое дерево компонентов** — что рендерить определяется в рантайме по URL (`pathname`, `params`, `matched`), а не статичным деревом маршрутов. Подходит, когда маршруты зависят от ролей, фич-флагов, CMS или конфига с бэка.
|
|
12
|
+
- **Иерархия URL без вложенных роутов** — плоская, графовая или условная структура путей; один паттерн (или PathMatcher) и проверка по `params` вместо вложенных `<Route>`.
|
|
13
|
+
- **SPA по подпути** — приложение может располагаться не в корне домена, а по подпути (например `/app/`); глобальный `base` в конфиге и опция `base` в `navigate`/`replace` для переходов «вне» этого пути.
|
|
14
|
+
- **SSR и гибридные сетапы** — на сервере задаётся `initialLocation` в конфиге один раз перед рендером запроса; единообразная настройка без отдельного API.
|
|
15
|
+
- **Реальные SPA и гибридные приложения** — один хук, один конфиг, типы и тесты; применим в продакшене при опоре на современные браузеры (Navigation API + URLPattern).
|
|
16
|
+
|
|
17
|
+
История формируется динамически: при каждом переходе можно выбрать `push` или `replace`.
|
|
18
|
+
|
|
19
|
+
[](https://github.com/budarin/use-route/actions/workflows/ci.yml)
|
|
20
|
+
[](https://www.npmjs.com/package/@budarin/use-route)
|
|
21
|
+
[](https://www.npmjs.com/package/@budarin/use-route)
|
|
22
|
+
[](https://bundlephobia.com/result?p=@budarin/use-route)
|
|
23
|
+
[](https://github.com/budarin/use-route)
|
|
24
|
+
|
|
25
|
+
**Живое демо:** запуск в браузере без установки.
|
|
26
|
+
|
|
27
|
+
- [Open in StackBlitz](https://stackblitz.com/github/budarin/use-route/tree/master/demo)
|
|
28
|
+
- [Open in CodeSandbox](https://codesandbox.io/p/sandbox/github/budarin/use-route/tree/master/demo)
|
|
29
|
+
|
|
30
|
+
## ✨ Особенности
|
|
31
|
+
|
|
32
|
+
- ✅ **Динамическое дерево** — маршрутизация в рантайме по pathname/params, без статичного route tree
|
|
33
|
+
- ✅ **Динамическая история** — при каждом `navigate`/`replace` выбирается `push` или `replace`
|
|
34
|
+
- ✅ **Navigation API** — `navigation.navigate()`, `back()`, `forward()`, `traverseTo()`
|
|
35
|
+
- ✅ **URLPattern** для парсинга `:params`
|
|
36
|
+
- ✅ **useSyncExternalStore** — concurrent-safe, SSR-ready
|
|
37
|
+
- ✅ **canGoBack(n)**, `canGoForward(n)` — точная проверка по истории
|
|
38
|
+
- ✅ **state** — чтение state текущей записи истории, установка при `navigate`/`replace`, обновление на месте через `updateState(state)`
|
|
39
|
+
- ✅ **LRU кэш URL** с настраиваемым лимитом (по умолчанию 50)
|
|
40
|
+
- ✅ **O(1) поиск** при получении `historyIndex`
|
|
41
|
+
- ✅ **0 провайдеров** — нет необходимости - просто `useRoute()`
|
|
42
|
+
- ✅ **~4 kB** gzipped (исходный код; с минификацией в бандле меньше)
|
|
43
|
+
|
|
44
|
+
## ⚠️ Когда не использовать
|
|
45
|
+
|
|
46
|
+
- **Нужна поддержка старых браузеров** — хук требует Navigation API и URLPattern (см. таблицу версий). Для старых браузеров возьмите React Router, TanStack Router или роутер с полифиллами.
|
|
47
|
+
- **Нужны loaders или загрузка данных в роутере** — здесь загрузка данных не входит в зону ответственности; её делают use cases и сервисы. Если вы хотите loaders/данные «из коробки» в маршруте — подойдут React Router (loaders) или TanStack Router.
|
|
48
|
+
- **Нужно декларативное дерево маршрутов** — хук не предоставляет `<Route>` / `<Routes>`; что рендерить, вы решаете в коде по `pathname`/`params`. Если важна именно декларативная вложенная структура маршрутов — используйте один из перечисленных роутеров.
|
|
49
|
+
- **Нужны встроенные guards, redirects, lazy-роуты** — этого в пакете нет; реализуется в приложении поверх хука.
|
|
50
|
+
|
|
51
|
+
В остальных случаях (современные браузеры, чистая архитектура, динамические маршруты) пакет подходит.
|
|
52
|
+
|
|
53
|
+
## 🚀 Быстрый старт
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npm i @budarin/use-route
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
import { useRoute, configureRouter } from '@budarin/use-route';
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
function App() {
|
|
64
|
+
const {
|
|
65
|
+
pathname,
|
|
66
|
+
params,
|
|
67
|
+
searchParams,
|
|
68
|
+
navigate,
|
|
69
|
+
go,
|
|
70
|
+
canGoBack
|
|
71
|
+
} = useRoute('/users/:id'); // опционально: паттерн для парсинга params
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div>
|
|
75
|
+
<h1>Current: {pathname}</h1>
|
|
76
|
+
<p>User ID: {params.id}</p>
|
|
77
|
+
|
|
78
|
+
<button onClick={() => navigate('/users/123')}>
|
|
79
|
+
To Profile
|
|
80
|
+
</button>
|
|
81
|
+
|
|
82
|
+
<button onClick={() => go(-1)} disabled={!canGoBack()}>
|
|
83
|
+
← Back
|
|
84
|
+
</button>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## 📖 API
|
|
91
|
+
|
|
92
|
+
### `useRoute(pattern?: string | PathMatcher, options?: UseRouteOptions)` / `useRoute(options: UseRouteOptions)`
|
|
93
|
+
|
|
94
|
+
**Формы вызова:**
|
|
95
|
+
|
|
96
|
+
- **`useRoute()`** — без pattern и опций.
|
|
97
|
+
- **`useRoute(pattern)`** — только pattern (строка или PathMatcher).
|
|
98
|
+
- **`useRoute(pattern, options)`** — pattern и опции (например `section`).
|
|
99
|
+
- **`useRoute({ section: '/dashboard' })`** — только опции, без pattern (раздел под глобальным base; pathname и navigate относительно раздела).
|
|
100
|
+
|
|
101
|
+
**Параметры:**
|
|
102
|
+
|
|
103
|
+
- **`pattern`** (опционально) — строка-паттерн (URLPattern) или PathMatcher для парсинга `params` и `matched`.
|
|
104
|
+
- **`options`** (опционально)
|
|
105
|
+
|
|
106
|
+
**`section`**: путь раздела под глобальным base (например `/dashboard`). `navigate(to)` по умолчанию добавляет к путям полный префикс (base + section). Комбинируется с глобальным `base` из `configureRouter`, не заменяет его. В компонентах раздела вызывайте `useRoute({ section: '/dashboard' })` и работайте с путями относительно раздела.
|
|
107
|
+
|
|
108
|
+
**Возвращает:**
|
|
109
|
+
|
|
110
|
+
```typescript
|
|
111
|
+
{
|
|
112
|
+
// Текущее состояние
|
|
113
|
+
location: string;
|
|
114
|
+
pathname: string;
|
|
115
|
+
searchParams: URLSearchParams; // только чтение, не мутировать
|
|
116
|
+
params: Record<string, string>;
|
|
117
|
+
historyIndex: number;
|
|
118
|
+
state?: unknown; // state текущей записи истории (getState() / history.state)
|
|
119
|
+
matched?: boolean; // true/false при переданном pattern, иначе undefined
|
|
120
|
+
|
|
121
|
+
// Навигация
|
|
122
|
+
navigate: (to: string | URL, options?) => Promise<void>; // Navigation API; same-document при перехвате navigate + intercept()
|
|
123
|
+
back: () => void;
|
|
124
|
+
forward: () => void;
|
|
125
|
+
go: (delta: number) => void;
|
|
126
|
+
replace: (to: string | URL, options?: NavigateOptions) => Promise<void>;
|
|
127
|
+
updateState: (state: unknown) => void; // обновить state текущей записи без навигации
|
|
128
|
+
canGoBack: (steps?: number) => boolean;
|
|
129
|
+
canGoForward: (steps?: number) => boolean;
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**Опции методов `navigate` и `replace`** (один интерфейс **NavigateOptions**):
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
{
|
|
137
|
+
history?: 'push' | 'replace' | 'auto'; // по умолчанию из configureRouter или 'auto'
|
|
138
|
+
state?: unknown; // опциональные данные перехода (только подсказки для UX); подробнее — раздел про state ниже
|
|
139
|
+
base?: string; // полная подстановка префикса для этого вызова: '' или '/' — без префикса (другое приложение); иначе — полный путь (напр. '/auth')
|
|
140
|
+
section?: string; // переопределение секции для этого вызова: '' — корень приложения (только global base); '/path' — другая секция
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
**`state`** — произвольные данные, которые вы передаёте вместе с переходом в `navigate(to, { state })` или `replace(to, { state })`. Используйте только для опциональных подсказок (скролл, откуда пришли, префилл формы): страница должна корректно работать и при заходе по прямой ссылке без state. Подробно: см. ниже «Параметр state: когда добавлять в историю».
|
|
145
|
+
|
|
146
|
+
**`replace(to, options?)`** — то же, что `navigate(to, { ...options, history: 'replace' })`. Опции те же, что у **navigate** (state, base, section); поле **history** игнорируется (всегда замена записи).
|
|
147
|
+
|
|
148
|
+
**`updateState(state)`** — обновляет state **текущей** записи истории без навигации. Подписчики хука получают новый state; URL не меняется, новая запись в истории не создаётся. Удобно для черновика формы, позиции скролла и т.п.
|
|
149
|
+
|
|
150
|
+
**Параметр state: когда добавлять в историю и какое состояние можно передавать**
|
|
151
|
+
|
|
152
|
+
Многие разработчики ни разу не используют state при переходах — это нормально. State нужен только в узких сценариях. Ниже — когда его стоит добавлять, какое состояние можно передавать и какое нельзя, чтобы не было недопониманий.
|
|
153
|
+
|
|
154
|
+
**Что такое state и откуда он берётся.** State — это произвольные данные, которые вы передаёте в `navigate(to, { state })` или `replace(to, { state })`. Они сохраняются в записи истории (Navigation API) и доступны в хуке через поле **`state`** в возвращаемом объекте. **Важно:** state появляется только при программном переходе (ваш вызов navigate/replace). Если пользователь попал на тот же URL извне — ввёл адрес в строке, перешёл по букмарку, по ссылке с другого сайта, обновил страницу — для этой записи истории state нет. Поэтому поведение страницы не должно от state критически зависеть.
|
|
155
|
+
|
|
156
|
+
**Когда нужно добавлять state в историю.** Добавляйте state только когда вы хотите передать «подсказку» для целевой страницы, которая улучшает UX при программном переходе, но не обязательна для корректной работы:
|
|
157
|
+
|
|
158
|
+
- **Подсказка для скролла** — уходя со страницы списка, сохраняете в state позицию скролла; по «Назад» можно вернуть пользователя на то же место. Если зашли по прямой ссылке — state нет, показываете список с начала.
|
|
159
|
+
- **Подсказка «откуда пришли»** — переход с поиска на карточку: в state передаёте `{ from: 'search', highlight: 'keyword' }`; на карточке можно подсветить слово. При заходе по прямой ссылке подсветки нет — страница остаётся корректной.
|
|
160
|
+
- **Опциональный префилл формы** — переход «Редактировать» из списка: в state передаёте черновик; на странице редактирования при наличии state подставляете его, при отсутствии — грузите данные по id из URL/сервера.
|
|
161
|
+
- **Черновик формы на текущей странице** — при вводе в форму можно периодически сохранять черновик в state текущей записи через `updateState(draft)`; по «Назад» пользователь вернётся на эту страницу с тем же state, и вы подставите черновик. Без state показываете пустую форму или грузите данные по URL.
|
|
162
|
+
- **Источник перехода (аналитика, UI)** — в state передаёте `{ source: 'dashboard' }`; целевая страница может отправить это в аналитику или чуть изменить UI. При заходе по ссылке без state считаете источник «прямой» или «unknown».
|
|
163
|
+
|
|
164
|
+
**Какое state можно передавать.** Только то, что является **опциональным улучшением**: подсказки для скролла, флаги «откуда пришли», опциональный префилл, метаданные для аналитики. Правило: целевая страница должна корректно работать и без state (при прямом заходе по URL).
|
|
165
|
+
|
|
166
|
+
**Какое state нельзя передавать.** Не используйте state для того, без чего страница работает некорректно или неполно:
|
|
167
|
+
|
|
168
|
+
- **Обязательные данные страницы** — например, результаты поиска только из state. При переходе по ссылке `/search?q=foo` state нет — экран пустой. Результаты должны браться из query или сервера.
|
|
169
|
+
- **То, что должно быть в URL (шаринг, букмарк)** — state не попадает в URL. Если поведение страницы должно воспроизводиться по одной ссылке — используйте pathname и query, не state.
|
|
170
|
+
- **Авторизация, права, критичные данные** — не опирайтесь на state: пользователь может открыть URL напрямую. Проверки — по сессии/серверу.
|
|
171
|
+
- **Основной контент страницы** — что показывать, определяется URL и данными с бэкенда. State — только для подсказок, не источник правды.
|
|
172
|
+
|
|
173
|
+
**Итог.** State в истории — опциональный инструмент для «передать что-то вместе с переходом», когда это улучшение, а не требование. Если сомневаетесь — можно не использовать; в большинстве приложений достаточно pathname, query и запросов к API.
|
|
174
|
+
|
|
175
|
+
**`configureRouter(config)`** — глобальная настройка один раз при старте приложения:
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
configureRouter({
|
|
179
|
+
urlCacheLimit: 50, // лимит LRU-кэша URL (по умолчанию 50)
|
|
180
|
+
defaultHistory: 'replace', // history по умолчанию для всех navigate()
|
|
181
|
+
logger: myLogger, // логгер (дефолт: console)
|
|
182
|
+
base: '/app', // базовый путь: pathname без base, navigate(to) добавляет base к относительным путям
|
|
183
|
+
initialLocation: request.url, // для SSR: начальный URL при рендере на сервере (нет window)
|
|
184
|
+
});
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
**`base`** (глобальный) — нужен только когда **приложение располагается не в корне домена**, а по подпути. Пример: сайт `https://example.com/` — корень; ваше приложение отдаётся по `https://example.com/app/`, то есть все его маршруты физически лежат под путём `/app`. В этом случае задайте `base: '/app'`: `navigate('/dashboard')` переходит на `/app/dashboard`. Если приложение в корне домена (`https://example.com/`), глобальный `base` задавать не нужно — префикс не используется.
|
|
188
|
+
|
|
189
|
+
**`initialLocation`** — при SSR (нет `window`) хук не знает URL запроса. Задайте `initialLocation: request.url` (или полный URL страницы) один раз перед рендером запроса — тогда `pathname` и `searchParams` будут соответствовать запросу. На клиенте не используется. **По умолчанию задавать не нужно:** если на SSR `initialLocation` не задан, используется `'/'` (pathname и searchParams для корня).
|
|
190
|
+
|
|
191
|
+
**`logger`** тип `Logger` — объект с методами `trace`, `debug`, `info`, `warn`, `error` (как у `console`). Уровни: `LoggerLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error'`. Если не передан — используется `console`.
|
|
192
|
+
|
|
193
|
+
**`pattern` (опционально):** строка-шаблон пути (нативный **URLPattern**) или функция **PathMatcher**. См. ниже.
|
|
194
|
+
|
|
195
|
+
**Строка (URLPattern).** Поддерживается:
|
|
196
|
+
|
|
197
|
+
- **Именованные параметры** — `:name` (имя как в JS: буквы, цифры, `_`). Значение сегмента попадает в `params[name]`.
|
|
198
|
+
- **Опциональные группы** — `{ ... }?`: часть пути можно сделать необязательной. Один паттерн покрывает пути разной глубины; в `params` только те ключи, для которых есть сегмент в URL.
|
|
199
|
+
- **Wildcard** — `*`: совпадает с «хвостом» пути; в `params` не попадает (числовые ключи из `groups` отфильтрованы).
|
|
200
|
+
- **Regexp в параметре** — `:name(регулярка)` для ограничения формата сегмента (например только цифры). В `params` по-прежнему строка.
|
|
201
|
+
|
|
202
|
+
```typescript
|
|
203
|
+
useRoute('/users/:id');
|
|
204
|
+
useRoute('/elements/:elementId/*/:subElementId'); // wildcard
|
|
205
|
+
|
|
206
|
+
// Опциональные группы
|
|
207
|
+
useRoute('/users/:id{/posts/:postId}?');
|
|
208
|
+
|
|
209
|
+
// Ограничение формата параметра (regexp)
|
|
210
|
+
useRoute('/blog/:year(\\d+)/:month(\\d+)');
|
|
211
|
+
|
|
212
|
+
// Функция-матчер (иерархия, кастомный разбор)
|
|
213
|
+
const matchPost = (pathname: string) => ({ matched: pathname.startsWith('/posts/'), params: {} });
|
|
214
|
+
useRoute(matchPost);
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Полный синтаксис URLPattern: [URL Pattern API (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/URL_Pattern_API), [WHATWG URL Pattern](https://urlpattern.spec.whatwg.org/).
|
|
218
|
+
|
|
219
|
+
**PathMatcher** — функция, которую можно передать вместо строки, когда одного URLPattern недостаточно (иерархия сегментов, кастомная валидация, разбор через `split` или RegExp). Хук вызывает её с текущим `pathname` и подставляет возвращённые `matched` и `params` в состояние.
|
|
220
|
+
|
|
221
|
+
- **Параметр:** `pathname: string` — текущий pathname (без origin и query).
|
|
222
|
+
- **Возвращаемый тип:** `{ matched: boolean; params: RouteParams }`.
|
|
223
|
+
`matched` — совпал ли путь с вашей логикой; `params` — объект «имя параметра → значение сегмента» (тип `RouteParams` = `Record<RouteParamName, RouteParamValue>`).
|
|
224
|
+
- **Где использовать:** иерархические маршруты (например, `postId` только при наличии `userId`), пути с жёстким порядком сегментов, кастомные правила, которые не выразить одним URLPattern.
|
|
225
|
+
|
|
226
|
+
## 🛠 Примеры
|
|
227
|
+
|
|
228
|
+
### 1. Базовая навигация (pathname, navigate)
|
|
229
|
+
|
|
230
|
+
```tsx
|
|
231
|
+
import { useRoute } from '@budarin/use-route';
|
|
232
|
+
|
|
233
|
+
function BasicNavigationExample() {
|
|
234
|
+
const { pathname, navigate } = useRoute();
|
|
235
|
+
|
|
236
|
+
return (
|
|
237
|
+
<div>
|
|
238
|
+
<p>Текущий путь: {pathname}</p>
|
|
239
|
+
<button type="button" onClick={() => navigate('/posts')}>
|
|
240
|
+
К постам
|
|
241
|
+
</button>
|
|
242
|
+
<button type="button" onClick={() => navigate('/')}>
|
|
243
|
+
На главную
|
|
244
|
+
</button>
|
|
245
|
+
</div>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### 2. Параметры пути (useRoute('/users/:id'), params)
|
|
251
|
+
|
|
252
|
+
```tsx
|
|
253
|
+
import { useRoute } from '@budarin/use-route';
|
|
254
|
+
|
|
255
|
+
function ParamsExample() {
|
|
256
|
+
const { params, pathname, navigate } = useRoute('/users/:id');
|
|
257
|
+
|
|
258
|
+
return (
|
|
259
|
+
<div>
|
|
260
|
+
<p>Pathname: {pathname}</p>
|
|
261
|
+
<p>User ID из params: {params.id ?? '—'}</p>
|
|
262
|
+
<button type="button" onClick={() => navigate('/users/123')}>
|
|
263
|
+
User 123
|
|
264
|
+
</button>
|
|
265
|
+
<button type="button" onClick={() => navigate('/users/456')}>
|
|
266
|
+
User 456
|
|
267
|
+
</button>
|
|
268
|
+
</div>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### 3. Search params (query)
|
|
274
|
+
|
|
275
|
+
```tsx
|
|
276
|
+
import { useRoute } from '@budarin/use-route';
|
|
277
|
+
|
|
278
|
+
function SearchParamsExample() {
|
|
279
|
+
const { searchParams, navigate, pathname } = useRoute('/posts');
|
|
280
|
+
const pageParam = searchParams.get('page') ?? '1';
|
|
281
|
+
const currentPage = Number.parseInt(pageParam, 10) || 1;
|
|
282
|
+
|
|
283
|
+
return (
|
|
284
|
+
<div>
|
|
285
|
+
<p>Путь: {pathname}</p>
|
|
286
|
+
<p>Страница: {currentPage}</p>
|
|
287
|
+
<button
|
|
288
|
+
type="button"
|
|
289
|
+
onClick={() => navigate(`/posts?page=${currentPage - 1}`)}
|
|
290
|
+
disabled={currentPage <= 1}
|
|
291
|
+
>
|
|
292
|
+
Пред. страница
|
|
293
|
+
</button>
|
|
294
|
+
<button type="button" onClick={() => navigate(`/posts?page=${currentPage + 1}`)}>
|
|
295
|
+
След. страница
|
|
296
|
+
</button>
|
|
297
|
+
</div>
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
### 4. История (back, forward, go, canGoBack, canGoForward)
|
|
303
|
+
|
|
304
|
+
```tsx
|
|
305
|
+
import { useRoute } from '@budarin/use-route';
|
|
306
|
+
|
|
307
|
+
function HistoryExample() {
|
|
308
|
+
const { go, back, forward, canGoBack, canGoForward } = useRoute();
|
|
309
|
+
|
|
310
|
+
return (
|
|
311
|
+
<div>
|
|
312
|
+
<button type="button" onClick={() => back()} disabled={!canGoBack()}>
|
|
313
|
+
← Назад
|
|
314
|
+
</button>
|
|
315
|
+
<button type="button" onClick={() => go(-2)} disabled={!canGoBack(2)}>
|
|
316
|
+
← 2 шага
|
|
317
|
+
</button>
|
|
318
|
+
<button type="button" onClick={() => go(1)} disabled={!canGoForward()}>
|
|
319
|
+
Вперёд →
|
|
320
|
+
</button>
|
|
321
|
+
<button type="button" onClick={() => forward()} disabled={!canGoForward()}>
|
|
322
|
+
Forward
|
|
323
|
+
</button>
|
|
324
|
+
</div>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
### 5. Push и replace (и метод replace())
|
|
330
|
+
|
|
331
|
+
```tsx
|
|
332
|
+
import { useRoute } from '@budarin/use-route';
|
|
333
|
+
|
|
334
|
+
function PushReplaceExample() {
|
|
335
|
+
const { navigate, replace, pathname } = useRoute();
|
|
336
|
+
|
|
337
|
+
return (
|
|
338
|
+
<div>
|
|
339
|
+
<p>Текущий путь: {pathname}</p>
|
|
340
|
+
<button type="button" onClick={() => navigate('/step-push', { history: 'push' })}>
|
|
341
|
+
Перейти (push) — в истории появится запись
|
|
342
|
+
</button>
|
|
343
|
+
<button type="button" onClick={() => navigate('/step-replace', { history: 'replace' })}>
|
|
344
|
+
Перейти (replace через navigate)
|
|
345
|
+
</button>
|
|
346
|
+
<button type="button" onClick={() => replace('/step-replace-method')}>
|
|
347
|
+
Перейти через replace() — то же, что history: 'replace'
|
|
348
|
+
</button>
|
|
349
|
+
</div>
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
### 6. State (чтение, установка при навигации, обновление на месте)
|
|
355
|
+
|
|
356
|
+
State текущей записи истории доступен в хуке как **`state`**. Установить state при переходе — через опцию **`state`** в `navigate` или `replace`. Обновить state **текущей** страницы без перехода — **`updateState(state)`**. Используйте только для опциональных подсказок (скролл, откуда пришли, префилл формы); страница должна корректно работать и при заходе по прямой ссылке без state.
|
|
357
|
+
|
|
358
|
+
```tsx
|
|
359
|
+
import { useRoute } from '@budarin/use-route';
|
|
360
|
+
|
|
361
|
+
function StateExample() {
|
|
362
|
+
const { state, navigate, updateState, pathname } = useRoute();
|
|
363
|
+
|
|
364
|
+
return (
|
|
365
|
+
<div>
|
|
366
|
+
<p>Текущий путь: {pathname}</p>
|
|
367
|
+
<p>State записи: {state != null ? JSON.stringify(state) : '—'}</p>
|
|
368
|
+
<button
|
|
369
|
+
type="button"
|
|
370
|
+
onClick={() => navigate('/detail', { state: { from: 'list', scrollY: 100 } })}
|
|
371
|
+
>
|
|
372
|
+
Перейти с state
|
|
373
|
+
</button>
|
|
374
|
+
<button type="button" onClick={() => updateState({ draft: true, step: 2 })}>
|
|
375
|
+
Обновить state текущей записи (без навигации)
|
|
376
|
+
</button>
|
|
377
|
+
</div>
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
### 7. matched (совпадение pathname с pattern)
|
|
383
|
+
|
|
384
|
+
```tsx
|
|
385
|
+
import { useRoute } from '@budarin/use-route';
|
|
386
|
+
|
|
387
|
+
function MatchedExample() {
|
|
388
|
+
const { pathname, matched, params } = useRoute('/users/:id');
|
|
389
|
+
|
|
390
|
+
return (
|
|
391
|
+
<div>
|
|
392
|
+
<p>Pathname: {pathname}</p>
|
|
393
|
+
<p>Pattern /users/:id совпал: {matched === true ? 'да' : 'нет'}</p>
|
|
394
|
+
{matched === true ? (
|
|
395
|
+
<p>User ID: {params.id}</p>
|
|
396
|
+
) : (
|
|
397
|
+
<p>Это не страница пользователя (path не совпал с /users/:id).</p>
|
|
398
|
+
)}
|
|
399
|
+
</div>
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
### 8. Функция-матчер (PathMatcher)
|
|
405
|
+
|
|
406
|
+
Удобно, когда один URLPattern или простой regex не справляется: иерархия (например, `postId` только вместе с `userId`), кастомная валидация, разный порядок сегментов. Ниже — матчер для `/users/:userId` и `/users/:userId/posts/:postId`: два параметра, причём `postId` допустим только после литерала `posts` и только при наличии `userId`.
|
|
407
|
+
|
|
408
|
+
```tsx
|
|
409
|
+
import { useRoute, type PathMatcher } from '@budarin/use-route';
|
|
410
|
+
|
|
411
|
+
const matchUserPosts: PathMatcher = (pathname) => {
|
|
412
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
413
|
+
|
|
414
|
+
if (segments[0] !== 'users' || !segments[1]) return { matched: false, params: {} };
|
|
415
|
+
|
|
416
|
+
const params: Record<string, string> = { userId: segments[1] };
|
|
417
|
+
|
|
418
|
+
if (segments[2] === 'posts' && segments[3]) {
|
|
419
|
+
params.postId = segments[3];
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return { matched: true, params };
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
function UserPostsExample() {
|
|
426
|
+
const { pathname, matched, params } = useRoute(matchUserPosts);
|
|
427
|
+
|
|
428
|
+
if (!matched) return null;
|
|
429
|
+
|
|
430
|
+
return (
|
|
431
|
+
<div>
|
|
432
|
+
<p>Путь: {pathname}</p>
|
|
433
|
+
<p>User ID: {params.userId}</p>
|
|
434
|
+
{params.postId && <p>Post ID: {params.postId}</p>}
|
|
435
|
+
</div>
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
### 9. Глобальный base (приложение по подпути, не в корне домена)
|
|
441
|
+
|
|
442
|
+
Когда приложение располагается **не в корне домена**, а по подпути (например `https://example.com/app/` — все маршруты под `/app`), задайте в конфиге `base: '/app'`. Тогда `navigate(to)` добавляет base к относительным путям. Для одноразового перехода «вне» этого пути (например на `/login`) используйте опцию `base` в `navigate` или `replace`: `navigate('/login', { base: '' })`.
|
|
443
|
+
|
|
444
|
+
```tsx
|
|
445
|
+
import { useRoute, configureRouter } from '@budarin/use-route';
|
|
446
|
+
configureRouter({ base: '/app' });
|
|
447
|
+
|
|
448
|
+
function AppUnderBase() {
|
|
449
|
+
const { pathname, navigate } = useRoute();
|
|
450
|
+
|
|
451
|
+
return (
|
|
452
|
+
<div>
|
|
453
|
+
<p>Текущий путь: {pathname}</p>
|
|
454
|
+
|
|
455
|
+
<button type="button" onClick={() => navigate('/dashboard')}>
|
|
456
|
+
В дашборд → /app/dashboard
|
|
457
|
+
</button>
|
|
458
|
+
|
|
459
|
+
<button type="button" onClick={() => navigate('/login', { base: '' })}>
|
|
460
|
+
На логин (/login)
|
|
461
|
+
</button>
|
|
462
|
+
|
|
463
|
+
<button type="button" onClick={() => navigate('/auth/profile', { base: '/auth' })}>
|
|
464
|
+
В другой раздел (/auth/profile)
|
|
465
|
+
</button>
|
|
466
|
+
</div>
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
### 10. Section в хуке (options.section)
|
|
472
|
+
|
|
473
|
+
Когда у приложения несколько разделов по своим подпутям (`/dashboard`, `/admin`, `/auth`), в компонентах раздела задайте **section**: вызовите `useRoute({ section: '/dashboard' })`. Тогда `navigate(to)` по умолчанию добавляет полный префикс (base + section). <br />
|
|
474
|
+
Переход в корень приложения (без секции): `navigate('/', { section: '' })`. <br />
|
|
475
|
+
Переход «вне» приложения: `navigate('/login', { base: '' })`.
|
|
476
|
+
|
|
477
|
+
```tsx
|
|
478
|
+
import { useRoute } from '@budarin/use-route';
|
|
479
|
+
|
|
480
|
+
const DASHBOARD_BASE = '/dashboard';
|
|
481
|
+
|
|
482
|
+
function DashboardSection() {
|
|
483
|
+
// Section для раздела: pathname и navigate относительно /dashboard (под глобальным base, если задан)
|
|
484
|
+
const { pathname, navigate } = useRoute({ section: DASHBOARD_BASE });
|
|
485
|
+
|
|
486
|
+
return (
|
|
487
|
+
<div>
|
|
488
|
+
{/* При URL /dashboard/reports pathname === '/reports' */}
|
|
489
|
+
<p>Раздел Dashboard. Путь: {pathname}</p>
|
|
490
|
+
|
|
491
|
+
<button type="button" onClick={() => navigate('/reports')}>
|
|
492
|
+
Отчёты → /dashboard/reports
|
|
493
|
+
</button>
|
|
494
|
+
|
|
495
|
+
<button type="button" onClick={() => navigate('/settings')}>
|
|
496
|
+
Настройки → /dashboard/settings
|
|
497
|
+
</button>
|
|
498
|
+
|
|
499
|
+
{/* Переход в корень приложения (без секции) или на главную */}
|
|
500
|
+
<button type="button" onClick={() => navigate('/', { section: '' })}>
|
|
501
|
+
На главную
|
|
502
|
+
</button>
|
|
503
|
+
</div>
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
### 11. initialLocation (SSR)
|
|
509
|
+
|
|
510
|
+
При рендере на сервере нет `window`, поэтому хук не знает URL запроса. Задайте `initialLocation` в конфиге один раз перед рендером запроса (например `request.url`) — тогда `pathname` и `searchParams` будут соответствовать запросу. На клиенте `initialLocation` не используется.
|
|
511
|
+
|
|
512
|
+
```tsx
|
|
513
|
+
// Серверный обработчик (псевдокод: Express, Fastify, Next и т.д.)
|
|
514
|
+
import { configureRouter } from '@budarin/use-route';
|
|
515
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
516
|
+
import { App } from './App';
|
|
517
|
+
|
|
518
|
+
function handleRequest(req, res) {
|
|
519
|
+
// Один раз перед рендером этого запроса
|
|
520
|
+
configureRouter({ initialLocation: req.url });
|
|
521
|
+
|
|
522
|
+
const html = renderToStaticMarkup(<App />);
|
|
523
|
+
res.send(html);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// В App компоненты используют useRoute() — на сервере получают pathname/searchParams из initialLocation
|
|
527
|
+
function App() {
|
|
528
|
+
const { pathname, searchParams } = useRoute();
|
|
529
|
+
return (
|
|
530
|
+
<div>
|
|
531
|
+
<p>Pathname: {pathname}</p>
|
|
532
|
+
<p>Query: {searchParams.toString()}</p>
|
|
533
|
+
</div>
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
### 12. Компонент Link (пример реализации)
|
|
539
|
+
|
|
540
|
+
Минимальный пример компонента-ссылки поверх хука. Можно взять за основу и развивать под себя: активное состояние, префетч, аналитика, стили.
|
|
541
|
+
|
|
542
|
+
```tsx
|
|
543
|
+
import { useRoute } from '@budarin/use-route';
|
|
544
|
+
import { useCallback, type ComponentPropsWithoutRef } from 'react';
|
|
545
|
+
|
|
546
|
+
interface LinkProps extends ComponentPropsWithoutRef<'a'> {
|
|
547
|
+
to: string;
|
|
548
|
+
replace?: boolean;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function Link({ to, replace = false, onClick, ...props }: LinkProps) {
|
|
552
|
+
const { navigate } = useRoute();
|
|
553
|
+
|
|
554
|
+
const handleClick = useCallback(
|
|
555
|
+
(e: React.MouseEvent<HTMLAnchorElement>) => {
|
|
556
|
+
onClick?.(e);
|
|
557
|
+
|
|
558
|
+
if (!e.defaultPrevented) {
|
|
559
|
+
e.preventDefault();
|
|
560
|
+
navigate(to, { history: replace ? 'replace' : 'push' });
|
|
561
|
+
}
|
|
562
|
+
},
|
|
563
|
+
[navigate, to, replace, onClick]
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
return <a {...props} href={to} onClick={handleClick} />;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Использование:
|
|
570
|
+
// <Link to="/posts">Посты</Link>
|
|
571
|
+
// <Link to="/users/123" replace>Профиль (replace)</Link>
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
## ⚙️ Установка
|
|
575
|
+
|
|
576
|
+
```bash
|
|
577
|
+
npm i @budarin/use-route
|
|
578
|
+
|
|
579
|
+
pnpm add @budarin/use-route
|
|
580
|
+
|
|
581
|
+
yarn add @budarin/use-route
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
TypeScript: типы включены.
|
|
585
|
+
|
|
586
|
+
**`tsconfig.json` (рекомендуется):**
|
|
587
|
+
|
|
588
|
+
```json
|
|
589
|
+
{
|
|
590
|
+
"compilerOptions": {
|
|
591
|
+
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
|
592
|
+
"moduleResolution": "bundler",
|
|
593
|
+
"jsx": "react-jsx"
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
**Polyfills (опционально):**
|
|
599
|
+
|
|
600
|
+
```bash
|
|
601
|
+
npm i urlpattern-polyfill
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
```typescript
|
|
605
|
+
// src/polyfills.ts
|
|
606
|
+
import 'urlpattern-polyfill';
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
## 🌐 Браузеры и Node.js
|
|
610
|
+
|
|
611
|
+
Пакет **работает только** там, где есть **Navigation API** и **URLPattern**. Ограничивающие требования — версии ниже; без них хук не запустится.
|
|
612
|
+
|
|
613
|
+
| API | Chrome/Edge | Firefox | Safari | Node.js |
|
|
614
|
+
| -------------- | ----------- | ------- | ------ | ------- |
|
|
615
|
+
| Navigation API | 102+ | 109+ | 16.4+ | — |
|
|
616
|
+
| URLPattern | 110+ | 115+ | 16.4+ | 23.8+ |
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
## 🎛 Под капотом
|
|
620
|
+
|
|
621
|
+
- **Navigation API:** подписка на события `navigate`, `currententrychange`; для same-origin навигации — перехват `navigate` и вызов `event.intercept()`
|
|
622
|
+
- `useSyncExternalStore` на navigation события
|
|
623
|
+
- LRU кэш parsed URL (настраиваемый лимит)
|
|
624
|
+
- Map для O(1) поиска `historyIndex`
|
|
625
|
+
- URLPattern для `:params`
|
|
626
|
+
- Кэш compiled patterns; `clearRouterCaches()` — очистка кэшей (тесты, смена окружения)
|
|
627
|
+
- SSR-safe (checks `typeof window`)
|
|
628
|
+
|
|
629
|
+
## 🤝 Лицензия
|
|
630
|
+
|
|
631
|
+
MIT © budarin
|