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

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

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