Типобезпечні спільні дані та властивості сторінок в Inertia.js

Перекладено ШІ
Оригінал: Laravel News
Оновлено: 27 лютого, 2025
У цій статті я розкрию, як типізувати спільні дані та властивості сторінок у проектах Inertia.js за допомогою Laravel Data і TypeScript. Дізнайтеся, як це може покращити структуру ваших даних і зробити вашу розробку ефективнішою!

Якщо ви слідкуєте за мною, ви знаєте, що я активно працюю з React/Inertia і насолоджуюсь цим. Сьогодні я поділюсь технікою, яку використовую в усіх своїх проектах на Inertia.js, що працює і з React, і з Vue, а також з будь-яким JavaScript-фреймворком: типізація ваших спільних даних та властивостей сторінок

# Що таке спільні дані?

В Inertia.js спільні дані визначаються у вашому middleware HandleInertiaRequests у методі share(). Ці дані доступні з кожного запиту з вашого бекенду на фронтенд, отже, вони стають "глобальними". Оскільки ці дані використовуються всюди, важливо зрозуміти їх структуру

Прикладом можуть слугувати дані про користувача, які ви обмінюєте на кожному запиті. Ці дані можуть або не мати місце в залежності від стану автентифікації. Але як найкраще визначити та відстежувати цю структуру даних? Відповідь: Laravel Data DTOs і TypeScript!

# Властивості, специфічні для сторінки

Окрім глобальних спільних даних, більшість запитів також включає властивості, специфічні для сторінки. Деякі сторінки можуть покладатись тільки на глобальні дані, але більшість повертає додаткову інформацію, специфічну для цієї сторінки. Це може включати:

Якщо ваш додаток зростає і ваша команда розширюється, важливо мати чітке уявлення про ці дані без постійного переключення між контекстами для перевірки ваших моделей

Нещодавно я провів доповідь на Laravel Worldwide Meetup про типізацію даних за допомогою пакетів Spatie Laravel Data і TypeScript Transformer. Давайте розберемося, як типізувати як глобальні спільні дані, так і властивості, специфічні для сторінки, отримуючи до них доступ через хуки, а не передаючи їх безпосередньо компонентам сторінок

# Проблема, яку я хочу вирішити у своїй програмі

Для цього прикладу я пропущу повні деталі моделей і міграцій, але ви можете уявити простий випадок, коли потрібна назва команди name для відображення та ID для можливості перемикання користувача в команді

У моєму додатку користувачі можуть бути у багатьох командах. Я хочу дозволити їм перемикатись між командами та бачити всі команди, до яких вони належать

Я також матиму "breadcrumbs", які хочу визначити і передати з контролерів. Ці дані будуть контекстуальними до контролера та вигляду, які я повертаю, тому, хоча це і є глобальні дані, їх легко визначити в Inertia Middleware, тому ми зробимо це з нашого контролера

Отже, ви бачите, що в нашому макеті програми буде потрібен невеликий компонент для перемикання команд, разом з "breadcrumbs"

# Чому це проблема?

Дані, які надходять з бекенду програми, повинні досягти деяких дочірніх компонентів. Якщо уявити, що макет вашого додатку є батьківським компонентом для всіх інших дочірніх компонентів, вам потрібно зрозуміти, як передати дані їм від спільних властивостей для даних користувача та команди, а також для компонента "breadcrumbs"

Щоб це зробити, у вас є кілька варіантів:

# Зануримось у код

Отже, припустимо, ваші глобальні спільні дані виглядають саме так. Використаємо Laravel Data від Spatie для визначення наших відповідей

У вашому HandleInertiaRequests middleware ви будете ділитись наступними даними:

class InertiaShareData extends Data
{
   public function __construct(
       public readonly ?InertiaAuthData $auth,
       /** null|array<string,string> */
       public array|string|AlwaysProp|null $errors = null,
   ) {
   }
}

У наведеному вище прикладі ми визначили, що наші дані складаються з $errors, які надходять з будь-якого 422 редиректу валідації, разом з InertiaAuthData. Ці дані не завжди будуть присутні, оскільки ми, наприклад, можемо мати чи не мати авторизованого користувача у випадку зі сторінками реєстрації

Нижче ми маємо авторизовані дані, які нададуть інформацію про нашого поточного авторизованого користувача, поточну команду, дані якої вони переглядають, а також колекцію інших команд, до яких вони належать

class InertiaAuthData extends Data
{
   public function __construct(
       public readonly ?UserData $user,
       public readonly TeamData|Optional|null $currentTeam,
       #[DataCollectionOf(TeamData::class)]
       public readonly DataCollection|Optional|null $teams,
   ) {
   }
}

Потім, у вашому Inertia middleware, ви можете використовувати ці типи, щоб забезпечити належну структуру ваших спільних даних:

class HandleInertiaRequests extends Middleware
{
   public function share(Request $request): InertiaShareData
   {
       return InertiaShareData::from(
           array_merge(
               parent::share($request),
               $this->authData($request),
           )
       );
   }
 
   private function authData(Request $request): array
   {
       if ($user = $request->user()) {
           $user->loadMissing(['teams']);
           return [
               'auth' => [
                   'user' => UserData::from($user),
                   'currentTeam' => TeamData::optional($user->currentTeam),
                   'teams' => TeamData::collect($user->teams),
               ],
           ];
       }
 
       return ['auth' => null];
   }
}

Таким чином, з наведеною вище налаштуванням, кожен запит через наше middleware програми повертатиме ці дані на фронт. Тепер давайте подивимось, що наші контролери надішлють для відображення даних про "breadcrumbs"

class DashboardController
{
    public function __invoke(): \Inertia\Response
    {
        return inertia('dashboard/index', [
            'meta' => [
                'title' => 'Dashboard',
                'breadcrumbs' => [
                    [
                        'url' => route('dashboard.show'),
                        'label' => 'Dashboard',
                    ],
                ],
            ],
            // інші дані, які можуть бути потрібні
        ]);
    }
}

# Визначимо типи

Гарною рисою використання Laravel Data є те, що за допомогою додаткового пакету PHP DTO, які ми створили для обміну нашими даними, можуть бути перетворені у типи TypeScript

Це надзвичайна риса, але нам все ще потрібно визначити кілька речей, щоб повністю скористатися типізованими спільними та сторінковими властивостями

У /resources/js/types/ ви можете створити файл global.d.ts. Розширення .d.ts означає "файл декларації" в TypeScript. Ці файли використовуються для надання інформації про типи для JavaScript-коду або для визначення амбієнтних типів, доступних у всьому проекті. Вони не містять реалізації – лише декларації типів для сприяння автозаповненню та перевірці коду

Ми хочемо вказати TypeScript, яка форма і тип даних ми очікуємо, коли звертаємось до Inertia PageProps

// -/resources/js/types/global.d.ts
import { PageProps as InertiaPageProps } from '@inertiajs/core'
 
export type PageProps<
  T extends Record<string, unknown> | unknown[] = Record<string, unknown> | unknown[]
> = App.Data.InertiaSharedData & T;
 
declare module '@inertiajs/core' {
  interface PageProps extends InertiaPageProps, AppPageProps {}
}

У /resources/js/types/global.d.ts ми розширюємо типову систему Inertia. Тип PageProps, який ми створюємо, поєднує наші спільні дані (App.Data.InertiaSharedData) з будь-якими додатковими властивостями, специфічними для сторінки (T). Ми також додаємо до вбудованого інтерфейсу PageProps Inertia наші власні типи

Тепер створимо хук, який спростить доступ як до наших спільних пропсів, так і до будь-яких даних, специфічних для сторінки (наприклад, meta) із повною безпекою типів

// -/resources/js/composables/use-typed-page-props.ts
 
import { usePage } from '@inertiajs/react';
import type { PageProps } from '@/types/global';
 
export function useTypedPageProps<
  T extends Record<never, never> | unknown[] = Record<never, never> | unknown[]
>() {
  return usePage<PageProps<T>>();
}

Цей хук є обгорткою над хуком Inertia usePage, яка додає сильну типізацію. Давайте пояснимо кожен рядок

export function useTypedPageProps<
  // T є параметром типу, який за замовчуванням є пустим записом або масивом
  T extends Record<never, never> | unknown[] = Record<never, never> | unknown[]
>() {
  // Повертає usePage із нашим типом PageProps (спільні дані), поєднаним з будь-якими специфічними для сторінки даними (T)
  return usePage<PageProps<T>>();
}

# Тепер ми готові!

# Простота використання: Кастомні хуки

Тепер тут починається справжня цікавість. Замість того, щоб отримувати доступ до спільних даних безпосередньо, ми можемо створити кастомний хук, який надає правильну типізацію

// -resources/js/composables/use-auth.ts
import type { PageProps } from '@/types/global';
import { useTypedPageProps } from '@/composables/use-typed-page-props';
 
export function useAuth(): App.Data.UserData {
 const {
   auth: { user }
 } = useTypedPageProps<PageProps>().props;
 return user as unknown as App.Data.UserData;
}
 
export function useCurrentTeam(): App.Data.TeamData {
 const {
   auth: { currentTeam }
 } = useTypedPageProps<PageProps>().props;
 return currentTeam as unknown as App.Data.TeamData;
}
 
export function useTeams(): App.Data.TeamData[] {
 const {
   auth: { teams }
 } = useTypedPageProps<PageProps>().props;
 return teams as unknown as App.Data.TeamData[];
}

Тепер ви можете використовувати їх у своїх компонентах ось так:

function UserProfile() {
  const user = useAuth();
 
  return (
    <div>
      <h1>Ласкаво просимо, {user.name}!</h1>
      {user.company & 

Компанія: {user.company.name}

} </div> ); }

# Обробка метаданих специфічних для сторінки

Цю ж схему можна використовувати і для метаданих специфічних для сторінки. Ось приклад, як типізувати та використовувати метадані у ваших сторінках:

interface Metadata {
  title: string;
  breadcrumbs: {
    label: string;
    url: string;
  }[];
}
 
// У ваш компонент:
const { meta } = useTypedPageProps<{ meta: Metadata }>().props;
 
// Тепер ви отримаєте повне автозаповнення для ваших метаданих!
console.log(meta.title);
meta.breadcrumbs.map(crumb => crumb.url);

Тепер давайте подивимось, як ми можемо додати компонент "breadcrumbs", використовуючи наведений вище код

// Breadcrumbs.tsx
import { Link } from '@inertiajs/react';
import { ChevronRight, Home } from 'lucide-react';
import { useTypedPageProps } from '@/composables/use-typed-page-props';
 
interface BreadcrumbMeta {
  meta: {
    title: string;
    breadcrumbs: {
      label: string;
      url: string;
    }[];
  };
}
 
export function Breadcrumbs() {
  const { meta } = useTypedPageProps<BreadcrumbMeta>().props;
 
  return (
    <nav className="flex items-center space-x-1 text-sm text-gray-500">
      <Link href="/dashboard" className="flex items-center hover:text-blue-600">
        <Home className="h-4 w-4" />
      </Link>
 
      {meta.breadcrumbs.map((crumb, index) => (
        <div key={crumb.url} className="flex items-center">
          <ChevronRight className="h-4 w-4 mx-1" />
          {index === meta.breadcrumbs.length - 1 ? (
            <span className="font-medium text-gray-900">{crumb.label}</span>
          ) : (
            <Link
              href={crumb.url}
              className="hover:text-blue-600"
            >
              {crumb.label}
            </Link>
          )}
        </div>
      ))}
    </nav>
  );
}

Як це класно?

# А що з Vue?

Це найкраща частина цього підходу — він абсолютно незалежний від фреймворка. Хоча я вважаю, що React має набагато простішу і чистішу інтеграцію з TypeScript, ви можете використовувати ті ж самі хуки і у Vue

Оскільки Vue є важливою частиною Laravel, ось приклад для вас!

<!-- Breadcrumbs.vue -->
<script setup lang="ts">
import { Link } from '@inertiajs/vue3'
import { ChevronRight, Home } from 'lucide-vue-next'
import { useTypedPageProps } from '@/composables/use-typed-page-props'
 
interface BreadcrumbMeta {
  meta: {
    title: string;
    breadcrumbs: {
      label: string;
      url: string;
    }[];
  };
}
 
const { meta } = useTypedPageProps<BreadcrumbMeta>().props // це не змінюється!
</script>
 
<template>
  <nav class="flex items-center space-x-1 text-sm text-gray-500">
    <Link href="/dashboard" class="flex items-center hover:text-blue-600">
      <Home class="h-4 w-4" />
    </Link>
 
    <div v-for="(crumb, index) in meta.breadcrumbs" :key="crumb.url" class="flex items-center">
      <ChevronRight class="h-4 w-4 mx-1" />
      <span
        v-if="index === meta.breadcrumbs.length - 1"
        class="font-medium text-gray-900"
      >
        {{ crumb.label }}
      </span>
      <Link
        v-else
        :href="crumb.url"
        class="hover:text-blue-600"
      >
        {{ crumb.label }}
      </Link>
    </div>
  </nav>
</template>

# Цей пост потребує більшого натхнення, давайте піднімемо ставки

Як би чудово це не було, це невеликий, простий приклад. Давайте спробуємо створити щось складніше. Створимо компонент, який показуватиме поточному користувачу список їх команд та їх поточну команду у спливаючому вікні Shadcn та комбобокс! Це буде наш перемикач команд

// TeamSwitcher.tsx
import { useEffect, useState } from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';
import { useCurrentTeam, useTeams } from '@/composables/use-auth';
import { Button } from '@/components/ui/button';
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
} from '@/components/ui/command';
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from '@/components/ui/popover';
import { router } from '@inertiajs/react';
import { cn } from '@/lib/utils';
 
export function TeamSwitcher() {
  const [open, setOpen] = useState(false);
  const currentTeam = useCurrentTeam(); // це дасть нам дані про поточну команду з глобальних спільних даних
  const teams = useTeams(); // це дасть нам всі команди користувача, також з глобальних даних
 
  // Обробка перемикання команди
  const switchTeam = (teamId: number) => {
    router.post(route('teams.switch'), {
      team_id: teamId
    }, {
      preserveScroll: true,
      onSuccess: () => setOpen(false)
    });
  };
 
  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger asChild>
        <Button
          variant="outline"
          role="combobox"
          aria-expanded={open}
          className="w-[200px] justify-between"
        >
          {currentTeam?.name ?? "Виберіть команду..."}
          <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
        </Button>
      </PopoverTrigger>
      <PopoverContent className="w-[200px] p-0">
        <Command>
          <CommandInput placeholder="Шукати команди..." />
          <CommandEmpty>Команд не знайдено.</CommandEmpty>
          <CommandGroup>
            {teams.map((team) => (
              <CommandItem
                key={team.id}
                value={team.name}
                onSelect={() => switchTeam(team.id)}
              >
                <Check
                  className={cn(
                    "mr-2 h-4 w-4",
                    currentTeam?.id === team.id ? "opacity-100" : "opacity-0"
                  )}
                />
                {team.name}
              </CommandItem>
            ))}
          </CommandGroup>
        </Command>
      </PopoverContent>
    </Popover>
  );
}

Зауважте, як цей компонент є повністю автономним і не вимагає, щоб батьківський компонент передавав дані для коректного відображення та роботи

Також, у вашому коді ви можете скористатись функцією автозаповнення для кожної доступної у DTO частини даних

Давайте подивимось, як реалізувати ці компоненти в нашому макеті програми

// AppLayout.tsx
import { PropsWithChildren } from 'react';
import { TeamSwitcher } from '@/components/TeamSwitcher';
import { Breadcrumbs } from '@/components/Breadcrumbs';
import { useAuth } from '@/composables/use-auth';
 
export default function AppLayout({ children }: PropsWithChildren) {
  const user = useAuth();
 
  return (
    <div className="min-h-screen bg-gray-50">
      <header className="bg-white shadow">
        <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
          <div className="flex justify-between items-center">
            <h1 className="text-xl font-semibold">Мій додаток</h1>
 
            <div className="flex items-center space-x-4">
              {user & }
              
Ласкаво просимо, {user?.name}
</div> </div> </div> </header> <div className="mt-4"> </div>
{children}
</div> ); }

Тепер, чесно кажучи, наскільки чистим і легким для читання є цей код? Також зверніть увагу, що ми можемо знову використовувати той самий хук useAuth для отримання користувача в макеті програми, як справжній професіонал 😎

# Заключні думки

Ця схема значно поліпшила спосіб обробки спільних даних у наших Inertia-додатках. Це не лише робить код більш підтримуваним, але й допомагає своєчасно виявляти можливі проблеми перед потраплянням у продакшн

  1. Безпека типів: Ви отримуєте повну підтримку TypeScript для ваших спільних даних
  2. Кращий досвід розробника: Автозаповнення та типові підказки пришвидшують розробку
  3. Легший рефакторинг: Коли вам потрібно змінити структуру спільних даних, TypeScript допоможе вам знайти всі місця, які потрібно оновити
  4. Чистіший код: Більше ніяких тверджень про типи або здогадок, які властивості доступні
  5. Більш гнучкі UI-компоненти: Легко інтегрувати функціональність для будь-якого компонента, якому потрібні дані

Пам'ятайте, мета тут не просто у безпеці типів — це про те, щоб зробити вашу кодову базу більш підтримуваною, а досвід розробника — більш приємним. Удачі у програмуванні! 🚀