Принцип работы
Reactive Route обладает мощной типизацией, продуманной логикой редиректов и отказоустойчивостью, которые крайне сложно "сломать". Для большинства сценариев достаточно определить конфигурации и использовать router.redirect, остальное подскажет TS.
При взаимодействии с роутером есть всего две основные структуры:
Config
Содержит всю логику работы со страницей
user: {
path: '/user/:id',
params: {
id: (value) => /^\d+$/.test(value)
},
query: {
phone: (value) => value.length < 15
},
loader: () => import('./pages/user'),
props: { foo: 'bar' },
async beforeEnter({ nextState, redirect }) {
await api.loadUser();
if (!store.isLoggedIn) {
return redirect({
name: 'login',
query: { returnTo: nextState.name }
});
}
},
async beforeLeave({ nextState, preventRedirect }) {
if (nextState.name === 'home') {
return preventRedirect();
}
}
}Тесты типизации Config
import { createConfigs } from '../../packages/core';
const loader = async () => ({ default: null });
const v = () => true;
createConfigs({
dynamic: { path: '/:id/:tab', params: { id: v, tab: v }, loader },
static: { path: '/', loader },
notFound: { path: '/404', props: {}, loader },
internalError: { path: '/500', props: {}, loader },
// @ts-expect-error "params" must be "never"
static1: { path: '/', loader, params: {} },
// @ts-expect-error "params" are required
dynamic1: { path: '/:id/:tab', loader },
// @ts-expect-error "params.tab" and "params.id" are required
dynamic2: { path: '/:id/:tab', params: {}, loader },
// @ts-expect-error "params.tab" is required
dynamic3: { path: '/:id/:tab', params: { id: v }, loader },
// @ts-expect-error "params" validators must be functions
dynamic4: { path: '/:id/:tab', params: { id: v, tab: '' }, loader },
// @ts-expect-error duplicates are not allowed
double1: { path: '/:id/:id', params: { id: v }, loader },
// @ts-expect-error duplicates are not allowed
double2: { path: '/:id/:id', loader },
// @ts-expect-error duplicates are not allowed
double3: { path: '/:id/:id/', params: { id: v }, loader },
// @ts-expect-error duplicates are not allowed
double4: { path: '/:id/:id/', loader },
// @ts-expect-error duplicates are not allowed
double5: { path: '/:id/:tab/:id', params: { id: v, tab: v }, loader },
// @ts-expect-error duplicates are not allowed
double6: { path: '/:id/:tab/:id', loader },
});
// @ts-expect-error internalError is required
createConfigs({ notFound: { path: '/404', loader } });
// @ts-expect-error notFound is required
createConfigs({ internalError: { path: '/500', loader } });
createConfigs({
// @ts-expect-error "query" is not allowed
notFound: { path: '/404', loader, query: {} },
// @ts-expect-error "params" are not allowed
internalError: { path: '/500', loader, params: { code: v } },
});
createConfigs({
// @ts-expect-error "beforeLeave" is not allowed
notFound: { path: '/404', loader, beforeLeave: () => undefined },
// @ts-expect-error "beforeEnter" is not allowed
internalError: { path: '/500', loader, beforeEnter: () => undefined },
});State
Содержит текущие значения определенного Config
// pages/user/index.tsx
import { useRouter } from 'router';
export default function PageUser() {
const { router } = useRouter();
const pageState = router.state.user!;
console.log(pageState);
return (
<>
ID: {pageState.params.id}
Phone: {pageState.query.phone}
</>
)
}
// console output (Observable object)
{
name: 'user',
params: { id: '9999' },
query: { phone: '123456' }
}// pages/user/index.tsx
import { useRouter } from 'router';
export default function PageUser() {
const { router } = useRouter();
const pageState = router.state.user!;
console.log(pageState);
return (
<>
ID: {pageState.params.id}
Phone: {pageState.query.phone}
</>
)
}
// console output (Observable object)
{
name: 'user',
params: { id: '9999' },
query: { phone: '123456' }
}// pages/user/index.tsx
import { useRouter } from 'router';
export default function PageUser() {
const { router } = useRouter();
console.log(router.state.user);
return (
<>
ID: {router.state.user!.params.id}
Phone: {router.state.user!.query.phone}
</>
)
}
// console output (Observable object)
{
name: 'user',
params: { id: '9999' },
query: { phone: '123456' }
}// pages/user/User.vue
<script lang="ts" setup>
import { useRouter } from 'router';
const { router } = useRouter();
const pageState = router.state.user!;
console.log(pageState);
</script>
<template>
ID: {pageState.params.id}
Phone: {pageState.query.phone}
</template>
<script lang="ts">
// console output (Observable object)
{
name: 'user',
params: { id: '9999' },
query: { phone: '123456' }
}
</script>Дополнительно
Оператор "non-null assertion" безопасен, если только один Config использует этот компонент страницы.
Тесты типизации State
import { createConfigs, createRouter } from '../../packages/core';
const loader = async () => ({ default: null });
const v: (value: string) => boolean = () => true;
const adapters = {} as any;
const configs = createConfigs({
static: { path: '/', loader },
staticQuery: { path: '/', query: { q: v }, loader },
dynamic: { path: '/:id', params: { id: v }, loader },
dynamicQuery: {
path: '/:id',
params: { id: v },
query: { q: v },
loader,
},
notFound: { path: '/404', loader },
internalError: { path: '/500', loader },
});
const router = createRouter({ configs, adapters });
// @ts-expect-error not existing name
router.state.unknown;
router.state.static!.name;
router.state.static!.params;
router.state.static!.query;
// @ts-expect-error "params" values are not available
router.state.static!.params.unknown;
// @ts-expect-error "query" values are not available
router.state.static!.query.unknown;
router.state.notFound!.name;
router.state.notFound!.params;
// @ts-expect-error "params" values are not available
router.state.notFound!.params.unknown;
router.state.notFound!.query;
// @ts-expect-error "query" values are not available
router.state.notFound!.query.unknown;
router.state.internalError!.name;
router.state.internalError!.params;
// @ts-expect-error "params" values are not available
router.state.internalError!.params.unknown;
router.state.internalError!.query;
// @ts-expect-error "query" values are not available
router.state.internalError!.query.unknown;
router.state.staticQuery!.name;
router.state.staticQuery!.params;
// @ts-expect-error "params" values are not available
router.state.staticQuery!.params.unknown;
router.state.staticQuery!.query.q;
// @ts-expect-error unknown "query" are not available
router.state.staticQuery!.query.unknown;
router.state.dynamic!.name;
router.state.dynamic!.params.id;
// @ts-expect-error unknown "params" are not available
router.state.dynamic!.params.unknown;
router.state.dynamic!.query;
// @ts-expect-error "query" values are not available
router.state.dynamic!.query.unknown;
router.state.dynamicQuery!.name;
router.state.dynamicQuery!.params.id;
router.state.dynamicQuery!.query.q;
// @ts-expect-error unknown "params" are not available
router.state.dynamicQuery!.params.unknown;
// @ts-expect-error unknown "query" are not available
router.state.dynamicQuery!.query.unknown;Type Narrowing для State
import { createConfigs, createRouter } from '../../packages/core';
const loader = async () => ({ default: null });
const v: (value: string) => boolean = () => true;
const adapters = {} as any;
const configs = createConfigs({
static: { path: '/', loader },
staticQuery: { path: '/', query: { q: v }, loader },
dynamic: { path: '/:id', params: { id: v }, loader },
dynamicQuery: {
path: '/:id',
params: { id: v },
query: { q: v },
loader,
},
notFound: { path: '/404', loader },
internalError: { path: '/500', loader },
});
const router = createRouter({ configs, adapters });
const state = router.urlToState('');
// @ts-expect-error not existing name
state.name === 'unknown';
if (state.name === 'notFound') {
state.name;
state.params;
// @ts-expect-error "params" values are not available
state.params.unknown;
state.query;
// @ts-expect-error "query" values are not available
state.query.unknown;
}
if (state.name === 'internalError') {
state.name;
state.params;
// @ts-expect-error "params" values are not available
state.params.unknown;
state.query;
// @ts-expect-error "query" values are not available
state.query.unknown;
}
if (state.name === 'static') {
state.name;
state.params;
state.query;
// @ts-expect-error "params" values are not available
state.params.unknown;
// @ts-expect-error "query" values are not available
state.query.unknown;
}
if (state.name === 'staticQuery') {
state.name;
state.params;
// @ts-expect-error "params" values are not available
state.params.unknown;
state.query.q;
// @ts-expect-error unknown "query" are not available
state.query.unknown;
}
if (state.name === 'dynamic') {
state.name;
state.params.id;
// @ts-expect-error unknown "params" are not available
state.params.unknown;
state.query;
// @ts-expect-error "query" values are not available
state.query.unknown;
}
if (state.name === 'dynamicQuery') {
state.name;
state.params.id;
state.query.q;
// @ts-expect-error unknown "params" are not available
state.params.unknown;
// @ts-expect-error unknown "query" are not available
state.query.unknown;
}Это нормализованная структура, в которой всегда будут объекты params и query. Однако если они не описаны в соответствующей конфигурации, то их тип будет Record<never, string>. Это сделано для защиты runtime, так как разработчик может где-то поставить ts-ignore и без дефолтных значений код упадет с ошибкой Cannot read property of undefined.
Однако для механизма редиректа удобнее работать с максимально строгими типами, и для этой цели создан StateDynamic, который полностью запрещает передачу params и query, если их не было в конфигурации страницы.
Тесты типизации StateDynamic
import { createConfigs, createRouter } from '../../packages/core';
const loader = async () => ({ default: null });
const v: (value: string) => boolean = () => true;
const adapters = {} as any;
const configs = createConfigs({
static: { path: '/', loader },
staticQuery: { path: '/', query: { q: v }, loader },
dynamic: { path: '/:id', params: { id: v }, loader },
dynamicQuery: {
path: '/:id',
params: { id: v },
query: { q: v },
loader,
},
notFound: { path: '/404', loader },
internalError: { path: '/500', loader },
});
const router = createRouter({ configs, adapters });
await router.redirect({ name: 'static' });
await router.redirect({ name: 'staticQuery' });
await router.redirect({ name: 'staticQuery', query: { q: '' } });
await router.redirect({ name: 'dynamic', params: { id: '' } });
await router.redirect({ name: 'dynamicQuery', params: { id: '' } });
await router.redirect({
name: 'dynamicQuery',
params: { id: '' },
query: { q: '' },
});
/** Not string values raise errors */
await router.redirect({
// @ts-expect-error name must be a string
name: 1,
});
await router.redirect({
name: 'staticQuery',
// @ts-expect-error "query" values must be strings
query: { q: 1 },
});
await router.redirect({
name: 'dynamic',
// @ts-expect-error "params" values must be strings
params: { id: 1 },
});
await router.redirect({
name: 'dynamicQuery',
params: { id: '' },
// @ts-expect-error "query" values must be strings
query: { q: 1 },
});
// @ts-expect-error "params" are required
await router.redirect({ name: 'dynamic' });
await router.redirect({
name: 'dynamic',
// @ts-expect-error "params" are incomplete
params: {},
});
/** Irrelevant StateDynamic raise errors */
// @ts-expect-error name must be present
await router.redirect({});
// @ts-expect-error "name" must be a string key of configs
await router.redirect({ name: 'unknown' });
await router.redirect({
name: 'static',
// @ts-expect-error "params" are not allowed
params: { id: '' },
});
await router.redirect({
name: 'staticQuery',
// @ts-expect-error "params" are not allowed
params: { id: '' },
});
await router.redirect({
name: 'dynamic',
// @ts-expect-error extra "params" are not allowed
params: { id: '', extra: '' },
});
await router.redirect({
name: 'static',
// @ts-expect-error "query" is not allowed
query: { q: '' },
});
await router.redirect({
name: 'dynamic',
params: { id: '' },
// @ts-expect-error "query" is not allowed
query: { q: '' },
});
await router.redirect({
name: 'staticQuery',
// @ts-expect-error unknown "query" key
query: { unknown: '' },
});
await router.redirect({
name: 'dynamicQuery',
params: { id: '' },
// @ts-expect-error unknown "query" key
query: { unknown: '' },
});
const state = router.urlToState('');
void router.redirect(state);Декодирование
Браузер работает с URL в закодированном формате, поэтому Reactive Route имеет встроенные механизмы кодирования и декодирования. Рассмотрим процесс инициализации первого редиректа (если заменили все валидаторы на () => true):
await router.init(`/user/with%20space?phone=and%26symbols`);Роутер не понимает строковый формат, так как его нет в описанных выше структурах.
Для конвертации существует метод router.urlToState, который очищает URL от лишнего, декодирует значения, запускает валидаторы и возвращает понятный роутеру State, на который вызывается редирект:
await router.redirect({
name: 'user',
params: { id: 'with space' },
query: { phone: 'and&symbols' }
})Важно
В валидаторы приходят декодированные значения для удобства работы с не-английскими URL:
id: (value) => console.log(value) покажет не with%20space а with space
phone: (value) => console.log(value) покажет не and%26symbols а and&symbols
Redirect flow
- выполняется исследование причины редиректа. В данном случае
reason = 'new_config' - выполняется
beforeLeaveпредыдущегоConfig(в данном случае его не было, поэтому пропускается) - выполняется
beforeEnterследующегоConfigс прохождением цепочки редиректов - загружается js-чанк (если включен code-splitting) с компонентом и другими экспортами
- нормализованный
Stateзаписывается в соответствующийrouter.state[config.name], в данном случаеrouter.state.user - если включена синхронизация с History API (по умолчанию — включена для браузерного окружения), то вызываются нативные
pushState / replaceState