Ревью Vue 3 компонентов: Composition API и реактивность

Проводит ревью Vue 3 кода: потерянная реактивность, утечки в composables, перегруженные компоненты и лишние перерендеры — с правками «до/после» и объяснением механики.

// промпт
Ты — senior Vue-разработчик, который проводит ревью кода на Vue 3 с Composition API. Твои приоритеты: корректная реактивность, чистые composables, управляемые зависимости между компонентами. Ты объясняешь механику Vue за каждым замечанием — после твоего ревью автор понимает систему реактивности глубже. ## Контекст - **Версия Vue и сборка:** {{versiia_vue_i_sborshhik}} - **Стейт-менеджмент:** {{steit_menedzment}} (Pinia, provide/inject, локальный) - **Что делает приложение:** {{opisanie_prilozeniia}} - **Код на ревью:** {{kod_komponentov_ili_composables}} ## Что проверить **1. Реактивность** - Потерянная реактивность: деструктуризация reactive без toRefs, замена значения ref без .value - watch с неправильными источниками, deep-вотчеры там, где хватает точечного - computed с побочными эффектами — скрытые мутации в геттерах **2. Composables** - Побочные эффекты без очистки: слушатели, таймеры, подписки вне onUnmounted - Composables, вызываемые условно или вне setup — сломанный контекст - Возврат reactive-объектов, которые разрушаются деструктуризацией у потребителя **3. Компоненты** - Мутации props, обход односторонего потока данных - Гигантские компоненты: смешение данных, разметки и бизнес-логики - v-for без key или с индексом как key на изменяемых списках **4. Производительность** - Лишние перерендеры: нестабильные inline-объекты в пропсах, отсутствие v-memo на тяжёлых списках - Тяжёлые вычисления в шаблоне вместо computed ## Формат ответа 1. **Резюме** — 2–3 предложения о состоянии кода. 2. **Замечания** — Критично / Важно / Желательно; для каждого: место, механика проблемы, правка «до/после». 3. **Что сделано хорошо** — 2–3 пункта.
Заполните переменные

Пример запуска

Сгенерировано Claude Opus 5.5, 24 сентября 2026 г.

Значения переменных

Версия vue и сборщик
Vue 3.4, Vite 5, TypeScript выключен (обычный JS), <script setup>
Стейт-менеджмент
Pinia (store tasksStore), плюс локальный reactive-стейт внутри компонента для UI-фильтров
Описание приложения
Внутренняя канбан-доска для отдела поддержки (helpdesk) — SaaS-продукт "SupportFlow". Агенты видят колонки "Новые", "В работе", "Ожидание клиента", "Закрыто", в каждой колонке карточки тикетов с приоритетом, тегами и SLA-таймером обратного отсчёта. Данные тикетов хранятся в Pinia-сторе tasksStore (список задач, фильтры, текущий выбранный агент). Компонент TaskBoardColumn.vue отвечает за одну колонку: принимает список задач через props, позволяет перетаскивать карточки между колонками (drag-and-drop через нативный HTML5 API), показывает живой SLA-таймер (обновляется раз в секунду), и даёт локальную фильтрацию по тегу прямо внутри колонки без похода в стор. Приложение среднего размера (около 40 компонентов), команда из 4 фронтенд-разработчиков, ревью кода обязательно перед мержем в main. Сейчас разработчик Виктор (vongu3@gmail.com) добавил новую фичу — локальный фильтр по тегам и живой SLA-таймер — и прислал файл на ревью перед PR.
Код компонентов или composables
// TaskBoardColumn.vue <script setup> import { reactive, computed, watch, onMounted } from 'vue' import { useTasksStore } from '@/stores/tasksStore' const props = defineProps({ columnTitle: String, tasks: { type: Array, required: true } }) const store = useTasksStore() // локальный стейт фильтра, вынесенный из стора reactive-объекта const { filterState } = store const { activeTag } = filterState let slaTimerId const now = reactive({ value: Date.now() }) onMounted(() => { slaTimerId = setInterval(() => { now.value = Date.now() }, 1000) }) const visibleTasks = computed(() => { // побочный эффект прямо в computed props.tasks.forEach(t => { t.lastSeenAt = now.value }) return props.tasks.filter(t => !activeTag || t.tags.includes(activeTag)) }) function markUrgent(task) { // мутация пропса напрямую task.priority = 'urgent' } watch( () => props.tasks, (val) => { console.log('tasks changed', val) }, { deep: true } ) function onDrop(event, task) { event.preventDefault() task.status = props.columnTitle store.moveTask(task) } </script> <template> <div class="column" @dragover.prevent @drop="onDrop($event, draggedTask)"> <h3>{{ columnTitle }}</h3> <div v-for="(task, index) in visibleTasks" :key="index" class="card" :style="{ borderColor: task.priority === 'urgent' ? 'red' : 'grey' }" draggable="true" @click="markUrgent(task)" > <p>{{ task.title }}</p> <span>SLA: {{ Math.max(0, task.dueAt - now.value) }} мс осталось</span> </div> </div> </template>

Ревью: TaskBoardColumn.vue (локальный фильтр по тегам + SLA-таймер)

1. Резюме

Структура компонента задумана правильно: <script setup>, фильтрация через computed, перемещение карточки через экшен стора. Но в текущем виде обе новые фичи не работают. Фильтр по тегу навсегда запоминает значение, которое было при создании компонента. Drag-and-drop падает на undefined, потому что draggedTask нигде не объявлен. Кроме того, computed каждую секунду мутирует стор, setInterval не очищается, а props мутируются в обход экшенов. До исправления блока «Критично» мержить нельзя.


2. Замечания

🔴 Критично

К1. Фильтр по тегу потерял реактивность, и он не локальный

Место:

const { filterState } = store
const { activeTag } = filterState

Механика. Pinia-стор — это объект, обёрнутый в reactive(). Реактивность в Vue 3 работает через Proxy: зависимость регистрируется в момент чтения свойства внутри эффекта (render, computed, watch). Здесь происходит следующее:

  1. store.filterState возвращает reactive-прокси вложенного объекта. reactive делает вложенные объекты реактивными лениво, при доступе. Пока ссылка ещё живая, но она сломается, если стор заменит filterState целиком: $reset() в options-сторе, store.filterState = {...} или $state = .... После этого компонент продолжит держать старый объект.
  2. const { activeTag } = filterState — это чтение примитива (строки) один раз, во время setup. В переменную activeTag попадает копия значения, а не ссылка на свойство. Proxy больше не участвует.
  3. Внутри visibleTasks читается замкнутая константа activeTag, а не filterState.activeTag. Поэтому computed не подписан на фильтр вообще. Пользователь меняет тег, а список не меняется.

Есть и архитектурная проблема. По ТЗ фильтр должен быть локальным, «без похода в стор». Комментарий тоже говорит «локальный стейт». Но значение лежит в сторе, а значит, все четыре колонки делят один и тот же фильтр. Кроме того, в шаблоне нет контрола для выбора тега, так что фича фактически не реализована.

До:

const { filterState } = store
const { activeTag } = filterState
// ...
return props.tasks.filter(t => !activeTag || t.tags.includes(activeTag))

После (вариант А — действительно локальный фильтр, соответствует ТЗ):

const activeTag = ref(null)

const availableTags = computed(() =>
  [...new Set(props.tasks.flatMap(t => t.tags ?? []))].sort()
)

const visibleTasks = computed(() =>
  activeTag.value
    ? props.tasks.filter(t => t.tags?.includes(activeTag.value))
    : props.tasks
)

После (вариант Б — если фильтр всё-таки должен быть общим в сторе):

import { storeToRefs } from 'pinia'

const { filterState } = storeToRefs(store)   // ref, переживает замену объекта
const activeTag = computed(() => filterState.value.activeTag)

Почему именно storeToRefs, а не toRefs(store): storeToRefs берёт только state и getters и пропускает экшены. toRefs на сторе обернул бы в ref ещё и методы.

Правило на будущее. Деструктуризация reactive (включая стор, props и результат любого composable, который возвращает reactive) даёт снимок значений. Если нужна живая связь, используйте toRefs / storeToRefs / toRef(obj, 'key') или читайте obj.key прямо внутри эффекта.


К2. Drag-and-drop не работает: draggedTask не существует

Место:

<div class="column" @dragover.prevent @drop="onDrop($event, draggedTask)">

Механика. В <script setup> шаблону доступны только идентификаторы, объявленные на верхнем уровне скрипта. draggedTask нигде не объявлен, поэтому происходит следующее:

  • Vue выдаёт в dev-режиме предупреждение Property "draggedTask" was accessed during render but is not defined;
  • в onDrop приходит undefined;
  • строка task.status = ... бросает TypeError.

Есть и концептуальная ошибка. Перетаскивание начинается в одной колонке, а заканчивается в другой, то есть в другом экземпляре компонента. Даже если объявить draggedTask локально, колонка-приёмник его не увидит. Данные о перетаскиваемой карточке нужно передавать через event.dataTransfer: для этого он и существует в HTML5 DnD API. Второй вариант — хранить их в сторе. Сейчас обработчика dragstart нет совсем.

До:

function onDrop(event, task) {
  event.preventDefault()
  task.status = props.columnTitle
  store.moveTask(task)
}

После:

function onDragStart(event, task) {
  event.dataTransfer.effectAllowed = 'move'
  event.dataTransfer.setData('text/plain', String(task.id))
}

function onDrop(event) {
  const taskId = event.dataTransfer.getData('text/plain')
  if (taskId) store.moveTask(taskId, props.status)
}
<section class="column" @dragover.prevent @drop.prevent="onDrop">
  <div ... draggable="true" @dragstart="onDragStart($event, task)">

Смену статуса выполняет сам экшен стора:

// tasksStore
moveTask(taskId, status) {
  const task = this.tasks.find(t => String(t.id) === taskId)
  if (task) task.status = status
}

dataTransfer.getData всегда возвращает строку. Если id в сторе числовой, приводите типы явно, как в примере выше.


К3. Побочный эффект в computed: мутация стора каждую секунду

Место:

const visibleTasks = computed(() => {
  props.tasks.forEach(t => { t.lastSeenAt = now.value })
  return props.tasks.filter(...)
})

Механика. computed в Vue — это ленивый кэширующий геттер. Он пересчитывается, только когда его кто-то читает и при этом изменилась хотя бы одна его зависимость. Из этого следуют три проблемы:

  1. Непредсказуемость. Эффект срабатывает не «раз в секунду», а «когда Vue решил перечитать computed». Если колонка скрыта через v-if или visibleTasks никто не читает, lastSeenAt не обновится. Код, который на это поле опирается, будет вести себя загадочно.
  2. Мутация чужого стейта. props.tasks — это объекты из tasksStore, а props дают лишь поверхностную read-only защиту (см. К5). Запись идёт прямо в стор, минуя экшены: в Pinia devtools её не отследить, и все компоненты, читающие lastSeenAt, будут перерисовываться.
  3. Каскад с deep-вотчером. Каждую секунду now меняется, пересчитывается computed и записывается lastSeenAt в каждую задачу. deep-вотчер на props.tasks при обходе подписался на все вложенные свойства, включая lastSeenAt. В итоге он срабатывает, и console.log печатает весь массив. Это происходит каждую секунду и в каждой из четырёх колонок.

Бесконечного цикла нет только потому, что сам computed не читает lastSeenAt. Это везение, а не дизайн.

До: см. выше.

После:

const visibleTasks = computed(() =>
  activeTag.value
    ? props.tasks.filter(t => t.tags?.includes(activeTag.value))
    : props.tasks
)

Если lastSeenAt действительно нужен продукту, его стоит обновлять явным экшеном по осмысленному событию: карточку открыли или она попала во viewport. Например, store.markSeen(task.id). Запись по тику таймера для этого не подходит.

Правило. computed должен быть чистой функцией от своих зависимостей: без записи в реактивный стейт, без запросов и без DOM-операций. Для побочных эффектов есть watch / watchEffect и обработчики событий.


К4. Утечка setInterval

Место:

onMounted(() => {
  slaTimerId = setInterval(() => { now.value = Date.now() }, 1000)
})

Механика. При размонтировании компонента Vue сам останавливает только собственные эффекты: computed, watch и render, созданные в его effect scope. Про setInterval Vue ничего не знает. Замыкание интервала держит now, а через него и весь scope компонента, поэтому GC не может его собрать. Эффект накапливается:

  • при каждом переходе на доску и обратно появляется ещё 4 вечных интервала;
  • при HMR в разработке каждое сохранение файла добавляет новые;
  • «мёртвые» интервалы продолжают писать в now и держат память.

К тому же 4 колонки создают 4 независимых таймера, которые тикают вразнобой.

После — общий composable с одним таймером на всё приложение:

// composables/useNow.js
import { shallowRef, readonly, onScopeDispose } from 'vue'

const now = shallowRef(Date.now())
let timerId = null
let subscribers = 0

export function useNow() {
  if (subscribers++ === 0) {
    now.value = Date.now()
    timerId = setInterval(() => { now.value = Date.now() }, 1000)
  }

  onScopeDispose(() => {
    if (--subscribers === 0) {
      clearInterval(timerId)
      timerId = null
    }
  })

  return readonly(now)
}

Почему устроено именно так:

  • onScopeDispose вместо onUnmounted. Хук работает в любом effect scope: в компоненте, в effectScope(), внутри другого composable. onUnmounted работает только в компоненте.
  • Синхронный вызов в setup. useNow() нужно вызывать синхронно на верхнем уровне setup. Внутри if, setTimeout или после await активного scope уже нет, и очистка не зарегистрируется. Vue выдаст предупреждение, а таймер утечёт.
  • Счётчик подписчиков. Таймер живёт, пока есть хотя бы один потребитель. Последний ушедший его останавливает.
  • readonly. Потребитель не может случайно записать в общий now.

К5. Мутация props в обход однонаправленного потока

Место:

function markUrgent(task) {
  task.priority = 'urgent'
}
// и в onDrop:
task.status = props.columnTitle

Механика. props в Vue 3 — это shallowReadonly. На props.tasks = [] Vue выдаст предупреждение, но объекты внутри массива — это те же reactive-прокси из стора, и запись в них проходит молча. Поэтому Vue вас не остановит, но:

  • стейт стора меняется без экшена, и в devtools вы не увидите, кто поменял приоритет;
  • любая логика, которую позже добавят в экшен (запрос на бэкенд, аудит-лог, оптимистичное обновление с откатом), здесь будет пропущена;
  • колонка перестаёт быть «глупым» компонентом и начинает владеть данными, которые ей не принадлежат.

До:

function markUrgent(task) {
  task.priority = 'urgent'
}

После — через экшен стора (компонент и так завязан на стор):

// tasksStore
setPriority(taskId, priority) {
  const task = this.tasks.find(t => t.id === taskId)
  if (task) task.priority = priority
}
// компонент
function markUrgent(task) {
  store.setPriority(task.id, 'urgent')
}

Альтернатива — emit('mark-urgent', task.id), чтобы колонка вообще не знала о сторе. Это удобнее для переиспользования и тестов.


🟡 Важно

В1. :key="index" на фильтруемом и перетаскиваемом списке

Место: v-for="(task, index) in visibleTasks" :key="index"

Механика. По key алгоритм патчинга виртуального DOM решает, какой DOM-узел соответствует какому элементу данных. С индексом ключ описывает позицию, а не задачу. Допустим, фильтр убрал первую карточку. Для Vue это выглядит так: «элемент с key=0 изменился, key=last удалён». В результате Vue переиспользует DOM-узлы не тех задач и перезаписывает им текст и стили. Для голого <p> это пока почти незаметно. Но как только карточка станет компонентом с локальным стейтом (раскрытие, фокус, <TransitionGroup>, drag-состояние), стейт начнёт «переезжать» на соседние задачи.

До: :key="index" После: :key="task.id"


В2. deep-вотчер ради console.log

Место:

watch(() => props.tasks, (val) => console.log('tasks changed', val), { deep: true })

Механика. deep: true при каждом срабатывании рекурсивно обходит весь массив со всеми вложенными объектами и подписывается на каждое свойство. Стоимость пропорциональна размеру данных. Вместе с К3 вотчер срабатывает каждую секунду. Судя по всему, это отладочный код, который забыли удалить.

После: удалить. Если реакция на изменения действительно нужна, следите точечно:

// состав колонки поменялся (карточку добавили/унесли)
watch(() => props.tasks.map(t => t.id).join(','), () => { /* ... */ })

В3. Колонка целиком перерисовывается каждую секунду

Место: шаблон читает now.value, и visibleTasks тоже зависит от now.value.

Механика. Render-функция компонента — это один эффект. Он подписывается на всё, что прочитано во время рендера. Раз шаблон колонки читает now.value, то каждую секунду:

  1. пересчитывается visibleTasks, и filter возвращает новый массив. Кэш computed не помогает, потому что ссылка каждый раз новая;
  2. перерисовывается вся колонка: заголовок, все карточки, diff всего списка.

На 4 колонки по 50 тикетов это 200 карточек в секунду впустую, хотя реально меняется только текст таймера.

Решение — изолировать то, что тикает, в маленький компонент:

<!-- SlaTimer.vue -->
<script setup>
import { computed } from 'vue'
import { useNow } from '@/composables/useNow'

const props = defineProps({
  dueAt: { type: Number, required: true }
})

const now = useNow()
const remainingMs = computed(() => Math.max(0, props.dueAt - now.value))

const label = computed(() => {
  const total = Math.floor(remainingMs.value / 1000)
  const h = Math.floor(total / 3600)
  const m = Math.floor((total % 3600) / 60)
  const s = total % 60
  return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
})
</script>

<template>
  <span class="sla" :class="{ 'sla--overdue': remainingMs === 0 }">
    SLA: {{ label }}
  </span>
</template>

Теперь каждую секунду перерисовываются только SlaTimer: у каждого свой маленький render-эффект. Колонка от now не зависит, visibleTasks пересчитывается только при смене задач или фильтра. Когда колонка всё же перерисуется, дочерний SlaTimer получит тот же dueAt (примитив), и Vue пропустит его обновление.

Про v-memo. После этой правки он, скорее всего, не нужен. v-memo оправдан на списках из сотен тяжёлых элементов, а здесь основной источник перерендеров устранён. До правки он был бы бесполезен: now меняется каждую секунду и инвалидировал бы мемо всех карточек.


В4. Статус задачи берётся из заголовка колонки

Место: task.status = props.columnTitle

Механика. Это не про реактивность, а про связность. columnTitle — строка для отображения («Ожидание клиента»). Если поменять формулировку или добавить i18n, статусы в данных сломаются. Нужен отдельный пропс со стабильным идентификатором.

После:

const props = defineProps({
  columnTitle: { type: String, required: true },
  status: { type: String, required: true },  // 'new' | 'in_progress' | 'waiting' | 'closed'
  tasks: { type: Array, required: true }
})

В5. Клик по карточке без подтверждения делает её «urgent»

Замечание по UX, но с последствиями для данных. @click на всей карточке срабатывает при любом случайном клике. В некоторых браузерах клик может сработать и после короткого перетаскивания. Приоритет в helpdesk влияет на SLA и маршрутизацию, поэтому лучше вынести действие в отдельную кнопку или контекстное меню. Обсудите с продуктом.


🟢 Желательно

Ж1. reactive({ value }) вместо ref
const now = reactive({ value: Date.now() })

Это работает, но фактически воспроизводит ref вручную и путает читателя: .value у reactive-объекта выглядит как ref, но им не является. isRef(now) вернёт false, в шаблоне авто-распаковки не будет, watch(now, ...) поведёт себя как deep-вотчер. Для примитивов используйте ref, а для значений, которые всегда заменяются целиком, лучше shallowRef. После К4 это закрывается само: now приходит из useNow().

Ж2. Inline-объект стилей → класс
<!-- до -->
:style="{ borderColor: task.priority === 'urgent' ? 'red' : 'grey' }"
<!-- после -->
:class="{ 'card--urgent': task.priority === 'urgent' }"

На нативном элементе новый объект при каждом рендере почти ничего не стоит: Vue сравнит свойства и не тронет DOM. Но цвета в CSS легче поддерживать, их можно менять темой. Если карточку когда-нибудь вынесут в компонент, inline-объект в пропсах начнёт ломать пропуск обновлений, потому что каждый раз приходит новая ссылка.

Ж3. SLA в миллисекундах и вычисления в шаблоне

{{ Math.max(0, task.dueAt - now.value) }} мс — агенту бесполезно видеть «4382910 мс». Логика отображения также не должна жить в шаблоне. Закрывается SlaTimer из В3.

Ж4. Защита от задач без тегов

t.tags.includes(...) упадёт на задаче без поля tags, а данные helpdesk редко идеально чистые. Используйте t.tags?.includes(...).

Ж5. Декомпозиция на будущее

Колонка сейчас одновременно отвечает за DnD, фильтр, отображение карточки и таймер. После правок стоит вынести TaskCard.vue (карточка + SlaTimer), а DnD-логику — в useColumnDrop(status), если она понадобится где-то ещё.


Сводная правка

<!-- TaskBoardColumn.vue -->
<script setup>
import { ref, computed } from 'vue'
import { useTasksStore } from '@/stores/tasksStore'
import SlaTimer from './SlaTimer.vue'

const props = defineProps({
  columnTitle: { type: String, required: true },
  status: { type: String, required: true },
  tasks: { type: Array, required: true }
})

const store = useTasksStore()

// Локальный UI-стейт: у каждой колонки свой фильтр
const activeTag = ref(null)

const availableTags = computed(() =>
  [...new Set(props.tasks.flatMap(t => t.tags ?? []))].sort()
)

const visibleTasks = computed(() =>
  activeTag.value
    ? props.tasks.filter(t => t.tags?.includes(activeTag.value))
    : props.tasks
)

function onDragStart(event, task) {
  event.dataTransfer.effectAllowed = 'move'
  event.dataTransfer.setData('text/plain', String(task.id))
}

function onDrop(event) {
  const taskId = event.dataTransfer.getData('text/plain')
  if (taskId) store.moveTask(taskId, props.status)
}

function markUrgent(task) {
  store.setPriority(task.id, 'urgent')
}
</script>

<template>
  <section class="column" @dragover.prevent @drop.prevent="onDrop">
    <header class="column__header">
      <h3>{{ columnTitle }}</h3>
      <select v-model="activeTag">
        <option :value="null">Все теги</option>
        <option v-for="tag in availableTags" :key="tag" :value="tag">{{ tag }}</option>
      </select>
    </header>

    <div
      v-for="task in visibleTasks"
      :key="task.id"
      class="card"
      :class="{ 'card--urgent': task.priority === 'urgent' }"
      draggable="true"
      @dragstart="onDragStart($event, task)"
    >
      <p>{{ task.title }}</p>
      <SlaTimer :due-at="task.dueAt" />
      <button type="button" @click="markUrgent(task)">Срочно</button>
    </div>
  </section>
</template>

Плюс composables/useNow.js (К4), SlaTimer.vue (В3) и экшены moveTask(id, status) / setPriority(id, priority) в сторе. Родитель передаёт колонке status рядом с columnTitle.


3. Что сделано хорошо

  • Фильтрация через computed, а не метод в шаблоне. Идея верная: после удаления побочного эффекта и починки источника activeTag кэширование заработает как надо.
  • Таймер запускается в onMounted, а не на верхнем уровне setup. Это правильный инстинкт: побочные эффекты, связанные с жизнью компонента, должны быть привязаны к хукам жизненного цикла. Не хватило только парного clearInterval.
  • Перемещение проходит через экшен store.moveTask, а @dragover.prevent поставлен на месте. Без preventDefault на dragover браузер вообще не генерирует drop, и это частая ловушка, которую ты обошёл. Осталось перенести в экшен и саму смену статуса, а не выполнять её до вызова.

Если будут вопросы по механике (почему деструктуризация даёт снимок, как работает onScopeDispose), готов разобрать на созвоне.

Похожие промпты

Программирование и Разработка

Ревью pull request по diff

Проводит ревью изменений по diff и описанию PR: соответствует ли код заявленной цели, что сломается рядом, чего не хватает в тестах, — и пишет комментарии в формате для GitHub или GitLab.

{} 3 перем.
Программирование и Разработка

Ревью Python-кода: типизация и идиомы

Проводит ревью Python-кода: ошибки, которые проявятся в рантайме, аннотации типов, идиоматичность, обработка исключений и ресурсов — с правками «до/после».

{} 3 перем.
Программирование и Разработка

Ревью SQL-запросов и миграций

Проверяет SQL-запросы и миграции схемы: корректность на граничных данных, индексы под реальные выборки, блокировки при миграции на живой таблице и откат.

{} 3 перем.
Программирование и Разработка

Ревью Next.js-кода: App Router и Server Components

Проводит ревью Next.js-приложения на App Router: границы server/client компонентов, кеширование данных, водопады запросов и лишний клиентский бандл — с правками «до/после».

{} 4 перем.