← Курс/v-for: рендеринг списков#208 из 257+20 XP

v-for: рендеринг списков

Основы v-for

Директива v-for позволяет рендерить список элементов на основе массива. Синтаксис: v-for="item in items", где items — исходный массив, а item — псевдоним для каждого элемента.

<template>
  <ul>
    <li v-for="task in tasks" :key="task.id">
      {{ task.title }}
    </li>
  </ul>
</template>

<script setup>
const tasks = ref([
  { id: 1, title: 'Изучить Vue 3' },
  { id: 2, title: 'Написать компонент' },
  { id: 3, title: 'Задеплоить приложение' },
])
</script>

Индекс элемента

Вторым параметром можно получить текущий индекс:

<li v-for="(item, index) in items" :key="item.id">
  {{ index + 1 }}. {{ item.name }}
</li>

Итерация по объекту

v-for работает и с объектами. Три параметра: значение, ключ, индекс:

<template>
  <dl>
    <template v-for="(value, key, index) in user" :key="key">
      <dt>{{ key }}</dt>
      <dd>{{ value }}</dd>
    </template>
  </dl>
</template>

<script setup>
const user = reactive({
  name: 'Анна',
  age: 28,
  city: 'Москва',
})
</script>

Диапазоны чисел

v-for может принимать число — итерирует от 1 до N:

<!-- Выведет: 1 2 3 4 5 -->
<span v-for="n in 5" :key="n">{{ n }} </span>

Атрибут :key — почему он важен

Атрибут :key обязателен при v-for. Vue использует его для **отслеживания идентичности элементов** при обновлении списка.

Без key Vue применяет стратегию «патчинг на месте»: при изменении порядка он просто обновляет контент каждого существующего DOM-узла, не перемещая их. Это дешевле, но может привести к ошибкам со state компонентов, фокусом полей ввода, анимациями.

С уникальным key Vue точно знает, какой элемент соответствует какому DOM-узлу, и может корректно добавлять, удалять и перемещать их.

<!-- Плохо: индекс как key — при удалении/сортировке всё ломается -->
<li v-for="(item, index) in items" :key="index">

<!-- Хорошо: уникальный стабильный ID -->
<li v-for="item in items" :key="item.id">

v-for с v-if: приоритет

В Vue 3 v-if имеет **более высокий приоритет**, чем v-for. Поэтому нельзя использовать их на одном элементе (переменные из v-for будут недоступны в v-if).

Правильный подход — обернуть в <template>:

<!-- Неправильно в Vue 3: v-if не имеет доступа к item -->
<li v-for="item in items" v-if="item.isActive" :key="item.id">

<!-- Правильно: v-for снаружи, v-if внутри -->
<template v-for="item in items" :key="item.id">
  <li v-if="item.isActive">{{ item.name }}</li>
</template>

Или ещё лучше — фильтруй данные через computed-свойство и не смешивай директивы.

Примеры

Демонстрация работы :key при обновлении списка: с ключом и без ключа

// Эмулируем алгоритм Vue при обновлении v-for списка.
// Показываем разницу между "patch in place" (без key) и "keyed diff" (с key).

// --- Без :key: патчинг на месте ---
console.log('=== Обновление списка БЕЗ :key ===')

function patchInPlace(oldList, newList) {
  const ops = []
  const len = Math.max(oldList.length, newList.length)

  for (let i = 0; i < len; i++) {
    if (i >= newList.length) {
      ops.push(`Удалить DOM-узел на позиции ${i}`)
    } else if (i >= oldList.length) {
      ops.push(`Создать новый DOM-узел: "${newList[i].name}"`)
    } else if (oldList[i].id !== newList[i].id) {
      ops.push(`Патч узла [${i}]: заменить "${oldList[i].name}" -> "${newList[i].name}" (перезаписать контент)`)
    } else {
      ops.push(`Узел [${i}] не изменился: "${oldList[i].name}"`)
    }
  }
  return ops
}

const original = [
  { id: 1, name: 'Яблоко' },
  { id: 2, name: 'Банан' },
  { id: 3, name: 'Вишня' },
]

// Удаляем первый элемент — остальные сдвигаются
const afterDelete = [
  { id: 2, name: 'Банан' },
  { id: 3, name: 'Вишня' },
]

patchInPlace(original, afterDelete).forEach(op => console.log(' ', op))
console.log('Результат: Vue перезаписал содержимое первых двух узлов вместо удаления одного')

// --- С :key: умный diff ---
console.log('\n=== Обновление списка С :key ===')

function keyedDiff(oldList, newList) {
  const oldMap = new Map(oldList.map(item => [item.id, item]))
  const newMap = new Map(newList.map(item => [item.id, item]))
  const ops = []

  // Найти удалённые
  for (const [id, item] of oldMap) {
    if (!newMap.has(id)) {
      ops.push(`Удалить DOM-узел для id=${id} ("${item.name}")`)
    }
  }

  // Найти добавленные и перемещённые
  newList.forEach((item, newIndex) => {
    if (!oldMap.has(item.id)) {
      ops.push(`Создать новый DOM-узел для id=${item.id} ("${item.name}") на позиции ${newIndex}`)
    } else {
      const oldIndex = oldList.findIndex(i => i.id === item.id)
      if (oldIndex !== newIndex) {
        ops.push(`Переместить id=${item.id} ("${item.name}") с позиции ${oldIndex} на ${newIndex}`)
      } else {
        ops.push(`id=${item.id} ("${item.name}") остался на месте`)
      }
    }
  })

  return ops
}

keyedDiff(original, afterDelete).forEach(op => console.log(' ', op))
console.log('Результат: Vue точно удалил один узел, остальные не тронул')

// --- Почему индекс как key опасен ---
console.log('\n=== Почему :key="index" — плохая практика ===')
const withInput = [
  { id: 1, name: 'Задача A', userInput: 'мой черновик' },
  { id: 2, name: 'Задача B', userInput: '' },
  { id: 3, name: 'Задача C', userInput: '' },
]
const afterDeleteFirst = withInput.slice(1)

console.log('До удаления:  input[0].userInput =', withInput[0].userInput)
console.log('После удаления элемента id=1:')
console.log('  С key=index: DOM-узел с userInput="мой черновик" остался на позиции 0')
console.log('  Теперь он отображает "Задача B" — но черновик не пропал!')
console.log('  С key=id: Vue удалил правильный узел, черновик исчез вместе с задачей A')