Ревью Vue 3 компонентов: Composition API и реактивность
Проводит ревью Vue 3 кода: потерянная реактивность, утечки в composables, перегруженные компоненты и лишние перерендеры — с правками «до/после» и объяснением механики.
Пример запуска
Сгенерировано 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). Здесь происходит следующее:
store.filterStateвозвращает reactive-прокси вложенного объекта.reactiveделает вложенные объекты реактивными лениво, при доступе. Пока ссылка ещё живая, но она сломается, если стор заменитfilterStateцеликом:$reset()в options-сторе,store.filterState = {...}или$state = .... После этого компонент продолжит держать старый объект.const { activeTag } = filterState— это чтение примитива (строки) один раз, во времяsetup. В переменнуюactiveTagпопадает копия значения, а не ссылка на свойство. Proxy больше не участвует.- Внутри
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 — это ленивый кэширующий геттер. Он пересчитывается, только когда его кто-то читает и при этом изменилась хотя бы одна его зависимость. Из этого следуют три проблемы:
- Непредсказуемость. Эффект срабатывает не «раз в секунду», а «когда Vue решил перечитать
computed». Если колонка скрыта черезv-ifилиvisibleTasksникто не читает,lastSeenAtне обновится. Код, который на это поле опирается, будет вести себя загадочно. - Мутация чужого стейта.
props.tasks— это объекты изtasksStore, а props дают лишь поверхностную read-only защиту (см. К5). Запись идёт прямо в стор, минуя экшены: в Pinia devtools её не отследить, и все компоненты, читающиеlastSeenAt, будут перерисовываться. - Каскад с 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, то каждую секунду:
- пересчитывается
visibleTasks, иfilterвозвращает новый массив. Кэшcomputedне помогает, потому что ссылка каждый раз новая; - перерисовывается вся колонка: заголовок, все карточки, 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.
Ревью Python-кода: типизация и идиомы
Проводит ревью Python-кода: ошибки, которые проявятся в рантайме, аннотации типов, идиоматичность, обработка исключений и ресурсов — с правками «до/после».
Ревью SQL-запросов и миграций
Проверяет SQL-запросы и миграции схемы: корректность на граничных данных, индексы под реальные выборки, блокировки при миграции на живой таблице и откат.
Ревью Next.js-кода: App Router и Server Components
Проводит ревью Next.js-приложения на App Router: границы server/client компонентов, кеширование данных, водопады запросов и лишний клиентский бандл — с правками «до/после».