Как стать автором
Поиск
Написать публикацию
Обновить
Точка
Как мы делаем онлайн-сервисы для бизнеса

Навигация без хаоса: архитектура маршрутов в масштабируемом TypeScript-проекте

Уровень сложностиСредний
Время на прочтение15 мин
Количество просмотров2.7K

Привет! Я Илья, фронтенд-разработчик в финтех-компании Точка. Нам важно, чтобы поддержка пользователей была на высоком уровне, поэтому у нас есть десятки сервисов для организации обучения специалистов поддержки. Я работаю над одним из таких проектов. Он активно развивается: ежемесячно добавляем более 10 новых страниц — сейчас в проекте их больше 120.

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

Примеры в статье написаны на TypeScript, React и React Router, но подход универсален и может применяться в других технологиях:

  • React, Next.js, Vue, Svelte (Frontend).

  • React Native (Mobile).

  • Electron (Desktop).

  • Node.js, NestJS, Express (Backend).

Частая проблема организации путей роутинга

export const MAIN_PAGE_PATH = '/';
export const AUTH_PAGE_PATH = '/login';
export const NOT_FOUND_PAGE_PATH = '/404';
export const SEARCH_PAGE_PATH = '/search';
export const TASKS_PAGE_PATH = '/tasks';
export const ACTUAL_TASKS_PAGE_PATH = '/tasks/actual';
export const FINISHED_TASKS_PAGE_PATH = '/tasks/finished';
export const getTaskPagePath = (id: number, type: string): string => `/tasks/${id}?type=${type}`;
// и это хорошо ещё, если конструктор параметров вынесен глобально
// ...и здесь ещё сто штук

Обычно маршруты страниц в проектах выглядят именно так.

Эти многочисленные константы при вёрстке используются в таком формате:

--------- home-page.tsx:
import { useNavigate } from 'react-router-dom';
// используем утилиты для навигации
const navigate = useNavigate();
// осуществляем переход на другую страницу, прокидываем параметры
navigate(getTaskPagePath(task.id, task.type))

--------- task-page.tsx:
import { useParams } from 'react-router';
import { useQueryParams } from 'shared/helpers';
// извлекаем параметры страницы
const { id } = useParams<{ id: number }>();
const { type } = useQueryParams<{ type: string }>();
// как-то используем id и type в верстке
...

Поддерживать и развивать такой код непросто. Среди очевидных проблем:

  1. Поиск нужного пути затруднён однотипностью констант. При большом количестве похожих переменных легко запутаться и потратить лишнее время на поиск нужной.

  2. Дублирование в нейминге. В проекте могут использоваться константы с суффиксами PAGE, PATH, ROUTE и т.п. Это усложняет чтение и поддержку кода.

  3. Отсутствие связи между параметрами и страницами. Path- и query-параметры определены и в константах путей, и в самих компонентах страниц. В результате дублирования типов увеличивается вероятность несоответствий и ошибок.

Когда маршрутов становится больше, эти сложности накапливаются и негативно влияют на масштабируемость.

Базовое решение проблемы

В своем проекте мы решили создать единый объект, в котором ключи имеют полное соответствие строковому пути, кроме двух служебных:

  • index для корневых путей;

  • item для динамических путей элементов, обычно по идентификаторам.

Теперь все маршруты хранятся в таком виде:

export const Routes = {
  index: '/',
  login: '/login',
  notFound: '/404',
  search: '/search',
  tasks: {
    index: '/tasks',
    actual: '/tasks/actual',
    finished: '/tasks/finished',
    item: (id: number, type: string) => `/tasks/${id}?type=${type}`
  },
}

А навигация выглядит так:

const navigate = useNavigate();
...
navigate(Routes.tasks.item(123))

Это довольно простое и быстрое решение, и теперь у нас:

  • единая точка выбора пути, а автокомплит IDE быстро поможет найти нужный;

  • все пути чётко структурированы, легко ориентироваться и добавлять новые;

  • нет дублирования лишних слов в нейминге констант.

Не обязательно следовать именно нашей структуре: соблюдать точный нейминг ключей, юзать служебные index/item или единый объект — это лишь договорённость наших разработчиков. Главное — выбрать удобную и понятную схему для вашей команды.

Параметризуем маршруты

Но это было только начало. После этого мы решили раскрыть потенциал TypeScript.

Идея в том, чтобы связать параметры страниц и объект путей Routes.

Есть два типа параметров:

  • Path: /example/:id

  • Query: /example?id=123

Давайте сперва подготовим прототип получения параметров на страницах. Было бы классно просто передать в специальную утилиту наш путь или его тип из Routes, а в ответ получить параметры. Тогда все типы мы смогли бы описать единоразово в Routes. На примере React-хука:

const { id, type } = usePageParams<typeof Routes.tasks.item>();

Разберем пошагово, как этого добиться:

1. Сперва параметризуем пути в Routes.

Для проброса параметров будем использовать функции такого вида:

export const Routes = {
  ...
  tasks: {
    item: (id: number, type: string) => `/tasks/${id}?type=${type}`
  }
}

А чтобы упростить генерацию таких функций, создадим вспомогательную утилиту, которая:

  • будет принимать путь в качестве аргумента (если path-параметров нет — строку, а если есть — функцию).

  • будет возвращать функцию генерации пути с внедрением path и query-параметров:

Также для удобства разделим path и query на разные объекты и введём простенький хелпер createUrl, который будет склеивать ориджин, путь, квери и хэш в итоговый URL.

import { stringify } from 'qs';

export type TParametrizedRoute<TPath = any, TQuery = any> = (params: {
  path?: TPath;
  query?: TQuery;
}) => string;

// Утилита генератор функций:
// - принимаем путь в виде строки или функции
// - возвращаем функцию, которая конструирует маршрут страницы с параметрами
function parametrizedRoute<TPath = undefined, TQuery = undefined>(
  pathOrGetPath: TPath extends undefined ? string : (path: TPath) => string
): TParametrizedRoute<TPath, TQuery> {
  return ({ path, query }: { path?: TPath; query?: TQuery }) => {
    return createUrl({
      path: typeof pathOrGetPath === 'function'
              ? pathOrGetPath(path!)
              : pathOrGetPath || '',
      query
    });
  };
}

// В статье приводится примитивная универсальная утилита для склейки частей URLа.
// Пример готовой библиотеки: https://github.com/meabed/build-url-ts
export const createUrl = (args: {
  origin?: string;
  path?: string;
  query?: Record<string, unknown>;
  hash?: string;
}): string => {
  return [
    args.origin || '',
    args.path || '',
    args.query ? `?${stringify(args.query)}` : '',
    args.hash ? `#${args.hash}` : ''
  ].join('');
};

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

export const Routes = {
  tasks: {
    // у страницы есть только query - передаём строку пути:
    index: parametrizedRoute<undefined, { userId?: number; type?: string }>(
      '/tasks'
    )

    // у страницы есть только path:
    item: parametrizedRoute<{ id: number }>((params) => {
      return `/tasks/${params.id}`;
    }),

    // у страницы есть и path, и query:
    item: parametrizedRoute<{ id: number }, { type?: string }>((params) => {
      return `/tasks/${params.id}`;
    }),

    // у страницы нет ни query, ни path:
    list: '/tasks/list'
  }
};

2. Теперь напишем типы для извлечения параметров:

// Тип параметризованного пути уже ввели ранее: 
export type TParametrizedRoute<TPath = any, TQuery = any> = (params: {
  path?: TPath;
  query?: TQuery;
}) => string;

// Поскольку все конечные значения параметров в URL'е превратятся в строки, то лучше привести к ним, для этого используем специальный тип-конвертер:
export type TObjectFieldsToStrings<T> = {
  [K in keyof T]: T[K] extends string | undefined ? T[K] : string;
};


// Утилита извлечет и преобразует нужное поле из аргумента параметризованного пути
type TExtractRouteParams<
  T extends TParametrizedRoute,
  K extends keyof Parameters<T>[0]
> = TObjectFieldsToStrings<
  NonNullable<Parameters<T>[0][K]>
>;

// Утилита извлекает path из параметризованного пути
export type TExtractPageParams<T extends TParametrizedRoute>
  = TExtractRouteParams<T, 'path'>;

// Утилита извлекает query из параметризованного пути
export type TExtractPageQuery<T extends TParametrizedRoute>
  = TExtractRouteParams<T, 'query'>;

3. И утилиты для извлечения параметров:

import { stringify } from 'qs';
import { useCallback, useMemo } from 'react';
import { useParams } from 'react-router';
import { URLSearchParamsInit, useSearchParams } from 'react-router-dom';

/**
 * Утилита для извлечения параметров страницы (из path + из query)
 */
export function usePageParams<T extends TParametrizedRoute>(): {
  path: TExtractPageParams<T>;
  query: Partial<TExtractPageQuery<T>>;
  setParams: ReturnType<typeof useQueryParams<TExtractPageQuery<T>>>[1];
} {
  const path = useParams() as TExtractPageParams<T>;
  const [query, setParams] = useQueryParams<TExtractPageQuery<T>>();

  return { path, query, setParams };
}


/**
 * Утилита для работы с query-параметрами, если её нет во фреймворке
 */
export function useQueryParams<T = Record<string, unknown>>(
  defaultValues?: URLSearchParamsInit
): [Partial<T>, (values: Partial<T>, replace?: boolean) => void] {
  const [value, setValue] = useSearchParams(defaultValues);

  const parsedQueryParams = useMemo(
    () => Object.fromEntries(value.entries()) as Partial<T>,
    [value]
  );

  const setQueryParams = useCallback(
    (values: Partial<T>, replace: boolean = true) => {
      setValue(stringify({ ...parsedQueryParams, ...values }), { replace });
    },
    [parsedQueryParams]
  );

  return [parsedQueryParams, setQueryParams];
}

4. Итоговое получение параметров на странице будет выглядеть так:

export const TaskPage = (): ReactElement => {
  const params = usePageParams<typeof Routes.tasks.item>();
  const { path: { id }, query: { type } } = params;

  return ( ... );
}

Готово! А автокомплит в IDE успешно подсказывает существующие параметры при их извлечении.

Улучшаем схему: автоматическое извлечение параметров из строк

А что, если избежать ручного указания path-параметров? Ведь обычно они уже содержатся в пути страницы. И этот же строковый путь передаётся в Router:

{
  path: '/tasks/:id',
  element: <TaskPage />
}

Для начала напишем упрощённую версию. Мы хотим передать строку пути и автоматически извлечь из неё параметры. Воспользуемся мощными возможностями языка TypeScript: используем infer и Template Literal Types.

/**
 * Тип, который разбирает строку и извлекает параметры
 */
type TExtractParams<T extends string> =
  // Первый случай: строка содержит `:param/что-то`
  T extends `${string}:${infer Param}/${infer Rest}`
    // записываем тип "строка" по имени параметра
    // и рекурсивно обрабатываем оставшуюся часть строки
    ? { [K in Param]: string } & TExtractParams<`/${Rest}`>
    : // Второй случай: строка содержит `:param` без слэшей
      T extends `${string}:${infer Param}`
      ? { [K in Param]: string }
      : unknown;

const example: TExtractParams2<'/tasks/:first/:second'> = {
  first: '123', // Автокомплит IDE работает
  second: '456',
};

Теперь нам нужно доработать уже известный parametrizedRoute, чтобы он принимал путь в качестве параметра и возвращал всё так же функцию для генерации пути. Для этого воспользуемся магией литералов в TypeScript.

А если формально, то используем вывод типа из строкового литерала с помощью конструкции <T extends string>(route: T).

const Routes = {
  tasks: {
    item: parametrizedRoute('/tasks/:id')
  }
};

// Мы не задаём тип вручную через generic, а наоборот:
// он сам извлекается из аргумента, и мы можем использовать его далее
function parametrizedRoute<T extends string>(route: T): TParametrizedRoute<T> {
  const preparePath = (params: TExtractParams<T>): string => {
    // TODO: замена параметров в пути на реальные значения (сделаем далее)
    return '';
  };
  return ({ path }: { path: TExtractParams<T> }): string => {
    return createUrl({ path: preparePath(path) });
  };
}

// Проверяем:
navigate(
  Routes.tasks.item({
    path: {
      id: '123' // Автокомплит работает
    }
  })
);

Но здесь есть важный нюанс: нам ведь нужны ещё и query-параметры.

А если мы добавим их вторым аргументом, то магия литералов выключится и будем вынуждены пробрасывать путь в качестве дженерика, вот так:

function parametrizedRoute<P extends string, Q extends Record<string, unknown>>(
  path: P,
  query: Q
): TParametrizedRoute<P, Q> {
  ...
}

const Routes = {
  tasks: {                   ↓↓↓ дублирование                    
    item: parametrizedRoute<'/tasks/:id', { type: string }>('/tasks/:id')
  }
};

Но такое дублирование кода нам совсем ни к чему.

Тогда нужно оставить у parametrizedRoute единственный generic-тип. А query привяжем с помощью цепочки вызовов там, где это нужно:

const Routes = {
  tasks: {
    // пример без query:
    itemNoQuery: parametrizedRoute('/tasks/:id'),
    // пример с query:
    itemWithQuery: parametrizedRoute('/tasks/:id').withQuery<{
      type: number;
    }>()
  }
};

Доработаем утилиты и посмотрим на весь код:

/**
 * Тип, который разбирает строку и извлекает параметры
 */
export type TExtractParams<T extends string>
  = T extends `${string}:${infer Param}/${infer Rest}`
  ? { [K in Param]: string } & TExtractParams<`/${Rest}`>
  : T extends `${string}:${infer Param}`
    ? { [K in Param]: string }
    : unknown;

/**
 * Итоговый тип параметризированного пути:
 * - функция с аргументом path-параметров.
 * - поле-функция withQuery, которая возвращает функцию с аргументами path и query-параметров.
 * - поле route в каждой функции с исходным роутом (например, для React Router'а)
 */
type TParametrizedRoute<T extends string> = {
  ({ path }: { path: TExtractParams<T> }): string;
  route: string;

  withQuery<TQuery extends Record<string, unknown>>(): {
    ({ path, query }: { path: TExtractParams<T>; query: TQuery }): string;
    route: string;
  };
};

/**
 * Утилита для создания параметризованного пути.
 * Значения path и query-параметров автоматически подставляются
 * в путь страницы при конечном вызове
 */
function parametrizedRoute<T extends string>(route: T): TParametrizedRoute<T> {
  const preparePath = (params: TExtractParams<T>): string => {
    let processed: string = route;
    Object.keys(params).forEach((key) => {
      const value = params[key];
      if (typeof value === 'string') {
        processed = processed.replace(`:${key}`, value);
      }
    });
    return processed;
  };

  const result = ({ path }: { path: TExtractParams<T> }): string => {
    return createUrl({ path: preparePath(path) });
  };
  result.route = route;

  result.withQuery = <TQuery extends Record<string, unknown>>() => {
    const fn = ({ path, query }: { path: TExtractParams<T>; query: TQuery }): string => {
      return createUrl({ path: preparePath(path), query });
    };
    fn.route = route;
    return fn;
  };

  return result;
}

/**
 * Утилита для извлечения параметров страницы (из path + из query)
 */
function usePageParams<T extends (args: any) => string>(_route: T) {
  type RouteParams = Parameters<T>[0];
  type P = RouteParams extends { path: infer Path } ? Path : never;
  type Q = RouteParams extends { query: infer Query } ? Query : never;

  const path = useParams() as P;
  const [query, setParams] = useQueryParams<Q>();

  return { path, query, setParams };
}

Обратите внимание, что использование usePageParams изменилось, теперь мы будем передавать роут не через тип, а в аргументы:

const {
  path: { taskId },
  query: { type },
} = usePageParams(Routes.tasks.item);
Проверяем, что всё работает и IDE подсказывает типы правильно:
export const useExample = (): void => {
  const navigate = useNavigate();

  // ВСЁ ОК
  navigate(
    Routes.tasks.itemWithQuery({
      path: {
        id: '123'
      },
      query: {
        type: 456
      }
    })
  );
  // ВСЁ ОК
  const withQuery = usePageParams(Routes.tasks.itemWithQuery);
  console.log(withQuery.path.id); // '123' - string!
  console.log(withQuery.query.type); // 456
  console.log(withQuery.route); // 'tasks/:id'

  // ВСЁ ОК
  navigate(
    Routes.tasks.itemNoQuery({
      path: {
        id: 123
      }
    })
  );
  // ВСЁ ОК
  const noQuery = usePageParams(Routes.tasks.itemNoQuery);
  console.log(noQuery.path.id); // 123 - number!
  console.log(noQuery.route); // 'tasks/:id'
};

С помощью полей "route" объект Routes можно использовать как единый источник правды при создании роутера. Это исключает расхождения и упрощает поддержку:

export const browserRouter = createBrowserRouter([
  {
    children: [
      {
        path: Routes.tasks.index.route,
        element: <div>INDEX</div>
      },
      {
        path: Routes.tasks.item.route,
        element: <div>ITEM</div>
      }
    ]
  }
]);

Хм, а ЕЩЁ улучшить можно как-то?

Типизируем автоматические path-параметры

Не всегда path-параметры — это только строки или только числа. Давайте сделаем так, чтобы можно было указывать тип параметров:

const Routes = {
  tasks: {
    itemString: parametrizedRoute('/tasks/:id<string>'),
    itemNumber: parametrizedRoute('/tasks/:id<number>'),
    itemCustom: parametrizedRoute('/tasks/:id<custom>').withQuery<...>(),
  }
};

«А что за custom такой?», — спросите вы. Это мы так внедряем гибкость возможных типов: например, для поддержки строковых литеральных объединений, когда параметр может принимать только одно из фиксированных значений.

И ещё обработаем корнер-кейс, когда есть только query-параметры. Для этого напишем утилиту TRemoveEmptyObjects, которая преобразует пустой объект path: {} на необязательное поле path?: never.

parametrizedRoute('/example').withQuery<{ test: number }>()

…

type TRemoveEmptyObjects<T> = {
  [K in keyof T as keyof T[K] extends never ? never : K]: T[K];
};
type TPathParams<T extends string> = TRemoveEmptyObjects<{ path: TExtractParams<T> }>;

Новый план такой:

  • Парсим строку роута:

    • извлекаем path-параметры.из “скобочек” <...>

    • извлекаем строковый тип: “string” / “number” / “custom” / … .

  • Преобразовываем строковый тип в настоящий TS-тип: string / number / “a” | “b” / … .

  • На JS подставляем path-параметры (/:id), преобразуя в указанный тип.

Чтобы не просто писать код, а сразу его проверять и наглядно видеть то, что делают типы – давайте также дописывать тесты на каждый тип. Мы подсмотрели, как писать тесты, в статье у Кости Логиновских.

Итоговый код с комментариями:

/*
 * Мапы преобразования строки в фактический тип
 */
type TDefaultType = number;
type TParamTypeMap = {
  string: string;
  number: number;
  custom: 'a' | 'b';
};
type TTypeConverters = {
  [K in keyof TParamTypeMap]: (v: string | undefined) => TParamTypeMap[K] | undefined;
};
export const TYPE_CONVERTERS: TTypeConverters = {
  string: (v) => String(v),
  number: (v) => (isNaN(Number(v)) ? undefined : Number(v)),
  custom: (v) => (v === 'a' ? 'a' : 'b'),
};

export const getParamRegExp = (key: string): RegExp => new RegExp(`:${key}(?:<([\\w]+)>)?`);

/**
 * Тип, который скопирует переданный тип и тем самым объединит объекты в один
 * {id: number} & {type: string} => { id: number; type: string }.
 * Это визуально улучшит ошибки в IDE
 * @see https://www.totaltypescript.com/concepts/the-prettify-helper
 */
type TPrettify<T> = {
  [K in keyof T]: T[K];
} & {};

type TPrettifyTests = [
  Expect<
    Equals<
      TPrettify<{ first: string } & { second: { third: number } }>,
      { first: string; second: { third: number } }
    >
  >,
];

/**
 * Тип, который определяет тип path-параметра
 */
type TGetParamType<Param extends string> =
  // Проверяем, содержит ли строка имя и тип
  Param extends `${infer NamePart}<${infer TypePart}>`
    ? // Проверяем, указан ли такой тип в мапе
      TypePart extends keyof TParamTypeMap
      ? // Тип указан в мапе -> берём тип из мапы
        { [K in NamePart]: TParamTypeMap[TypePart] }
      : // Тип не указан в мапе -> дефолтный
        { [K in NamePart]: TDefaultType }
    : // Тип "<type>" не указан -> дефолтный
      { [K in Param]: TDefaultType };

type TGetParamTypeTests = [
  Expect<Equals<TGetParamType<'param<string>'>, { param: string }>>,
  Expect<Equals<TGetParamType<'param<number>'>, { param: number }>>,
  Expect<Equals<TGetParamType<'param<custom>'>, { param: 'a' | 'b' }>>,
];

/**
 * Тип, который разбирает строку и извлекает параметры с учётом <type>
 */
export type TExtractParams<T extends string> = TPrettify<
  T extends `${string}:${infer Param}/${infer Rest}`
    ? TGetParamType<Param> & TExtractParams<`/${Rest}`>
    : T extends `${string}:${infer Param}`
      ? TGetParamType<Param>
      : unknown
>;

type TExtractParamsTests = [
  Expect<Equals<TExtractParams<'/:first'>, { first: TDefaultType }>>,
  Expect<Equals<TExtractParams<'/start/:first<string>/end'>, { first: string }>>,
  Expect<
    Equals<
      TExtractParams<'/start/:first<number>/middle/:second/end'>,
      { first: number; second: TDefaultType }
    >
  >,
  Expect<
    Equals<
      TExtractParams<'/:first<number>/:second/:third<custom>'>,
      { first: number; second: TDefaultType; third: 'a' | 'b' }
    >
  >,
];

/**
 * Обрабатываем случай, когда только квери: parametrizedRoute('/example').withQuery<{ test: number }>(),
 * Пустой объект { path: {} } будет преобразован в необязательное поле { path?: never }
 */
type TRemoveEmptyObjects<T> = {
  [K in keyof T as keyof T[K] extends never ? never : K]: T[K];
};

type TRemoveEmptyObjectsTests = [
  Expect<
    Equals<
      TRemoveEmptyObjects<{ first: {}; second: { example: string }; third: {} }>,
      { second: { example: string } }
    >
  >,
];

/**
 * Тип, который извлекает и преобразовывает path-параметры при их наличии
 */
type TPathParams<T extends string> = TRemoveEmptyObjects<{ path: TExtractParams<T> }>;

type TPathParamsTests = [
  Expect<Equals<TPathParams<'/:first<string>'>, { path: { first: string } }>>,
  Expect<Equals<TPathParams<'/example'>, {}>>,
];

/**
 * Тип, который преобразует все НЕ строки в строки. Нужен для Query.
 */
type TObjectFieldsToStrings<T> = {
  [K in keyof T]: T[K] extends string | undefined ? T[K] : string;
};

type TObjectFieldsToStringsTests = [
  Expect<
    Equals<
      TObjectFieldsToStrings<{ first: number; second: boolean; third: string }>,
      { first: string; second: string; third: string }
    >
  >,
];

/**
 * Итоговый тип параметризированного пути:
 */
export type TParametrizedRoute<T extends string> = {
  // здесь теперь TPathParams, который убирает пустой path
  (params: TPathParams<T>): string;
  // исходный путь вида /example/:id<number>/inner
  initialRoute: T;
  // путь для роутера вида /example/:id/inner
  route: string;

  withQuery<TQuery extends Record<string, unknown>>(): {
    // здесь теперь TPathParams, который убирает пустой path
    (params: TPathParams<T> & { query: TQuery }): string;
    initialRoute: T;
    route: string;
  };
};

export function parametrizedRoute<T extends string>(route: T): TParametrizedRoute<T> {
  const preparePath = (params: TPathParams<T>): string => {
    if (!('path' in params)) {
      return route;
    }
    let processed: string = route;
    const path = params.path as TExtractParams<T>;
    Object.keys(path).forEach((key) => {
      processed = processed.replace(getParamRegExp(key), String(path[key]));
    });
    return processed;
  };

  function prepareRoute(value: string): string {
    return value.replace(/:(\w+)<[^>]+>/g, ':$1');
  }

  const result = (params: TPathParams<T>): string => {
    return createUrl({ path: preparePath(params) });
  };
  result.initialRoute = route;
  result.route = prepareRoute(route);

  result.withQuery = <TQuery extends Record<string, unknown>>() => {
    const fn = (params: TPathParams<T> & { query: TQuery }): string => {
      return createUrl({ path: preparePath(params), query: params.query });
    };
    fn.initialRoute = route;
    fn.route = prepareRoute(route);
    return fn;
  };

  return result;
}

/**
 * Утилита для извлечения параметров страницы (из path + из query)
 */
export function usePageParams<
  Route extends string,
  Fn extends ((args: any) => string) & { initialRoute: Route },
>(fn: Fn) {
  type RouteParams = Parameters<Fn>[0];
  type P = RouteParams extends { path: infer Path } ? Path : never;
  // Квери параметры возвращаются именно строками - преобразуем:
  type Q = RouteParams extends { query: infer Query } ? TObjectFieldsToStrings<Query> : never;

  const rawPathParams = useParams();
  /**
   * Преобразовываем значения параметров к ожидаемым типам
   */
  const path = useMemo(() => {
    return Object.fromEntries(
      Object.keys(rawPathParams).map((key) => {
        const value = rawPathParams[key];
        const match = fn.initialRoute.match(getParamRegExp(key));
        const type = match?.[1];
        const defaultConverter: (v: any) => TDefaultType | undefined = TYPE_CONVERTERS['number'];
        const converter: (v: string | undefined) => any =
          (type && TYPE_CONVERTERS[type]) ?? defaultConverter;
        return [key, converter?.(value)];
      }),
    ) as P;
  }, [rawPathParams, fn.initialRoute]);

  const [query, setQuery] = useQueryParams<Q>();

  return { path, query, setQuery };
}

Итоги

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

И это лишь один из возможных подходов — адаптируйте его под конкретные задачи и стиль вашей команды. Спасибо, что дочитали до конца!

Теги:
Хабы:
Всего голосов 10: ↑10 и ↓0+12
Комментарии12

Публикации

Информация

Сайт
tochka.com
Дата регистрации
Дата основания
Численность
1 001–5 000 человек
Местоположение
Россия
Представитель
Сулейманова Евгения