Skip to content

Принцип работы

Reactive Route обладает мощной типизацией, продуманной логикой редиректов и отказоустойчивостью, которые крайне сложно "сломать". Для большинства сценариев достаточно определить конфигурации и использовать router.redirect, остальное подскажет TS.

При взаимодействии с роутером есть всего две основные структуры:

Config

Содержит всю логику работы со страницей

ts
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
ts
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

tsx
// 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' }
}
tsx
// 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' }
}
tsx
// 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' }
}
vue
// 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
ts
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
ts
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
ts
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):

ts
await router.init(`/user/with%20space?phone=and%26symbols`);

Роутер не понимает строковый формат, так как его нет в описанных выше структурах.

Для конвертации существует метод router.urlToState, который очищает URL от лишнего, декодирует значения, запускает валидаторы и возвращает понятный роутеру State, на который вызывается редирект:

ts
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

No AI participated in the development. MIT License