Перейти к основному содержимому

Архитектура проекта

Я потратил немало времени, открывая чужие (и свои собственные) проекты, где компоненты на 600 строк соседствовали с запросами к API прямо в onMounted, а папка components/ превратилась в свалку из 80 файлов без какой-либо структуры. Эта статья про то, как я решил эту проблему для себя раз и навсегда.

Примеры здесь на Vue 3 — это просто способ, которым мне удобнее всего объяснять. Сами принципы я применяю в любом стеке: React, Astro, ванильный HTML/CSS/JS. Стек меняется, подход — нет.


Часть I. Файловая система

Структура директорий

Вот базовое дерево, которое я использую в каждом приложении:

src/
├── assets/
│   ├── fonts/
│   └── styles/
│       ├── lib/         # tailwind.css и другие глобальные конфиги
│       └── main.css
├── components/
│   ├── ui/              # UiButton.vue, UiInput.vue — атомарные кирпичики
│   ├── base/            # BaseList.vue, BaseCatalog.vue — переиспользуемые секции
│   ├── app/             # AppHeader.vue, AppSidebar.vue, AppFooter.vue
│   └── pages/
│       └── products/
│           ├── ProductsPage.vue
│           └── ProductsListSection.vue
├── composables/         # useFilters.ts, usePagination.ts
├── config/              # Константы, магические значения, UI-конфиги
├── layouts/
│   └── default.vue
├── router/
│   ├── index.ts
│   └── productsRoutes.ts
├── services/            # API-клиент (подробнее — в отдельной статье)
├── stores/
│   └── products.store.ts
├── types/
│   ├── index.ts
│   └── enums/
├── utils/               # Нереактивные вспомогательные функции
├── validation/          # Схемы валидации для форм
│   └── productSchema.ts
└── views/
    └── ProductsView.vue  # Тонкая обёртка над ProductsPage.vue

Каждая папка здесь решает одну задачу. Никакой папки helpers/ куда валится всё подряд, никакого components/ProductCard.vue в корне — только понятная иерархия.

Иерархия компонентов

Самое важное решение во всей архитектуре. Я делю компоненты на четыре категории — и смешивать их нежелательно.

  1. ui/ — атомарные кирпичики. Минимальная логика, никакого бизнес-контекста. Это переиспользуемые примитивы, которые должны работать в любом месте приложения: UiButton.vue, UiInput.vue, UiTabs.vue, UiModal.vue. Правило простое: если компонент знает про Product или User — он уже не ui/.
  2. base/ — переиспользуемые блоки. Более крупные компоненты, которые могут содержать бизнес-логику и собирать в себе несколько Ui*-компонентов. Они переиспользуются между разными фичами: BaseEntityHeader.vue, BaseEntityList.vue, BaseEntityDrawer.vue.
  3. app/ — глобальный лейаут. Компоненты, которые существуют в единственном экземпляре и формируют скелет приложения: AppHeader.vue, AppSidebar.vue, AppFooter.vue.
  4. pages/[feature]/ — компоненты страниц. Вот где сосредоточена основная бизнес-логика. Каждая страница — своя папка:
pages/
└── products/
    ├── ProductsPage.vue        # Главный компонент страницы
    └── ProductsListSection.vue # Подсекция страницы

*Page.vue — это центр управления страницей: он получает данные через стор, распределяет их по дочерним компонентам и управляет состоянием. Именно сюда смотришь в первую очередь, когда нужно разобраться, как работает та или иная страница.

*Section.vue / *Block.vue / *List.vue и т.д. — это крупные смысловые блоки, которые выделяются из Page, чтобы не раздувать его до неуправляемых размеров.

Инфраструктура страницы

views/ (в Vue) и pages/ (в Nuxt) — инфраструктура страницы: подключение лейаута, SEO-мета, редиректы, роутинг-параметры. Всё, что делает страницу страницей — но не то, что она показывает.

pages/index.vue
<script setup lang="ts">
import HomePage from "@/components/pages/home/HomePage.vue"

const { t } = useI18n()

useSeoMeta({
  title: t("pages.home.title"),
  description: t("pages.home.description")
})

definePageMeta({
  layout: "home"
})
</script>

<template>
  <HomePage />
</template>

Бизнес-логика, запросы к API, состояние фичи — всё это уходит в *Page.vue. View знает только то, что нужно знать роутеру и браузеру.


Часть II. Нейминг и соглашения

Хорошее именование — это документация, которая пишет сама себя. Когда видишь UiButton, сразу понятно: атомарный, без бизнес-логики, из ui/. Когда видишь products.store.ts — стор, не composable и т.д.

Файлы

  • Префикс директории в имени файла. Компонент из ui/UiButton.vue. Из base/BaseEntityList.vue. Из app/AppHeader.vue. Это упрощает навигацию по файловой структуре и помогает избежать конфликтов имён при поиске.
  • Сторы: [name].store.ts — использую суффикс .store.ts при создании сторов.
  • Composables: use[Name].ts — стандарт Vue-экосистемы.
  • Схемы валидации: суффикс SchemaproductSchema.ts, loginSchema.ts.
  • Роуты: суффикс RoutesproductsRoutes.ts, usersRoutes.ts.
  • Константы и enum-значения: UPPER_SNAKE_CASE.

Шаблоны

  • PascalCase для компонентов. Всегда <UiButton>, никогда <ui-button>. Это не только конвенция — это способ визуально отделить компоненты от нативных HTML-тегов.
  • Префикс props. при обращении к пропсам. Позволяет мгновенно отличить пропс от локального ref при чтении шаблона. Звучит как мелочь — но когда компонент разрастается, это экономит минуты каждый раз.
<!-- ✅ Правильно -->
<div>{{ props.title }}</div>

<!-- ❌ Неправильно -->
<div>{{ title }}</div>
  • Обработчики через handle*. Никогда не вызываю emit или сложную логику напрямую в шаблоне — только через именованную функцию в <script setup>. Это легче расширять, тестировать и читать: шаблон остаётся декларативным.
<!-- ✅ Правильно -->
<UiButton @click="handleSubmit" />

<!-- ❌ Неправильно -->
<UiButton @click="emit('submit', formData)" />

Часть III. Внутри компонента

Порядок в <script setup>

Я придерживаюсь строгого порядка, потому что когда все файлы устроены одинаково, глаз мгновенно находит нужное место.

components/pages/products/ProductsPage.vue
<script setup lang="ts">
// 1. Импорты (Фреймворк → Сторы → Компоненты → Типы)
import { computed, onMounted, ref } from "vue"
import { useRouter } from "vue-router"
import { useProductsStore } from "@/stores/useProductsStore"
import ProductsListSection from "@/components/pages/products/ProductsListSection.vue"
import type { Product } from "@/types"

// 2. Локальные константы
const PAGE_SIZE = 20

// 3. Props и Emits
const props = defineProps<{
  initialFilter?: string
}>()

// 4. Глобальные утилиты
const router = useRouter()

// 5. Сторы
const productsStore = useProductsStore()

// 6. Реактивное состояние
const searchQuery = ref("")

// 7. Computed
const filteredProducts = computed(() =>
  productsStore.products.filter((p) =>
    p.name.toLowerCase().includes(searchQuery.value.toLowerCase())
  )
)

// 8. Функции
const handleProductClick = (product: Product) => {
  router.push(`/products/${product.id}`)
}

// 9. Хуки жизненного цикла
onMounted(async () => {
  await productsStore.fetchProducts()
})
</script>

У меня остался некий ПТСР на эту тему из-за Vue 2 Options API, где логика была раскидана по разным секциям — data, methods, computed. Composition API к этому не обязывает, но привычка явно обозначать порядок осталась.


Часть IV. Инструменты

Роутинг: разбиваем по фичам

Все роуты в одном файле — это ловушка. Проект чуть вырастает, и router/index.ts превращается в простыню на 300 строк, в которой никто не хочет разбираться.

Я выношу роуты каждой фичи в отдельный файл:

router/productsRoutes.ts
import type { RouteRecordRaw } from "vue-router"

export const productsRoutes: RouteRecordRaw[] = [
  {
    path: "/",
    component: () => import("@/layouts/default.vue"),
    children: [
      {
        path: "/products",
        name: "products",
        component: () => import("@/views/ProductsView.vue")
      },
      {
        path: "/products/:id",
        name: "product-detail",
        component: () => import("@/views/ProductDetailView.vue")
      }
    ]
  }
]

А router/index.ts просто собирает их вместе:

router/index.ts
import { createRouter, createWebHistory } from "vue-router"
import { productsRoutes } from "@/router/productsRoutes"
import { usersRoutes } from "@/router/usersRoutes"

export const router = createRouter({
  history: createWebHistory(),
  routes: [...productsRoutes, ...usersRoutes]
})

Добавление новой фичи — это создание одного файла и одна строчка в index.ts.

Сторы: только Setup Stores

Я использую исключительно Setup Stores — не Options API-вариант. Они ближе к composables, лучше типизируются и проще тестируются.

stores/useProductsStore.ts
import { ref } from "vue"
import { defineStore } from "pinia"
import { ApiClient } from "@/services/client"
import type { Product } from "@/types"

export const useProductsStore = defineStore("products", () => {
  const products = ref<Product[]>([])
  const isLoading = ref(false)

  const fetchProducts = async () => {
    isLoading.value = true
    try {
      products.value = await ApiClient.products.getProducts()
    } finally {
      isLoading.value = false
    }
  }

  return { products, isLoading, fetchProducts }
})

Валидация: схемы отдельно от компонентов

Когда валидация живёт прямо в компоненте — это боль при масштабировании. Я выношу все схемы в src/validation/ и использую yup + vee-validate.

Схема — это всегда функция, а не объект. Это позволяет использовать useI18n() внутри и получать переведённые сообщения об ошибках:

validation/productSchema.ts
import { number, object, string } from "yup"

export default function schema() {
  const { t } = useI18n()

  return object({
    name: string().required(t("Название обязательно")).min(3, t("Минимум 3 символа")),
    price: number().required(t("Цена обязательна")).positive(t("Цена должна быть положительной"))
  })
}

В компоненте:

components/pages/products/ProductsCreateSection.vue
<script setup lang="ts">
import { useForm } from "vee-validate"
import schema from "@/validation/productSchema"

const { handleSubmit, errors } = useForm({
  validationSchema: schema()
})

const handleCreate = handleSubmit(async (values) => {
  await ApiClient.products.createProduct(values)
})
</script>

Сервисный слой

API-клиент и организация запросов — отдельная большая тема. Если коротко: всё живёт в services/, ни один компонент не знает про fetch напрямую, а вся типизация сосредоточена в доменных модулях. Подробно я разобрал это в статье «Чистый API-клиент».


Сквозной пример: страница Products

Посмотрим, как все эти слои работают вместе — полный путь от роута до шаблона.

1. Роут знает только про View:

router/productsRoutes.ts
{
  path: "/products",
  name: "products",
  component: () => import("@/views/ProductsView.vue")
}

2. View — тонкая обёртка:

views/ProductsView.vue
<script setup lang="ts">
import ProductsPage from "@/components/pages/products/ProductsPage.vue"
</script>

<template>
  <ProductsPage />
</template>

3. Page — центр управления страницей:

components/pages/products/ProductsPage.vue
<script setup lang="ts">
import { computed, onMounted, ref } from "vue"
import { useRouter } from "vue-router"
import { useProductsStore } from "@/stores/useProductsStore"
import AppHeader from "@/components/App/AppHeader.vue"
import ProductsListSection from "@/components/pages/products/ProductsListSection.vue"
import type { Product } from "@/types"

const router = useRouter()
const productsStore = useProductsStore()

const searchQuery = ref("")

const filteredProducts = computed(() =>
  productsStore.products.filter((p) =>
    p.name.toLowerCase().includes(searchQuery.value.toLowerCase())
  )
)

const handleProductClick = (product: Product) => {
  router.push(`/products/${product.id}`)
}

onMounted(async () => {
  await productsStore.fetchProducts()
})
</script>

<template>
  <div>
    <AppHeader />
    <ProductsListSection
      :products="filteredProducts"
      :is-loading="productsStore.isLoading"
      @select="handleProductClick"
    />
  </div>
</template>

4. Section — отвечает только за отображение:

components/pages/products/ProductsListSection.vue
<script setup lang="ts">
import type { Product } from "@/types"

const props = defineProps<{
  products: Product[]
  isLoading: boolean
}>()

const emit = defineEmits<{
  select: [product: Product]
}>()

const handleSelect = (product: Product) => {
  emit("select", product)
}
</script>

<template>
  <section>
    <div v-if="props.isLoading">Загрузка...</div>
    <ul v-else>
      <li
        v-for="product in props.products"
        :key="product.id"
        @click="handleSelect(product)"
      >
        {{ product.name }}
      </li>
    </ul>
  </section>
</template>

5. Store — весь data-fetching здесь:

stores/useProductsStore.ts
import { ref } from "vue"
import { defineStore } from "pinia"
import { ApiClient } from "@/services/client"
import type { Product } from "@/types"

export const useProductsStore = defineStore("products", () => {
  const products = ref<Product[]>([])
  const isLoading = ref(false)

  const fetchProducts = async () => {
    isLoading.value = true
    try {
      products.value = await ApiClient.products.getProducts()
    } finally {
      isLoading.value = false
    }
  }

  return { products, isLoading, fetchProducts }
})

Section не знает про стор. Page не знает про fetch. Store не знает про роутер. Каждый слой знает только о соседнем — это и есть главная идея.


А как это работает в Nuxt?

Подход остаётся тем же — те же правила нейминга, та же иерархия компонентов, те же сторы. Основное отличие: views/ переименовывается в pages/ и используется файловый роутинг. Главное дополнение — Layers: способ выделять фичи или переиспользуемые части в изолированные модули. Это тема отдельной статьи — «Nuxt 3 Layers».


Итог

Эта архитектура не идеальна, может показаться чересчур многословной, но зато она очень гибкая и масштабируемая. Есть множество иных подходов: Feature Slice Design (FSD), Atomic Design и т.п. Все они хороши по-своему, но главное — чтобы архитектура подходила именно вам/вашей команде и проекту.

СлойЗадача
ui/Атомарные переиспользуемые примитивы
base/Крупные переиспользуемые блоки с логикой
pages/[feature]/Page.vueЦентр управления страницей: данные, состояние
pages/[feature]/Section.vueВизуальные подблоки страницы
views/Тонкие обёртки для роутера
stores/Реактивное состояние и data-fetching
router/Разбитый по фичам роутинг
validation/Изолированные схемы валидации
services/API-клиент
\n\n\n","pages/index.vue",[144,336,337,371,393,400,425,430,442,469,491,498,503,513,528,535,545,550,560,572],{"__ignoreMap":180},[338,339,341,345,349,353,356,359,362,366,368],"span",{"class":340,"line":124},"line",[338,342,344],{"class":343},"sMK4o","<",[338,346,348],{"class":347},"swJcz","script",[338,350,352],{"class":351},"spNyl"," setup",[338,354,355],{"class":351}," lang",[338,357,358],{"class":343},"=",[338,360,361],{"class":343},"\"",[338,363,365],{"class":364},"sfazB","ts",[338,367,361],{"class":343},[338,369,370],{"class":343},">\n",[338,372,373,377,381,384,387,390],{"class":340,"line":6},[338,374,376],{"class":375},"s7zQu","import",[338,378,380],{"class":379},"sTEyZ"," HomePage ",[338,382,383],{"class":375},"from",[338,385,386],{"class":343}," \"",[338,388,389],{"class":364},"@/components/pages/home/HomePage.vue",[338,391,392],{"class":343},"\"\n",[338,394,396],{"class":340,"line":395},3,[338,397,399],{"emptyLinePlaceholder":398},true,"\n",[338,401,403,406,409,412,415,418,422],{"class":340,"line":402},4,[338,404,405],{"class":351},"const",[338,407,408],{"class":343}," {",[338,410,411],{"class":379}," t ",[338,413,414],{"class":343},"}",[338,416,417],{"class":343}," =",[338,419,421],{"class":420},"s2Zo4"," useI18n",[338,423,424],{"class":379},"()\n",[338,426,428],{"class":340,"line":427},5,[338,429,399],{"emptyLinePlaceholder":398},[338,431,433,436,439],{"class":340,"line":432},6,[338,434,435],{"class":420},"useSeoMeta",[338,437,438],{"class":379},"(",[338,440,441],{"class":343},"{\n",[338,443,445,448,451,454,456,458,461,463,466],{"class":340,"line":444},7,[338,446,447],{"class":347}," title",[338,449,450],{"class":343},":",[338,452,453],{"class":420}," t",[338,455,438],{"class":379},[338,457,361],{"class":343},[338,459,460],{"class":364},"pages.home.title",[338,462,361],{"class":343},[338,464,465],{"class":379},")",[338,467,468],{"class":343},",\n",[338,470,472,475,477,479,481,483,486,488],{"class":340,"line":471},8,[338,473,474],{"class":347}," description",[338,476,450],{"class":343},[338,478,453],{"class":420},[338,480,438],{"class":379},[338,482,361],{"class":343},[338,484,485],{"class":364},"pages.home.description",[338,487,361],{"class":343},[338,489,490],{"class":379},")\n",[338,492,494,496],{"class":340,"line":493},9,[338,495,414],{"class":343},[338,497,490],{"class":379},[338,499,501],{"class":340,"line":500},10,[338,502,399],{"emptyLinePlaceholder":398},[338,504,506,509,511],{"class":340,"line":505},11,[338,507,508],{"class":420},"definePageMeta",[338,510,438],{"class":379},[338,512,441],{"class":343},[338,514,516,519,521,523,526],{"class":340,"line":515},12,[338,517,518],{"class":347}," layout",[338,520,450],{"class":343},[338,522,386],{"class":343},[338,524,525],{"class":364},"home",[338,527,392],{"class":343},[338,529,531,533],{"class":340,"line":530},13,[338,532,414],{"class":343},[338,534,490],{"class":379},[338,536,538,541,543],{"class":340,"line":537},14,[338,539,540],{"class":343},"\n",[338,573,575,577,579],{"class":340,"line":574},18,[338,576,540],{"class":343},[338,578,557],{"class":347},[338,580,370],{"class":343},[140,582,583,584,586],{},"Бизнес-логика, запросы к API, состояние фичи — всё это уходит в ",[144,585,297],{},". View знает только то, что нужно знать роутеру и браузеру.",[156,588],{},[159,590,592],{"id":591},"часть-ii-нейминг-и-соглашения","Часть II. Нейминг и соглашения",[140,594,595,596,599,600,602,603,606],{},"Хорошее именование — это документация, которая пишет сама себя. Когда видишь ",[144,597,598],{},"UiButton",", сразу понятно: атомарный, без бизнес-логики, из ",[144,601,210],{},". Когда видишь ",[144,604,605],{},"products.store.ts"," — стор, не composable и т.д.",[164,608,610],{"id":609},"файлы","Файлы",[612,613,614,635,649,658,673,687],"ul",{},[203,615,616,619,620,622,623,625,626,622,628,625,630,622,632,634],{},[206,617,618],{},"Префикс директории в имени файла."," Компонент из ",[144,621,210],{}," — ",[144,624,215],{},". Из ",[144,627,244],{},[144,629,256],{},[144,631,266],{},[144,633,271],{},". Это упрощает навигацию по файловой структуре и помогает избежать конфликтов имён при поиске.",[203,636,637,640,641,644,645,648],{},[206,638,639],{},"Сторы:"," ",[144,642,643],{},"[name].store.ts"," — использую суффикс ",[144,646,647],{},".store.ts"," при создании сторов.",[203,650,651,640,654,657],{},[206,652,653],{},"Composables:",[144,655,656],{},"use[Name].ts"," — стандарт Vue-экосистемы.",[203,659,660,663,664,622,667,216,670,237],{},[206,661,662],{},"Схемы валидации:"," суффикс ",[144,665,666],{},"Schema",[144,668,669],{},"productSchema.ts",[144,671,672],{},"loginSchema.ts",[203,674,675,663,678,622,681,216,684,237],{},[206,676,677],{},"Роуты:",[144,679,680],{},"Routes",[144,682,683],{},"productsRoutes.ts",[144,685,686],{},"usersRoutes.ts",[203,688,689,640,692,237],{},[206,690,691],{},"Константы и enum-значения:",[144,693,694],{},"UPPER_SNAKE_CASE",[164,696,698],{"id":697},"шаблоны","Шаблоны",[612,700,701,715],{},[203,702,703,706,707,710,711,714],{},[206,704,705],{},"PascalCase для компонентов."," Всегда ",[144,708,709],{},"",", никогда ",[144,712,713],{},"",". Это не только конвенция — это способ визуально отделить компоненты от нативных HTML-тегов.",[203,716,717,724,725,728],{},[206,718,719,720,723],{},"Префикс ",[144,721,722],{},"props."," при обращении к пропсам."," Позволяет мгновенно отличить пропс от локального ",[144,726,727],{},"ref"," при чтении шаблона. Звучит как мелочь — но когда компонент разрастается, это экономит минуты каждый раз.",[172,730,732],{"className":332,"code":731,"language":89,"meta":180,"style":180},"\n
{{ props.title }}
\n\n\n
{{ title }}
\n",[144,733,734,740,759,763,768],{"__ignoreMap":180},[338,735,736],{"class":340,"line":124},[338,737,739],{"class":738},"sHwdD","\n",[338,741,742,744,747,750,753,755,757],{"class":340,"line":6},[338,743,344],{"class":343},[338,745,746],{"class":347},"div",[338,748,749],{"class":343},">",[338,751,752],{"class":379},"{{ props.title }}",[338,754,540],{"class":343},[338,756,746],{"class":347},[338,758,370],{"class":343},[338,760,761],{"class":340,"line":395},[338,762,399],{"emptyLinePlaceholder":398},[338,764,765],{"class":340,"line":402},[338,766,767],{"class":738},"\n",[338,769,770,772,774,776,779,781,783],{"class":340,"line":427},[338,771,344],{"class":343},[338,773,746],{"class":347},[338,775,749],{"class":343},[338,777,778],{"class":379},"{{ title }}",[338,780,540],{"class":343},[338,782,746],{"class":347},[338,784,370],{"class":343},[612,786,787],{},[203,788,789,795,796,799,800,803],{},[206,790,791,792,237],{},"Обработчики через ",[144,793,794],{},"handle*"," Никогда не вызываю ",[144,797,798],{},"emit"," или сложную логику напрямую в шаблоне — только через именованную функцию в ",[144,801,802],{},"\n","components/pages/products/ProductsPage.vue",[144,900,901,921,926,957,977,997,1013,1036,1040,1045,1058,1062,1067,1082,1094,1101,1105,1110,1123,1128,1134,1148,1153,1159,1178,1183,1189,1209,1236,1274,1280,1285,1290,1296,1324,1358,1364,1369,1375,1392,1408,1415],{"__ignoreMap":180},[338,902,903,905,907,909,911,913,915,917,919],{"class":340,"line":124},[338,904,344],{"class":343},[338,906,348],{"class":347},[338,908,352],{"class":351},[338,910,355],{"class":351},[338,912,358],{"class":343},[338,914,361],{"class":343},[338,916,365],{"class":364},[338,918,361],{"class":343},[338,920,370],{"class":343},[338,922,923],{"class":340,"line":6},[338,924,925],{"class":738},"// 1. Импорты (Фреймворк → Сторы → Компоненты → Типы)\n",[338,927,928,930,932,935,937,940,942,945,948,951,953,955],{"class":340,"line":395},[338,929,376],{"class":375},[338,931,408],{"class":343},[338,933,934],{"class":379}," computed",[338,936,872],{"class":343},[338,938,939],{"class":379}," onMounted",[338,941,872],{"class":343},[338,943,944],{"class":379}," ref",[338,946,947],{"class":343}," }",[338,949,950],{"class":375}," from",[338,952,386],{"class":343},[338,954,89],{"class":364},[338,956,392],{"class":343},[338,958,959,961,963,966,968,970,972,975],{"class":340,"line":402},[338,960,376],{"class":375},[338,962,408],{"class":343},[338,964,965],{"class":379}," useRouter",[338,967,947],{"class":343},[338,969,950],{"class":375},[338,971,386],{"class":343},[338,973,974],{"class":364},"vue-router",[338,976,392],{"class":343},[338,978,979,981,983,986,988,990,992,995],{"class":340,"line":427},[338,980,376],{"class":375},[338,982,408],{"class":343},[338,984,985],{"class":379}," useProductsStore",[338,987,947],{"class":343},[338,989,950],{"class":375},[338,991,386],{"class":343},[338,993,994],{"class":364},"@/stores/useProductsStore",[338,996,392],{"class":343},[338,998,999,1001,1004,1006,1008,1011],{"class":340,"line":432},[338,1000,376],{"class":375},[338,1002,1003],{"class":379}," ProductsListSection ",[338,1005,383],{"class":375},[338,1007,386],{"class":343},[338,1009,1010],{"class":364},"@/components/pages/products/ProductsListSection.vue",[338,1012,392],{"class":343},[338,1014,1015,1017,1020,1022,1025,1027,1029,1031,1034],{"class":340,"line":444},[338,1016,376],{"class":375},[338,1018,1019],{"class":375}," type",[338,1021,408],{"class":343},[338,1023,1024],{"class":379}," Product",[338,1026,947],{"class":343},[338,1028,950],{"class":375},[338,1030,386],{"class":343},[338,1032,1033],{"class":364},"@/types",[338,1035,392],{"class":343},[338,1037,1038],{"class":340,"line":471},[338,1039,399],{"emptyLinePlaceholder":398},[338,1041,1042],{"class":340,"line":493},[338,1043,1044],{"class":738},"// 2. Локальные константы\n",[338,1046,1047,1049,1052,1054],{"class":340,"line":500},[338,1048,405],{"class":351},[338,1050,1051],{"class":379}," PAGE_SIZE ",[338,1053,358],{"class":343},[338,1055,1057],{"class":1056},"sbssI"," 20\n",[338,1059,1060],{"class":340,"line":505},[338,1061,399],{"emptyLinePlaceholder":398},[338,1063,1064],{"class":340,"line":515},[338,1065,1066],{"class":738},"// 3. Props и Emits\n",[338,1068,1069,1071,1074,1076,1079],{"class":340,"line":530},[338,1070,405],{"class":351},[338,1072,1073],{"class":379}," props ",[338,1075,358],{"class":343},[338,1077,1078],{"class":420}," defineProps",[338,1080,1081],{"class":343},"<{\n",[338,1083,1084,1087,1090],{"class":340,"line":537},[338,1085,1086],{"class":347}," initialFilter",[338,1088,1089],{"class":343},"?:",[338,1091,1093],{"class":1092},"sBMFI"," string\n",[338,1095,1096,1099],{"class":340,"line":547},[338,1097,1098],{"class":343},"}>",[338,1100,424],{"class":379},[338,1102,1103],{"class":340,"line":552},[338,1104,399],{"emptyLinePlaceholder":398},[338,1106,1107],{"class":340,"line":562},[338,1108,1109],{"class":738},"// 4. Глобальные утилиты\n",[338,1111,1112,1114,1117,1119,1121],{"class":340,"line":574},[338,1113,405],{"class":351},[338,1115,1116],{"class":379}," router ",[338,1118,358],{"class":343},[338,1120,965],{"class":420},[338,1122,424],{"class":379},[338,1124,1126],{"class":340,"line":1125},19,[338,1127,399],{"emptyLinePlaceholder":398},[338,1129,1131],{"class":340,"line":1130},20,[338,1132,1133],{"class":738},"// 5. Сторы\n",[338,1135,1137,1139,1142,1144,1146],{"class":340,"line":1136},21,[338,1138,405],{"class":351},[338,1140,1141],{"class":379}," productsStore ",[338,1143,358],{"class":343},[338,1145,985],{"class":420},[338,1147,424],{"class":379},[338,1149,1151],{"class":340,"line":1150},22,[338,1152,399],{"emptyLinePlaceholder":398},[338,1154,1156],{"class":340,"line":1155},23,[338,1157,1158],{"class":738},"// 6. Реактивное состояние\n",[338,1160,1162,1164,1167,1169,1171,1173,1176],{"class":340,"line":1161},24,[338,1163,405],{"class":351},[338,1165,1166],{"class":379}," searchQuery ",[338,1168,358],{"class":343},[338,1170,944],{"class":420},[338,1172,438],{"class":379},[338,1174,1175],{"class":343},"\"\"",[338,1177,490],{"class":379},[338,1179,1181],{"class":340,"line":1180},25,[338,1182,399],{"emptyLinePlaceholder":398},[338,1184,1186],{"class":340,"line":1185},26,[338,1187,1188],{"class":738},"// 7. Computed\n",[338,1190,1192,1194,1197,1199,1201,1203,1206],{"class":340,"line":1191},27,[338,1193,405],{"class":351},[338,1195,1196],{"class":379}," filteredProducts ",[338,1198,358],{"class":343},[338,1200,934],{"class":420},[338,1202,438],{"class":379},[338,1204,1205],{"class":343},"()",[338,1207,1208],{"class":351}," =>\n",[338,1210,1212,1215,1217,1220,1222,1225,1227,1229,1232,1234],{"class":340,"line":1211},28,[338,1213,1214],{"class":379}," productsStore",[338,1216,237],{"class":343},[338,1218,1219],{"class":379},"products",[338,1221,237],{"class":343},[338,1223,1224],{"class":420},"filter",[338,1226,438],{"class":379},[338,1228,438],{"class":343},[338,1230,140],{"class":1231},"sHdIc",[338,1233,465],{"class":343},[338,1235,1208],{"class":351},[338,1237,1239,1242,1244,1247,1249,1252,1254,1256,1259,1262,1264,1267,1269,1271],{"class":340,"line":1238},29,[338,1240,1241],{"class":379}," p",[338,1243,237],{"class":343},[338,1245,1246],{"class":379},"name",[338,1248,237],{"class":343},[338,1250,1251],{"class":420},"toLowerCase",[338,1253,1205],{"class":379},[338,1255,237],{"class":343},[338,1257,1258],{"class":420},"includes",[338,1260,1261],{"class":379},"(searchQuery",[338,1263,237],{"class":343},[338,1265,1266],{"class":379},"value",[338,1268,237],{"class":343},[338,1270,1251],{"class":420},[338,1272,1273],{"class":379},"())\n",[338,1275,1277],{"class":340,"line":1276},30,[338,1278,1279],{"class":379}," )\n",[338,1281,1283],{"class":340,"line":1282},31,[338,1284,490],{"class":379},[338,1286,1288],{"class":340,"line":1287},32,[338,1289,399],{"emptyLinePlaceholder":398},[338,1291,1293],{"class":340,"line":1292},33,[338,1294,1295],{"class":738},"// 8. Функции\n",[338,1297,1299,1301,1304,1306,1309,1312,1314,1316,1318,1321],{"class":340,"line":1298},34,[338,1300,405],{"class":351},[338,1302,1303],{"class":379}," handleProductClick ",[338,1305,358],{"class":343},[338,1307,1308],{"class":343}," (",[338,1310,1311],{"class":1231},"product",[338,1313,450],{"class":343},[338,1315,1024],{"class":1092},[338,1317,465],{"class":343},[338,1319,1320],{"class":351}," =>",[338,1322,1323],{"class":343}," {\n",[338,1325,1327,1330,1332,1335,1337,1340,1343,1346,1348,1350,1353,1356],{"class":340,"line":1326},35,[338,1328,1329],{"class":379}," router",[338,1331,237],{"class":343},[338,1333,1334],{"class":420},"push",[338,1336,438],{"class":347},[338,1338,1339],{"class":343},"`",[338,1341,1342],{"class":364},"/products/",[338,1344,1345],{"class":343},"${",[338,1347,1311],{"class":379},[338,1349,237],{"class":343},[338,1351,1352],{"class":379},"id",[338,1354,1355],{"class":343},"}`",[338,1357,490],{"class":347},[338,1359,1361],{"class":340,"line":1360},36,[338,1362,1363],{"class":343},"}\n",[338,1365,1367],{"class":340,"line":1366},37,[338,1368,399],{"emptyLinePlaceholder":398},[338,1370,1372],{"class":340,"line":1371},38,[338,1373,1374],{"class":738},"// 9. Хуки жизненного цикла\n",[338,1376,1378,1380,1382,1385,1388,1390],{"class":340,"line":1377},39,[338,1379,146],{"class":420},[338,1381,438],{"class":379},[338,1383,1384],{"class":351},"async",[338,1386,1387],{"class":343}," ()",[338,1389,1320],{"class":351},[338,1391,1323],{"class":343},[338,1393,1395,1398,1401,1403,1406],{"class":340,"line":1394},40,[338,1396,1397],{"class":375}," await",[338,1399,1400],{"class":379}," productsStore",[338,1402,237],{"class":343},[338,1404,1405],{"class":420},"fetchProducts",[338,1407,424],{"class":347},[338,1409,1411,1413],{"class":340,"line":1410},41,[338,1412,414],{"class":343},[338,1414,490],{"class":379},[338,1416,1418,1420,1422],{"class":340,"line":1417},42,[338,1419,540],{"class":343},[338,1421,348],{"class":347},[338,1423,370],{"class":343},[1425,1426,1427],"blockquote",{},[140,1428,1429,1430,216,1433,216,1436,1439],{},"У меня остался некий ПТСР на эту тему из-за Vue 2 Options API, где логика была раскидана по разным секциям — ",[144,1431,1432],{},"data",[144,1434,1435],{},"methods",[144,1437,1438],{},"computed",". Composition API к этому не обязывает, но привычка явно обозначать порядок осталась.",[156,1441],{},[159,1443,1445],{"id":1444},"часть-iv-инструменты","Часть IV. Инструменты",[164,1447,1449],{"id":1448},"роутинг-разбиваем-по-фичам","Роутинг: разбиваем по фичам",[140,1451,1452,1453,1456],{},"Все роуты в одном файле — это ловушка. Проект чуть вырастает, и ",[144,1454,1455],{},"router/index.ts"," превращается в простыню на 300 строк, в которой никто не хочет разбираться.",[140,1458,1459],{},"Я выношу роуты каждой фичи в отдельный файл:",[172,1461,1465],{"className":1462,"code":1463,"filename":1464,"language":365,"meta":180,"style":180},"language-ts shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","import type { RouteRecordRaw } from \"vue-router\"\n\nexport const productsRoutes: RouteRecordRaw[] = [\n {\n path: \"/\",\n component: () => import(\"@/layouts/default.vue\"),\n children: [\n {\n path: \"/products\",\n name: \"products\",\n component: () => import(\"@/views/ProductsView.vue\")\n },\n {\n path: \"/products/:id\",\n name: \"product-detail\",\n component: () => import(\"@/views/ProductDetailView.vue\")\n }\n ]\n }\n]\n","router/productsRoutes.ts",[144,1466,1467,1488,1492,1515,1520,1536,1563,1572,1577,1593,1608,1632,1637,1641,1656,1671,1694,1699,1704,1709],{"__ignoreMap":180},[338,1468,1469,1471,1473,1475,1478,1480,1482,1484,1486],{"class":340,"line":124},[338,1470,376],{"class":375},[338,1472,1019],{"class":375},[338,1474,408],{"class":343},[338,1476,1477],{"class":379}," RouteRecordRaw",[338,1479,947],{"class":343},[338,1481,950],{"class":375},[338,1483,386],{"class":343},[338,1485,974],{"class":364},[338,1487,392],{"class":343},[338,1489,1490],{"class":340,"line":6},[338,1491,399],{"emptyLinePlaceholder":398},[338,1493,1494,1497,1500,1503,1505,1507,1510,1512],{"class":340,"line":395},[338,1495,1496],{"class":375},"export",[338,1498,1499],{"class":351}," const",[338,1501,1502],{"class":379}," productsRoutes",[338,1504,450],{"class":343},[338,1506,1477],{"class":1092},[338,1508,1509],{"class":379},"[] ",[338,1511,358],{"class":343},[338,1513,1514],{"class":379}," [\n",[338,1516,1517],{"class":340,"line":402},[338,1518,1519],{"class":343}," {\n",[338,1521,1522,1525,1527,1529,1532,1534],{"class":340,"line":427},[338,1523,1524],{"class":347}," path",[338,1526,450],{"class":343},[338,1528,386],{"class":343},[338,1530,1531],{"class":364},"/",[338,1533,361],{"class":343},[338,1535,468],{"class":343},[338,1537,1538,1541,1543,1545,1547,1550,1552,1554,1557,1559,1561],{"class":340,"line":432},[338,1539,1540],{"class":420}," component",[338,1542,450],{"class":343},[338,1544,1387],{"class":343},[338,1546,1320],{"class":351},[338,1548,1549],{"class":343}," import",[338,1551,438],{"class":379},[338,1553,361],{"class":343},[338,1555,1556],{"class":364},"@/layouts/default.vue",[338,1558,361],{"class":343},[338,1560,465],{"class":379},[338,1562,468],{"class":343},[338,1564,1565,1568,1570],{"class":340,"line":444},[338,1566,1567],{"class":347}," children",[338,1569,450],{"class":343},[338,1571,1514],{"class":379},[338,1573,1574],{"class":340,"line":471},[338,1575,1576],{"class":343}," {\n",[338,1578,1579,1582,1584,1586,1589,1591],{"class":340,"line":493},[338,1580,1581],{"class":347}," path",[338,1583,450],{"class":343},[338,1585,386],{"class":343},[338,1587,1588],{"class":364},"/products",[338,1590,361],{"class":343},[338,1592,468],{"class":343},[338,1594,1595,1598,1600,1602,1604,1606],{"class":340,"line":500},[338,1596,1597],{"class":347}," name",[338,1599,450],{"class":343},[338,1601,386],{"class":343},[338,1603,1219],{"class":364},[338,1605,361],{"class":343},[338,1607,468],{"class":343},[338,1609,1610,1613,1615,1617,1619,1621,1623,1625,1628,1630],{"class":340,"line":505},[338,1611,1612],{"class":420}," component",[338,1614,450],{"class":343},[338,1616,1387],{"class":343},[338,1618,1320],{"class":351},[338,1620,1549],{"class":343},[338,1622,438],{"class":379},[338,1624,361],{"class":343},[338,1626,1627],{"class":364},"@/views/ProductsView.vue",[338,1629,361],{"class":343},[338,1631,490],{"class":379},[338,1633,1634],{"class":340,"line":515},[338,1635,1636],{"class":343}," },\n",[338,1638,1639],{"class":340,"line":530},[338,1640,1576],{"class":343},[338,1642,1643,1645,1647,1649,1652,1654],{"class":340,"line":537},[338,1644,1581],{"class":347},[338,1646,450],{"class":343},[338,1648,386],{"class":343},[338,1650,1651],{"class":364},"/products/:id",[338,1653,361],{"class":343},[338,1655,468],{"class":343},[338,1657,1658,1660,1662,1664,1667,1669],{"class":340,"line":547},[338,1659,1597],{"class":347},[338,1661,450],{"class":343},[338,1663,386],{"class":343},[338,1665,1666],{"class":364},"product-detail",[338,1668,361],{"class":343},[338,1670,468],{"class":343},[338,1672,1673,1675,1677,1679,1681,1683,1685,1687,1690,1692],{"class":340,"line":552},[338,1674,1612],{"class":420},[338,1676,450],{"class":343},[338,1678,1387],{"class":343},[338,1680,1320],{"class":351},[338,1682,1549],{"class":343},[338,1684,438],{"class":379},[338,1686,361],{"class":343},[338,1688,1689],{"class":364},"@/views/ProductDetailView.vue",[338,1691,361],{"class":343},[338,1693,490],{"class":379},[338,1695,1696],{"class":340,"line":562},[338,1697,1698],{"class":343}," }\n",[338,1700,1701],{"class":340,"line":574},[338,1702,1703],{"class":379}," ]\n",[338,1705,1706],{"class":340,"line":1125},[338,1707,1708],{"class":343}," }\n",[338,1710,1711],{"class":340,"line":1130},[338,1712,1713],{"class":379},"]\n",[140,1715,1716,1717,1719],{},"А ",[144,1718,1455],{}," просто собирает их вместе:",[172,1721,1723],{"className":1462,"code":1722,"filename":1455,"language":365,"meta":180,"style":180},"import { createRouter, createWebHistory } from \"vue-router\"\nimport { productsRoutes } from \"@/router/productsRoutes\"\nimport { usersRoutes } from \"@/router/usersRoutes\"\n\nexport const router = createRouter({\n history: createWebHistory(),\n routes: [...productsRoutes, ...usersRoutes]\n})\n",[144,1724,1725,1749,1768,1788,1792,1808,1821,1845],{"__ignoreMap":180},[338,1726,1727,1729,1731,1734,1736,1739,1741,1743,1745,1747],{"class":340,"line":124},[338,1728,376],{"class":375},[338,1730,408],{"class":343},[338,1732,1733],{"class":379}," createRouter",[338,1735,872],{"class":343},[338,1737,1738],{"class":379}," createWebHistory",[338,1740,947],{"class":343},[338,1742,950],{"class":375},[338,1744,386],{"class":343},[338,1746,974],{"class":364},[338,1748,392],{"class":343},[338,1750,1751,1753,1755,1757,1759,1761,1763,1766],{"class":340,"line":6},[338,1752,376],{"class":375},[338,1754,408],{"class":343},[338,1756,1502],{"class":379},[338,1758,947],{"class":343},[338,1760,950],{"class":375},[338,1762,386],{"class":343},[338,1764,1765],{"class":364},"@/router/productsRoutes",[338,1767,392],{"class":343},[338,1769,1770,1772,1774,1777,1779,1781,1783,1786],{"class":340,"line":395},[338,1771,376],{"class":375},[338,1773,408],{"class":343},[338,1775,1776],{"class":379}," usersRoutes",[338,1778,947],{"class":343},[338,1780,950],{"class":375},[338,1782,386],{"class":343},[338,1784,1785],{"class":364},"@/router/usersRoutes",[338,1787,392],{"class":343},[338,1789,1790],{"class":340,"line":402},[338,1791,399],{"emptyLinePlaceholder":398},[338,1793,1794,1796,1798,1800,1802,1804,1806],{"class":340,"line":427},[338,1795,1496],{"class":375},[338,1797,1499],{"class":351},[338,1799,1116],{"class":379},[338,1801,358],{"class":343},[338,1803,1733],{"class":420},[338,1805,438],{"class":379},[338,1807,441],{"class":343},[338,1809,1810,1813,1815,1817,1819],{"class":340,"line":432},[338,1811,1812],{"class":347}," history",[338,1814,450],{"class":343},[338,1816,1738],{"class":420},[338,1818,1205],{"class":379},[338,1820,468],{"class":343},[338,1822,1823,1826,1828,1831,1834,1837,1839,1842],{"class":340,"line":444},[338,1824,1825],{"class":347}," routes",[338,1827,450],{"class":343},[338,1829,1830],{"class":379}," [",[338,1832,1833],{"class":343},"...",[338,1835,1836],{"class":379},"productsRoutes",[338,1838,872],{"class":343},[338,1840,1841],{"class":343}," ...",[338,1843,1844],{"class":379},"usersRoutes]\n",[338,1846,1847,1849],{"class":340,"line":471},[338,1848,414],{"class":343},[338,1850,490],{"class":379},[140,1852,1853,1854,237],{},"Добавление новой фичи — это создание одного файла и одна строчка в ",[144,1855,1856],{},"index.ts",[164,1858,1860],{"id":1859},"сторы-только-setup-stores","Сторы: только Setup Stores",[140,1862,1863],{},"Я использую исключительно Setup Stores — не Options API-вариант. Они ближе к composables, лучше типизируются и проще тестируются.",[172,1865,1868],{"className":1462,"code":1866,"filename":1867,"language":365,"meta":180,"style":180},"import { ref } from \"vue\"\nimport { defineStore } from \"pinia\"\nimport { ApiClient } from \"@/services/client\"\nimport type { Product } from \"@/types\"\n\nexport const useProductsStore = defineStore(\"products\", () => {\n const products = ref([])\n const isLoading = ref(false)\n\n const fetchProducts = async () => {\n isLoading.value = true\n try {\n products.value = await ApiClient.products.getProducts()\n } finally {\n isLoading.value = false\n }\n }\n\n return { products, isLoading, fetchProducts }\n})\n","stores/useProductsStore.ts",[144,1869,1870,1888,1908,1928,1948,1952,1981,2005,2024,2028,2046,2060,2067,2094,2104,2118,2123,2127,2131,2151],{"__ignoreMap":180},[338,1871,1872,1874,1876,1878,1880,1882,1884,1886],{"class":340,"line":124},[338,1873,376],{"class":375},[338,1875,408],{"class":343},[338,1877,944],{"class":379},[338,1879,947],{"class":343},[338,1881,950],{"class":375},[338,1883,386],{"class":343},[338,1885,89],{"class":364},[338,1887,392],{"class":343},[338,1889,1890,1892,1894,1897,1899,1901,1903,1906],{"class":340,"line":6},[338,1891,376],{"class":375},[338,1893,408],{"class":343},[338,1895,1896],{"class":379}," defineStore",[338,1898,947],{"class":343},[338,1900,950],{"class":375},[338,1902,386],{"class":343},[338,1904,1905],{"class":364},"pinia",[338,1907,392],{"class":343},[338,1909,1910,1912,1914,1917,1919,1921,1923,1926],{"class":340,"line":395},[338,1911,376],{"class":375},[338,1913,408],{"class":343},[338,1915,1916],{"class":379}," ApiClient",[338,1918,947],{"class":343},[338,1920,950],{"class":375},[338,1922,386],{"class":343},[338,1924,1925],{"class":364},"@/services/client",[338,1927,392],{"class":343},[338,1929,1930,1932,1934,1936,1938,1940,1942,1944,1946],{"class":340,"line":402},[338,1931,376],{"class":375},[338,1933,1019],{"class":375},[338,1935,408],{"class":343},[338,1937,1024],{"class":379},[338,1939,947],{"class":343},[338,1941,950],{"class":375},[338,1943,386],{"class":343},[338,1945,1033],{"class":364},[338,1947,392],{"class":343},[338,1949,1950],{"class":340,"line":427},[338,1951,399],{"emptyLinePlaceholder":398},[338,1953,1954,1956,1958,1961,1963,1965,1967,1969,1971,1973,1975,1977,1979],{"class":340,"line":432},[338,1955,1496],{"class":375},[338,1957,1499],{"class":351},[338,1959,1960],{"class":379}," useProductsStore ",[338,1962,358],{"class":343},[338,1964,1896],{"class":420},[338,1966,438],{"class":379},[338,1968,361],{"class":343},[338,1970,1219],{"class":364},[338,1972,361],{"class":343},[338,1974,872],{"class":343},[338,1976,1387],{"class":343},[338,1978,1320],{"class":351},[338,1980,1323],{"class":343},[338,1982,1983,1986,1989,1991,1993,1995,1997,2000,2002],{"class":340,"line":444},[338,1984,1985],{"class":351}," const",[338,1987,1988],{"class":379}," products",[338,1990,417],{"class":343},[338,1992,944],{"class":420},[338,1994,344],{"class":343},[338,1996,229],{"class":1092},[338,1998,1999],{"class":347},"[]",[338,2001,749],{"class":343},[338,2003,2004],{"class":347},"([])\n",[338,2006,2007,2009,2012,2014,2016,2018,2022],{"class":340,"line":471},[338,2008,1985],{"class":351},[338,2010,2011],{"class":379}," isLoading",[338,2013,417],{"class":343},[338,2015,944],{"class":420},[338,2017,438],{"class":347},[338,2019,2021],{"class":2020},"sfNiH","false",[338,2023,490],{"class":347},[338,2025,2026],{"class":340,"line":493},[338,2027,399],{"emptyLinePlaceholder":398},[338,2029,2030,2032,2035,2037,2040,2042,2044],{"class":340,"line":500},[338,2031,1985],{"class":351},[338,2033,2034],{"class":379}," fetchProducts",[338,2036,417],{"class":343},[338,2038,2039],{"class":351}," async",[338,2041,1387],{"class":343},[338,2043,1320],{"class":351},[338,2045,1323],{"class":343},[338,2047,2048,2051,2053,2055,2057],{"class":340,"line":505},[338,2049,2050],{"class":379}," isLoading",[338,2052,237],{"class":343},[338,2054,1266],{"class":379},[338,2056,417],{"class":343},[338,2058,2059],{"class":2020}," true\n",[338,2061,2062,2065],{"class":340,"line":515},[338,2063,2064],{"class":375}," try",[338,2066,1323],{"class":343},[338,2068,2069,2072,2074,2076,2078,2081,2083,2085,2087,2089,2092],{"class":340,"line":530},[338,2070,2071],{"class":379}," products",[338,2073,237],{"class":343},[338,2075,1266],{"class":379},[338,2077,417],{"class":343},[338,2079,2080],{"class":375}," await",[338,2082,1916],{"class":379},[338,2084,237],{"class":343},[338,2086,1219],{"class":379},[338,2088,237],{"class":343},[338,2090,2091],{"class":420},"getProducts",[338,2093,424],{"class":347},[338,2095,2096,2099,2102],{"class":340,"line":537},[338,2097,2098],{"class":343}," }",[338,2100,2101],{"class":375}," finally",[338,2103,1323],{"class":343},[338,2105,2106,2109,2111,2113,2115],{"class":340,"line":547},[338,2107,2108],{"class":379}," isLoading",[338,2110,237],{"class":343},[338,2112,1266],{"class":379},[338,2114,417],{"class":343},[338,2116,2117],{"class":2020}," false\n",[338,2119,2120],{"class":340,"line":552},[338,2121,2122],{"class":343}," }\n",[338,2124,2125],{"class":340,"line":562},[338,2126,1708],{"class":343},[338,2128,2129],{"class":340,"line":574},[338,2130,399],{"emptyLinePlaceholder":398},[338,2132,2133,2136,2138,2140,2142,2144,2146,2148],{"class":340,"line":1125},[338,2134,2135],{"class":375}," return",[338,2137,408],{"class":343},[338,2139,1988],{"class":379},[338,2141,872],{"class":343},[338,2143,2011],{"class":379},[338,2145,872],{"class":343},[338,2147,2034],{"class":379},[338,2149,2150],{"class":343}," }\n",[338,2152,2153,2155],{"class":340,"line":1130},[338,2154,414],{"class":343},[338,2156,490],{"class":379},[164,2158,2160],{"id":2159},"валидация-схемы-отдельно-от-компонентов","Валидация: схемы отдельно от компонентов",[140,2162,2163,2164,2167,2168,2171,2172,237],{},"Когда валидация живёт прямо в компоненте — это боль при масштабировании. Я выношу все схемы в ",[144,2165,2166],{},"src/validation/"," и использую ",[144,2169,2170],{},"yup"," + ",[144,2173,2174],{},"vee-validate",[140,2176,2177,2178,2181],{},"Схема — это всегда функция, а не объект. Это позволяет использовать ",[144,2179,2180],{},"useI18n()"," внутри и получать переведённые сообщения об ошибках:",[172,2183,2186],{"className":1462,"code":2184,"filename":2185,"language":365,"meta":180,"style":180},"import { number, object, string } from \"yup\"\n\nexport default function schema() {\n const { t } = useI18n()\n\n return object({\n name: string().required(t(\"Название обязательно\")).min(3, t(\"Минимум 3 символа\")),\n price: number().required(t(\"Цена обязательна\")).positive(t(\"Цена должна быть положительной\"))\n })\n}\n","validation/productSchema.ts",[144,2187,2188,2217,2221,2238,2254,2258,2268,2328,2379,2386],{"__ignoreMap":180},[338,2189,2190,2192,2194,2197,2199,2202,2204,2207,2209,2211,2213,2215],{"class":340,"line":124},[338,2191,376],{"class":375},[338,2193,408],{"class":343},[338,2195,2196],{"class":379}," number",[338,2198,872],{"class":343},[338,2200,2201],{"class":379}," object",[338,2203,872],{"class":343},[338,2205,2206],{"class":379}," string",[338,2208,947],{"class":343},[338,2210,950],{"class":375},[338,2212,386],{"class":343},[338,2214,2170],{"class":364},[338,2216,392],{"class":343},[338,2218,2219],{"class":340,"line":6},[338,2220,399],{"emptyLinePlaceholder":398},[338,2222,2223,2225,2228,2231,2234,2236],{"class":340,"line":395},[338,2224,1496],{"class":375},[338,2226,2227],{"class":375}," default",[338,2229,2230],{"class":351}," function",[338,2232,2233],{"class":420}," schema",[338,2235,1205],{"class":343},[338,2237,1323],{"class":343},[338,2239,2240,2242,2244,2246,2248,2250,2252],{"class":340,"line":402},[338,2241,1985],{"class":351},[338,2243,408],{"class":343},[338,2245,453],{"class":379},[338,2247,947],{"class":343},[338,2249,417],{"class":343},[338,2251,421],{"class":420},[338,2253,424],{"class":347},[338,2255,2256],{"class":340,"line":427},[338,2257,399],{"emptyLinePlaceholder":398},[338,2259,2260,2262,2264,2266],{"class":340,"line":432},[338,2261,2135],{"class":375},[338,2263,2201],{"class":420},[338,2265,438],{"class":347},[338,2267,441],{"class":343},[338,2269,2270,2273,2275,2277,2279,2281,2284,2286,2289,2291,2293,2296,2298,2301,2303,2306,2308,2311,2313,2315,2317,2319,2322,2324,2326],{"class":340,"line":444},[338,2271,2272],{"class":347}," name",[338,2274,450],{"class":343},[338,2276,2206],{"class":420},[338,2278,1205],{"class":347},[338,2280,237],{"class":343},[338,2282,2283],{"class":420},"required",[338,2285,438],{"class":347},[338,2287,2288],{"class":420},"t",[338,2290,438],{"class":347},[338,2292,361],{"class":343},[338,2294,2295],{"class":364},"Название обязательно",[338,2297,361],{"class":343},[338,2299,2300],{"class":347},"))",[338,2302,237],{"class":343},[338,2304,2305],{"class":420},"min",[338,2307,438],{"class":347},[338,2309,2310],{"class":1056},"3",[338,2312,872],{"class":343},[338,2314,453],{"class":420},[338,2316,438],{"class":347},[338,2318,361],{"class":343},[338,2320,2321],{"class":364},"Минимум 3 символа",[338,2323,361],{"class":343},[338,2325,2300],{"class":347},[338,2327,468],{"class":343},[338,2329,2330,2333,2335,2337,2339,2341,2343,2345,2347,2349,2351,2354,2356,2358,2360,2363,2365,2367,2369,2371,2374,2376],{"class":340,"line":471},[338,2331,2332],{"class":347}," price",[338,2334,450],{"class":343},[338,2336,2196],{"class":420},[338,2338,1205],{"class":347},[338,2340,237],{"class":343},[338,2342,2283],{"class":420},[338,2344,438],{"class":347},[338,2346,2288],{"class":420},[338,2348,438],{"class":347},[338,2350,361],{"class":343},[338,2352,2353],{"class":364},"Цена обязательна",[338,2355,361],{"class":343},[338,2357,2300],{"class":347},[338,2359,237],{"class":343},[338,2361,2362],{"class":420},"positive",[338,2364,438],{"class":347},[338,2366,2288],{"class":420},[338,2368,438],{"class":347},[338,2370,361],{"class":343},[338,2372,2373],{"class":364},"Цена должна быть положительной",[338,2375,361],{"class":343},[338,2377,2378],{"class":347},"))\n",[338,2380,2381,2384],{"class":340,"line":493},[338,2382,2383],{"class":343}," }",[338,2385,490],{"class":347},[338,2387,2388],{"class":340,"line":500},[338,2389,1363],{"class":343},[140,2391,2392],{},"В компоненте:",[172,2394,2397],{"className":332,"code":2395,"filename":2396,"language":89,"meta":180,"style":180},"\n","components/pages/products/ProductsCreateSection.vue",[144,2398,2399,2419,2438,2454,2458,2482,2493,2499,2503,2529,2550,2556],{"__ignoreMap":180},[338,2400,2401,2403,2405,2407,2409,2411,2413,2415,2417],{"class":340,"line":124},[338,2402,344],{"class":343},[338,2404,348],{"class":347},[338,2406,352],{"class":351},[338,2408,355],{"class":351},[338,2410,358],{"class":343},[338,2412,361],{"class":343},[338,2414,365],{"class":364},[338,2416,361],{"class":343},[338,2418,370],{"class":343},[338,2420,2421,2423,2425,2428,2430,2432,2434,2436],{"class":340,"line":6},[338,2422,376],{"class":375},[338,2424,408],{"class":343},[338,2426,2427],{"class":379}," useForm",[338,2429,947],{"class":343},[338,2431,950],{"class":375},[338,2433,386],{"class":343},[338,2435,2174],{"class":364},[338,2437,392],{"class":343},[338,2439,2440,2442,2445,2447,2449,2452],{"class":340,"line":395},[338,2441,376],{"class":375},[338,2443,2444],{"class":379}," schema ",[338,2446,383],{"class":375},[338,2448,386],{"class":343},[338,2450,2451],{"class":364},"@/validation/productSchema",[338,2453,392],{"class":343},[338,2455,2456],{"class":340,"line":402},[338,2457,399],{"emptyLinePlaceholder":398},[338,2459,2460,2462,2464,2467,2469,2472,2474,2476,2478,2480],{"class":340,"line":427},[338,2461,405],{"class":351},[338,2463,408],{"class":343},[338,2465,2466],{"class":379}," handleSubmit",[338,2468,872],{"class":343},[338,2470,2471],{"class":379}," errors ",[338,2473,414],{"class":343},[338,2475,417],{"class":343},[338,2477,2427],{"class":420},[338,2479,438],{"class":379},[338,2481,441],{"class":343},[338,2483,2484,2487,2489,2491],{"class":340,"line":432},[338,2485,2486],{"class":347}," validationSchema",[338,2488,450],{"class":343},[338,2490,2233],{"class":420},[338,2492,424],{"class":379},[338,2494,2495,2497],{"class":340,"line":444},[338,2496,414],{"class":343},[338,2498,490],{"class":379},[338,2500,2501],{"class":340,"line":471},[338,2502,399],{"emptyLinePlaceholder":398},[338,2504,2505,2507,2510,2512,2514,2516,2518,2520,2523,2525,2527],{"class":340,"line":493},[338,2506,405],{"class":351},[338,2508,2509],{"class":379}," handleCreate ",[338,2511,358],{"class":343},[338,2513,2466],{"class":420},[338,2515,438],{"class":379},[338,2517,1384],{"class":351},[338,2519,1308],{"class":343},[338,2521,2522],{"class":1231},"values",[338,2524,465],{"class":343},[338,2526,1320],{"class":351},[338,2528,1323],{"class":343},[338,2530,2531,2533,2535,2537,2539,2541,2544,2546,2548],{"class":340,"line":500},[338,2532,1397],{"class":375},[338,2534,1916],{"class":379},[338,2536,237],{"class":343},[338,2538,1219],{"class":379},[338,2540,237],{"class":343},[338,2542,2543],{"class":420},"createProduct",[338,2545,438],{"class":347},[338,2547,2522],{"class":379},[338,2549,490],{"class":347},[338,2551,2552,2554],{"class":340,"line":505},[338,2553,414],{"class":343},[338,2555,490],{"class":379},[338,2557,2558,2560,2562],{"class":340,"line":515},[338,2559,540],{"class":343},[338,2561,348],{"class":347},[338,2563,370],{"class":343},[164,2565,2567],{"id":2566},"сервисный-слой","Сервисный слой",[140,2569,2570,2571,2574,2575,2578,2579,237],{},"API-клиент и организация запросов — отдельная большая тема. Если коротко: всё живёт в ",[144,2572,2573],{},"services/",", ни один компонент не знает про ",[144,2576,2577],{},"fetch"," напрямую, а вся типизация сосредоточена в доменных модулях. Подробно я разобрал это в статье ",[2580,2581,2583],"a",{"href":2582},"/docs/guides/articles/services","«Чистый API-клиент»",[156,2585],{},[159,2587,2589],{"id":2588},"сквозной-пример-страница-products","Сквозной пример: страница Products",[140,2591,2592],{},"Посмотрим, как все эти слои работают вместе — полный путь от роута до шаблона.",[140,2594,2595,2598,2599,450],{},[206,2596,2597],{},"1. Роут"," знает только про ",[144,2600,2601],{},"View",[172,2603,2605],{"className":1462,"code":2604,"filename":1464,"language":365,"meta":180,"style":180},"{\n path: \"/products\",\n name: \"products\",\n component: () => import(\"@/views/ProductsView.vue\")\n}\n",[144,2606,2607,2611,2626,2641,2664],{"__ignoreMap":180},[338,2608,2609],{"class":340,"line":124},[338,2610,441],{"class":343},[338,2612,2613,2616,2618,2620,2622,2624],{"class":340,"line":6},[338,2614,2615],{"class":1092}," path",[338,2617,450],{"class":343},[338,2619,386],{"class":343},[338,2621,1588],{"class":364},[338,2623,361],{"class":343},[338,2625,468],{"class":343},[338,2627,2628,2631,2633,2635,2637,2639],{"class":340,"line":395},[338,2629,2630],{"class":1092}," name",[338,2632,450],{"class":343},[338,2634,386],{"class":343},[338,2636,1219],{"class":364},[338,2638,361],{"class":343},[338,2640,468],{"class":343},[338,2642,2643,2646,2648,2650,2652,2654,2656,2658,2660,2662],{"class":340,"line":402},[338,2644,2645],{"class":1092}," component",[338,2647,450],{"class":343},[338,2649,1387],{"class":343},[338,2651,1320],{"class":351},[338,2653,1549],{"class":343},[338,2655,438],{"class":347},[338,2657,361],{"class":343},[338,2659,1627],{"class":364},[338,2661,361],{"class":343},[338,2663,490],{"class":347},[338,2665,2666],{"class":340,"line":427},[338,2667,1363],{"class":343},[140,2669,2670,2673],{},[206,2671,2672],{},"2. View"," — тонкая обёртка:",[172,2675,2678],{"className":332,"code":2676,"filename":2677,"language":89,"meta":180,"style":180},"\n\n\n","views/ProductsView.vue",[144,2679,2680,2700,2716,2724,2728,2736,2745],{"__ignoreMap":180},[338,2681,2682,2684,2686,2688,2690,2692,2694,2696,2698],{"class":340,"line":124},[338,2683,344],{"class":343},[338,2685,348],{"class":347},[338,2687,352],{"class":351},[338,2689,355],{"class":351},[338,2691,358],{"class":343},[338,2693,361],{"class":343},[338,2695,365],{"class":364},[338,2697,361],{"class":343},[338,2699,370],{"class":343},[338,2701,2702,2704,2707,2709,2711,2714],{"class":340,"line":6},[338,2703,376],{"class":375},[338,2705,2706],{"class":379}," ProductsPage ",[338,2708,383],{"class":375},[338,2710,386],{"class":343},[338,2712,2713],{"class":364},"@/components/pages/products/ProductsPage.vue",[338,2715,392],{"class":343},[338,2717,2718,2720,2722],{"class":340,"line":395},[338,2719,540],{"class":343},[338,2721,348],{"class":347},[338,2723,370],{"class":343},[338,2725,2726],{"class":340,"line":402},[338,2727,399],{"emptyLinePlaceholder":398},[338,2729,2730,2732,2734],{"class":340,"line":427},[338,2731,344],{"class":343},[338,2733,557],{"class":347},[338,2735,370],{"class":343},[338,2737,2738,2740,2743],{"class":340,"line":432},[338,2739,565],{"class":343},[338,2741,2742],{"class":347},"ProductsPage",[338,2744,571],{"class":343},[338,2746,2747,2749,2751],{"class":340,"line":444},[338,2748,540],{"class":343},[338,2750,557],{"class":347},[338,2752,370],{"class":343},[140,2754,2755,2758],{},[206,2756,2757],{},"3. Page"," — центр управления страницей:",[172,2760,2762],{"className":332,"code":2761,"filename":898,"language":89,"meta":180,"style":180},"\n\n\n",[144,2763,2764,2784,2810,2828,2846,2862,2876,2896,2900,2912,2924,2928,2944,2948,2964,2986,3016,3020,3024,3028,3050,3076,3080,3084,3098,3110,3116,3124,3128,3136,3144,3154,3161,3175,3189,3203,3208,3217],{"__ignoreMap":180},[338,2765,2766,2768,2770,2772,2774,2776,2778,2780,2782],{"class":340,"line":124},[338,2767,344],{"class":343},[338,2769,348],{"class":347},[338,2771,352],{"class":351},[338,2773,355],{"class":351},[338,2775,358],{"class":343},[338,2777,361],{"class":343},[338,2779,365],{"class":364},[338,2781,361],{"class":343},[338,2783,370],{"class":343},[338,2785,2786,2788,2790,2792,2794,2796,2798,2800,2802,2804,2806,2808],{"class":340,"line":6},[338,2787,376],{"class":375},[338,2789,408],{"class":343},[338,2791,934],{"class":379},[338,2793,872],{"class":343},[338,2795,939],{"class":379},[338,2797,872],{"class":343},[338,2799,944],{"class":379},[338,2801,947],{"class":343},[338,2803,950],{"class":375},[338,2805,386],{"class":343},[338,2807,89],{"class":364},[338,2809,392],{"class":343},[338,2811,2812,2814,2816,2818,2820,2822,2824,2826],{"class":340,"line":395},[338,2813,376],{"class":375},[338,2815,408],{"class":343},[338,2817,965],{"class":379},[338,2819,947],{"class":343},[338,2821,950],{"class":375},[338,2823,386],{"class":343},[338,2825,974],{"class":364},[338,2827,392],{"class":343},[338,2829,2830,2832,2834,2836,2838,2840,2842,2844],{"class":340,"line":402},[338,2831,376],{"class":375},[338,2833,408],{"class":343},[338,2835,985],{"class":379},[338,2837,947],{"class":343},[338,2839,950],{"class":375},[338,2841,386],{"class":343},[338,2843,994],{"class":364},[338,2845,392],{"class":343},[338,2847,2848,2850,2853,2855,2857,2860],{"class":340,"line":427},[338,2849,376],{"class":375},[338,2851,2852],{"class":379}," AppHeader ",[338,2854,383],{"class":375},[338,2856,386],{"class":343},[338,2858,2859],{"class":364},"@/components/App/AppHeader.vue",[338,2861,392],{"class":343},[338,2863,2864,2866,2868,2870,2872,2874],{"class":340,"line":432},[338,2865,376],{"class":375},[338,2867,1003],{"class":379},[338,2869,383],{"class":375},[338,2871,386],{"class":343},[338,2873,1010],{"class":364},[338,2875,392],{"class":343},[338,2877,2878,2880,2882,2884,2886,2888,2890,2892,2894],{"class":340,"line":444},[338,2879,376],{"class":375},[338,2881,1019],{"class":375},[338,2883,408],{"class":343},[338,2885,1024],{"class":379},[338,2887,947],{"class":343},[338,2889,950],{"class":375},[338,2891,386],{"class":343},[338,2893,1033],{"class":364},[338,2895,392],{"class":343},[338,2897,2898],{"class":340,"line":471},[338,2899,399],{"emptyLinePlaceholder":398},[338,2901,2902,2904,2906,2908,2910],{"class":340,"line":493},[338,2903,405],{"class":351},[338,2905,1116],{"class":379},[338,2907,358],{"class":343},[338,2909,965],{"class":420},[338,2911,424],{"class":379},[338,2913,2914,2916,2918,2920,2922],{"class":340,"line":500},[338,2915,405],{"class":351},[338,2917,1141],{"class":379},[338,2919,358],{"class":343},[338,2921,985],{"class":420},[338,2923,424],{"class":379},[338,2925,2926],{"class":340,"line":505},[338,2927,399],{"emptyLinePlaceholder":398},[338,2929,2930,2932,2934,2936,2938,2940,2942],{"class":340,"line":515},[338,2931,405],{"class":351},[338,2933,1166],{"class":379},[338,2935,358],{"class":343},[338,2937,944],{"class":420},[338,2939,438],{"class":379},[338,2941,1175],{"class":343},[338,2943,490],{"class":379},[338,2945,2946],{"class":340,"line":530},[338,2947,399],{"emptyLinePlaceholder":398},[338,2949,2950,2952,2954,2956,2958,2960,2962],{"class":340,"line":537},[338,2951,405],{"class":351},[338,2953,1196],{"class":379},[338,2955,358],{"class":343},[338,2957,934],{"class":420},[338,2959,438],{"class":379},[338,2961,1205],{"class":343},[338,2963,1208],{"class":351},[338,2965,2966,2968,2970,2972,2974,2976,2978,2980,2982,2984],{"class":340,"line":547},[338,2967,1214],{"class":379},[338,2969,237],{"class":343},[338,2971,1219],{"class":379},[338,2973,237],{"class":343},[338,2975,1224],{"class":420},[338,2977,438],{"class":379},[338,2979,438],{"class":343},[338,2981,140],{"class":1231},[338,2983,465],{"class":343},[338,2985,1208],{"class":351},[338,2987,2988,2990,2992,2994,2996,2998,3000,3002,3004,3006,3008,3010,3012,3014],{"class":340,"line":552},[338,2989,1241],{"class":379},[338,2991,237],{"class":343},[338,2993,1246],{"class":379},[338,2995,237],{"class":343},[338,2997,1251],{"class":420},[338,2999,1205],{"class":379},[338,3001,237],{"class":343},[338,3003,1258],{"class":420},[338,3005,1261],{"class":379},[338,3007,237],{"class":343},[338,3009,1266],{"class":379},[338,3011,237],{"class":343},[338,3013,1251],{"class":420},[338,3015,1273],{"class":379},[338,3017,3018],{"class":340,"line":562},[338,3019,1279],{"class":379},[338,3021,3022],{"class":340,"line":574},[338,3023,490],{"class":379},[338,3025,3026],{"class":340,"line":1125},[338,3027,399],{"emptyLinePlaceholder":398},[338,3029,3030,3032,3034,3036,3038,3040,3042,3044,3046,3048],{"class":340,"line":1130},[338,3031,405],{"class":351},[338,3033,1303],{"class":379},[338,3035,358],{"class":343},[338,3037,1308],{"class":343},[338,3039,1311],{"class":1231},[338,3041,450],{"class":343},[338,3043,1024],{"class":1092},[338,3045,465],{"class":343},[338,3047,1320],{"class":351},[338,3049,1323],{"class":343},[338,3051,3052,3054,3056,3058,3060,3062,3064,3066,3068,3070,3072,3074],{"class":340,"line":1136},[338,3053,1329],{"class":379},[338,3055,237],{"class":343},[338,3057,1334],{"class":420},[338,3059,438],{"class":347},[338,3061,1339],{"class":343},[338,3063,1342],{"class":364},[338,3065,1345],{"class":343},[338,3067,1311],{"class":379},[338,3069,237],{"class":343},[338,3071,1352],{"class":379},[338,3073,1355],{"class":343},[338,3075,490],{"class":347},[338,3077,3078],{"class":340,"line":1150},[338,3079,1363],{"class":343},[338,3081,3082],{"class":340,"line":1155},[338,3083,399],{"emptyLinePlaceholder":398},[338,3085,3086,3088,3090,3092,3094,3096],{"class":340,"line":1161},[338,3087,146],{"class":420},[338,3089,438],{"class":379},[338,3091,1384],{"class":351},[338,3093,1387],{"class":343},[338,3095,1320],{"class":351},[338,3097,1323],{"class":343},[338,3099,3100,3102,3104,3106,3108],{"class":340,"line":1180},[338,3101,1397],{"class":375},[338,3103,1400],{"class":379},[338,3105,237],{"class":343},[338,3107,1405],{"class":420},[338,3109,424],{"class":347},[338,3111,3112,3114],{"class":340,"line":1185},[338,3113,414],{"class":343},[338,3115,490],{"class":379},[338,3117,3118,3120,3122],{"class":340,"line":1191},[338,3119,540],{"class":343},[338,3121,348],{"class":347},[338,3123,370],{"class":343},[338,3125,3126],{"class":340,"line":1211},[338,3127,399],{"emptyLinePlaceholder":398},[338,3129,3130,3132,3134],{"class":340,"line":1238},[338,3131,344],{"class":343},[338,3133,557],{"class":347},[338,3135,370],{"class":343},[338,3137,3138,3140,3142],{"class":340,"line":1276},[338,3139,565],{"class":343},[338,3141,746],{"class":347},[338,3143,370],{"class":343},[338,3145,3146,3149,3152],{"class":340,"line":1282},[338,3147,3148],{"class":343}," <",[338,3150,3151],{"class":347},"AppHeader",[338,3153,571],{"class":343},[338,3155,3156,3158],{"class":340,"line":1287},[338,3157,3148],{"class":343},[338,3159,3160],{"class":347},"ProductsListSection\n",[338,3162,3163,3166,3168,3170,3173],{"class":340,"line":1292},[338,3164,3165],{"class":351}," :products",[338,3167,358],{"class":343},[338,3169,361],{"class":343},[338,3171,3172],{"class":364},"filteredProducts",[338,3174,392],{"class":343},[338,3176,3177,3180,3182,3184,3187],{"class":340,"line":1298},[338,3178,3179],{"class":351}," :is-loading",[338,3181,358],{"class":343},[338,3183,361],{"class":343},[338,3185,3186],{"class":364},"productsStore.isLoading",[338,3188,392],{"class":343},[338,3190,3191,3194,3196,3198,3201],{"class":340,"line":1326},[338,3192,3193],{"class":351}," @select",[338,3195,358],{"class":343},[338,3197,361],{"class":343},[338,3199,3200],{"class":364},"handleProductClick",[338,3202,392],{"class":343},[338,3204,3205],{"class":340,"line":1360},[338,3206,3207],{"class":343}," />\n",[338,3209,3210,3213,3215],{"class":340,"line":1366},[338,3211,3212],{"class":343}," \nimport type { Product } from \"@/types\"\n\nconst props = defineProps<{\n products: Product[]\n isLoading: boolean\n}>()\n\nconst emit = defineEmits<{\n select: [product: Product]\n}>()\n\nconst handleSelect = (product: Product) => {\n emit(\"select\", product)\n}\n\n\n\n","components/pages/products/ProductsListSection.vue",[144,3236,3237,3257,3277,3281,3293,3305,3315,3321,3325,3339,3356,3362,3366,3389,3410,3414,3422,3426,3434,3443,3472,3483,3491,3505,3519,3533,3538,3543,3552,3561,3569],{"__ignoreMap":180},[338,3238,3239,3241,3243,3245,3247,3249,3251,3253,3255],{"class":340,"line":124},[338,3240,344],{"class":343},[338,3242,348],{"class":347},[338,3244,352],{"class":351},[338,3246,355],{"class":351},[338,3248,358],{"class":343},[338,3250,361],{"class":343},[338,3252,365],{"class":364},[338,3254,361],{"class":343},[338,3256,370],{"class":343},[338,3258,3259,3261,3263,3265,3267,3269,3271,3273,3275],{"class":340,"line":6},[338,3260,376],{"class":375},[338,3262,1019],{"class":375},[338,3264,408],{"class":343},[338,3266,1024],{"class":379},[338,3268,947],{"class":343},[338,3270,950],{"class":375},[338,3272,386],{"class":343},[338,3274,1033],{"class":364},[338,3276,392],{"class":343},[338,3278,3279],{"class":340,"line":395},[338,3280,399],{"emptyLinePlaceholder":398},[338,3282,3283,3285,3287,3289,3291],{"class":340,"line":402},[338,3284,405],{"class":351},[338,3286,1073],{"class":379},[338,3288,358],{"class":343},[338,3290,1078],{"class":420},[338,3292,1081],{"class":343},[338,3294,3295,3298,3300,3302],{"class":340,"line":427},[338,3296,3297],{"class":347}," products",[338,3299,450],{"class":343},[338,3301,1024],{"class":1092},[338,3303,3304],{"class":379},"[]\n",[338,3306,3307,3310,3312],{"class":340,"line":432},[338,3308,3309],{"class":347}," isLoading",[338,3311,450],{"class":343},[338,3313,3314],{"class":1092}," boolean\n",[338,3316,3317,3319],{"class":340,"line":444},[338,3318,1098],{"class":343},[338,3320,424],{"class":379},[338,3322,3323],{"class":340,"line":471},[338,3324,399],{"emptyLinePlaceholder":398},[338,3326,3327,3329,3332,3334,3337],{"class":340,"line":493},[338,3328,405],{"class":351},[338,3330,3331],{"class":379}," emit ",[338,3333,358],{"class":343},[338,3335,3336],{"class":420}," defineEmits",[338,3338,1081],{"class":343},[338,3340,3341,3344,3346,3348,3350,3352,3354],{"class":340,"line":500},[338,3342,3343],{"class":347}," select",[338,3345,450],{"class":343},[338,3347,1830],{"class":379},[338,3349,1311],{"class":1092},[338,3351,450],{"class":343},[338,3353,1024],{"class":1092},[338,3355,1713],{"class":379},[338,3357,3358,3360],{"class":340,"line":505},[338,3359,1098],{"class":343},[338,3361,424],{"class":379},[338,3363,3364],{"class":340,"line":515},[338,3365,399],{"emptyLinePlaceholder":398},[338,3367,3368,3370,3373,3375,3377,3379,3381,3383,3385,3387],{"class":340,"line":530},[338,3369,405],{"class":351},[338,3371,3372],{"class":379}," handleSelect ",[338,3374,358],{"class":343},[338,3376,1308],{"class":343},[338,3378,1311],{"class":1231},[338,3380,450],{"class":343},[338,3382,1024],{"class":1092},[338,3384,465],{"class":343},[338,3386,1320],{"class":351},[338,3388,1323],{"class":343},[338,3390,3391,3394,3396,3398,3401,3403,3405,3408],{"class":340,"line":537},[338,3392,3393],{"class":420}," emit",[338,3395,438],{"class":347},[338,3397,361],{"class":343},[338,3399,3400],{"class":364},"select",[338,3402,361],{"class":343},[338,3404,872],{"class":343},[338,3406,3407],{"class":379}," product",[338,3409,490],{"class":347},[338,3411,3412],{"class":340,"line":547},[338,3413,1363],{"class":343},[338,3415,3416,3418,3420],{"class":340,"line":552},[338,3417,540],{"class":343},[338,3419,348],{"class":347},[338,3421,370],{"class":343},[338,3423,3424],{"class":340,"line":562},[338,3425,399],{"emptyLinePlaceholder":398},[338,3427,3428,3430,3432],{"class":340,"line":574},[338,3429,344],{"class":343},[338,3431,557],{"class":347},[338,3433,370],{"class":343},[338,3435,3436,3438,3441],{"class":340,"line":1125},[338,3437,565],{"class":343},[338,3439,3440],{"class":347},"section",[338,3442,370],{"class":343},[338,3444,3445,3447,3449,3452,3454,3456,3459,3461,3463,3466,3468,3470],{"class":340,"line":1130},[338,3446,3148],{"class":343},[338,3448,746],{"class":347},[338,3450,3451],{"class":351}," v-if",[338,3453,358],{"class":343},[338,3455,361],{"class":343},[338,3457,3458],{"class":364},"props.isLoading",[338,3460,361],{"class":343},[338,3462,749],{"class":343},[338,3464,3465],{"class":379},"Загрузка...",[338,3467,540],{"class":343},[338,3469,746],{"class":347},[338,3471,370],{"class":343},[338,3473,3474,3476,3478,3481],{"class":340,"line":1136},[338,3475,3148],{"class":343},[338,3477,612],{"class":347},[338,3479,3480],{"class":351}," v-else",[338,3482,370],{"class":343},[338,3484,3485,3488],{"class":340,"line":1150},[338,3486,3487],{"class":343}," <",[338,3489,3490],{"class":347},"li\n",[338,3492,3493,3496,3498,3500,3503],{"class":340,"line":1155},[338,3494,3495],{"class":351}," v-for",[338,3497,358],{"class":343},[338,3499,361],{"class":343},[338,3501,3502],{"class":364},"product in props.products",[338,3504,392],{"class":343},[338,3506,3507,3510,3512,3514,3517],{"class":340,"line":1161},[338,3508,3509],{"class":351}," :key",[338,3511,358],{"class":343},[338,3513,361],{"class":343},[338,3515,3516],{"class":364},"product.id",[338,3518,392],{"class":343},[338,3520,3521,3524,3526,3528,3531],{"class":340,"line":1180},[338,3522,3523],{"class":351}," @click",[338,3525,358],{"class":343},[338,3527,361],{"class":343},[338,3529,3530],{"class":364},"handleSelect(product)",[338,3532,392],{"class":343},[338,3534,3535],{"class":340,"line":1185},[338,3536,3537],{"class":343}," >\n",[338,3539,3540],{"class":340,"line":1191},[338,3541,3542],{"class":379}," {{ product.name }}\n",[338,3544,3545,3548,3550],{"class":340,"line":1211},[338,3546,3547],{"class":343}," ",{"id":1444,"depth":6,"text":1445,"children":4021},[4022,4023,4024,4025],{"id":1448,"depth":395,"text":1449},{"id":1859,"depth":395,"text":1860},{"id":2159,"depth":395,"text":2160},{"id":2566,"depth":395,"text":2567},{"id":2588,"depth":6,"text":2589},{"id":3867,"depth":6,"text":3868},{"id":3890,"depth":6,"text":3891},"md",{"icon":20,"publishedAt":21,"languageOriginal":22,"tags":4031},[24,25,26],{"title":17,"description":18},"guides/articles/architecture","JxTXQKfnGgeAZ_mJ7xegYG4c3mPRKm92nsUDFwsJw1k",[4036,4039,4042,4045,4048,4051,4054,4057,4060],{"title":17,"meta":4037,"path":27},{"icon":20,"publishedAt":21,"languageOriginal":22,"tags":4038},[24,25,26],{"title":29,"meta":4040,"path":37},{"icon":32,"habrUrl":33,"publishedAt":34,"languageOriginal":22,"tags":4041},[36,24],{"title":39,"meta":4043,"path":46},{"icon":42,"publishedAt":43,"languageOriginal":22,"tags":4044},[24,26,45],{"title":48,"meta":4046,"path":56},{"icon":32,"habrUrl":51,"publishedAt":52,"languageOriginal":22,"tags":4047},[54,55],{"title":59,"meta":4049,"path":69},{"icon":62,"githubRepo":63,"githubUrl":64,"publishedAt":65,"tags":4050},[67,24,68],{"title":71,"meta":4052,"path":79},{"icon":74,"githubRepo":75,"githubUrl":76,"publishedAt":77,"tags":4053},[36,24,68],{"title":81,"meta":4055,"path":92},{"icon":84,"githubRepo":85,"githubUrl":86,"publishedAt":87,"tags":4056},[89,90,91,24,68],{"title":94,"meta":4058,"path":101},{"icon":97,"githubRepo":98,"githubUrl":99,"publishedAt":87,"tags":4059},[89,24],{"title":103,"meta":4061,"path":111},{"icon":106,"githubRepo":107,"githubUrl":108,"publishedAt":87,"tags":4062},[89,110,24,68],["Reactive",4064],{"$scolor-mode":4065,"$si18n:cached-locale-configs":4068,"$si18n:resolved-locale":180,"$snuxt-seo-utils:routeRules":4073,"$stoasts":4074,"$svisitor-had-baseline":4067,"$snav-highlight-ack-map":4075,"$snav-highlight-pages":4076,"$ssite-config":4077},{"preference":4066,"value":4066,"unknown":398,"forced":4067},"dark",false,{"ru":4069,"en":4071},{"fallbacks":4070,"cacheable":398},[],{"fallbacks":4072,"cacheable":398},[],{"head":-1,"seoMeta":-1},[],{},[],{"_priority":4078,"currentLocale":22,"defaultLocale":22,"env":4083,"name":4084,"url":4085},{"env":4079,"url":4080,"name":4081,"defaultLocale":4082,"currentLocale":4082},-15,0,-3,-2,"production","Aganov.dev","https://aganov.dev",["Set"],["ShallowReactive",4088],{"nav-highlights:content_ru":-1,"sidebar:section-root:content_ru:guides":-1,"github-stars:davidaganov/davidaganov.github.io":-1,"site-nav:all-sections:content_ru":-1,"sidebar:collection:articles:/docs/guides/articles:content_ru":-1,"sidebar:collection:templates:/docs/guides/templates:content_ru":-1,"content-page:content_ru:/docs/guides/articles/architecture":-1,"docs-search:ru:":-1,"top-projects:content_ru":-1,"docs-flat-nav:content_ru:guides":-1},"/docs/guides/articles/architecture",["Reactive",4091],{}]