javascript

Vue DnD Kit 3.0: переписал свою dnd-либу для Vue с нуля

  • среда, 4 ноября 2026 г. в 00:00:13
https://habr.com/ru/articles/1102964/

Я уже писал тут про Vue DnD Kit, свою либу для drag and drop во Vue. С тех пор вышла 3.0, и от прошлой версии там мало что осталось: переписал все с нуля. Новое API, анимации из коробки, канбан и деревья без танцев с бубном. Ну и граблей по дороге собрал прилично, про них тоже расскажу.

Что было не так со второй версией

Вторая версия работала, ей пользовались, в issues писали. Но проблема у нее была одна и большая: она давала только низкоуровневые makeDraggable и makeDroppable, а все остальное вы собирали сами.

Нужен обычный сортируемый список? Прокидываешь в каждый элемент индекс и весь массив, ловишь onDrop, зовешь suggestSort, пишешь результат, оборачиваешь все в <TransitionGroup>, чтобы хоть как-то анимировалось. Плейсхолдер рисуешь сам. Канбан это еще больше кода, дерево вообще отдельный квест.

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

Аудит

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

  • 57 багов;

  • около 30 мест, где дока и код расходятся

  • 24 фичи, которые v2 умела, а в моих первых планах на v3 их не было (тот же автоскролл в том виде в котором он щас есть). Хорошо, что это всплыло до начала работы.

Из любимого:

  • в доке задержка активации в миллисекундах, а код считал ее в секундах

  • CJS-сборка сломана: .cjs.js в пакете с "type": "module"

  • любой mouseup на странице затирал user-select и touch-action, даже если вы ставили их сами

  • типы жили в рукописных .d.ts с потерянными импортами, и часть из них тихо превращалась в any

  • поиск цели под курсором за O(N²).

Короче, латать это смысла не было, решил переписывать.

Начал с RFC. Главный вопрос был такой: строить v3 поверх @dnd-kit/dom (как раз вышел официальный @dnd-kit/vue) или писать свой движок. Выбрал свой. У официальной обертки нету мульти-драга и выделения рамкой, канбан и деревья собираются руками по примерам, а результат применялся вручную через move(). Мне же хотелось, чтобы все работало из коробки и ощущалось как обычный Vue, а не как обертка над чужими классами.

При чем тут Claude

Большую часть работы я делал в паре с Claude Code, скрывать не буду.

Аудит v2 и разбор того, как устроены dnd-kit и библиотеки виртуализации, делали агенты, из этого и собрался RFC. Решения были за мной: что входит в 3.0, как называются опции, какое поведение по умолчанию. Обычно это выглядело как раунды вопросов: вот варианты, вот плюсы и минусы, выбирай. Код писали агенты, иногда несколько параллельно, дальше тесты, дальше я ручками все тыкал.

ИИ сильно ускоряет, но чего хотят пользователи, он не знает, это все равно решаешь сам. И баги он находит не все, много багов в ядре я нашел сам что в коде, что в примерах.

API стал проще

Сортируемый список во второй версии:

Пример кода
<!-- TaskList.vue -->
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import { makeDroppable, type IDragEvent } from '@vue-dnd-kit/core';
import TaskItem from './TaskItem.vue';

const tasks = defineModel<Task[]>({ required: true });
const el = useTemplateRef<HTMLElement>('el');

makeDroppable(
  el,
  {
    events: {
      onDrop(e: IDragEvent) {
        const r = e.helpers.suggestSort('vertical');
        if (r) tasks.value = r.targetItems as Task[];
      },
    },
  },
  () => tasks.value,
);
</script>

<template>
  <div ref="el">
    <TransitionGroup name="list">
      <TaskItem
        v-for="(task, index) in tasks"
        :key="task.id"
        :task="task"
        :index="index"
        :tasks="tasks"
      />
    </TransitionGroup>
  </div>
</template>
<!-- TaskItem.vue -->
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import { makeDraggable } from '@vue-dnd-kit/core';

const props = defineProps<{ task: Task; index: number; tasks: Task[] }>();
const el = useTemplateRef<HTMLElement>('el');

const { isDragging } = makeDraggable(el, { id: props.task.id }, () => [
  props.index,
  props.tasks,
]);
</script>

<template>
  <div ref="el" :class="{ 'is-dragging': isDragging }">{{ task.title }}</div>
</template>

И то же самое в третьей:

Пример кода
<!-- TaskList.vue -->
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import { makeSortableList } from '@vue-dnd-kit/core';
import TaskItem from './TaskItem.vue';

const tasks = defineModel<Task[]>({ required: true });
const el = useTemplateRef<HTMLElement>('el');

const { items } = makeSortableList(el, { items: tasks });
</script>

<template>
  <ul ref="el">
    <TaskItem v-for="task in items" :key="task.id" :task="task" />
  </ul>
</template>
<!-- TaskItem.vue -->
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import { makeSortableItem } from '@vue-dnd-kit/core';

const props = defineProps<{ task: Task }>();
const el = useTemplateRef<HTMLElement>('el');

const { isPlaceholder } = makeSortableItem(el, { item: () => props.task });
</script>

<template>
  <li ref="el" :class="{ slot: isPlaceholder }">{{ task.title }}</li>
</template>

Ни индексов, ни onDrop, ни TransitionGroup. Анимации, клавиатура, автоскролл и озвучка для скринридеров уже внутри.

Вся фишка в черновике. items из makeSortableList это черновик вашего массива: пока тащишь, библиотека переставляет элементы в нем, а ваши данные не трогает. На дропе пишет новый порядок в ваш ref. Если источник readonly (Pinia, данные с сервера), ничего не пишет, а отдает готовый результат:

const { items } = makeSortableList(listRef, {
  id: 'todo',
  group: 'board',
  items: () => store.lists.todo, // геттер, только для чтения
  onDrop: (e) => {
    e.preventDefault();
    for (const [listId, next] of e.result) store.setList(listId, next);
  },
});

Канбан это просто одинаковый group у колонок:

const { items } = makeSortableList(listRef, {
  items: toRef(props.column, 'tasks'),
  group: 'board',
});

Что нового

  • Анимации по умолчанию: соседи разъезжаются, элемент после дропа летит в свой слот, при отмене возвращается назад. Можно выключить или подсунуть свои на GSAP или motion. prefers-reduced-motion учитывается.

  • Копирование из палитры (pull: 'clone'), swap вместо сдвига, корзина, удаление дропом мимо списка.

  • Деревья: вложение сдвигом вбок или наведением на строку, лимит глубины.

  • Мульти-драг: выделяешь Ctrl/Cmd-кликом или рамкой, и все едет одним блоком.

  • Асинхронный onBeforeDrop: можно показать диалог и дождаться ответа. Принять, отклонить или вернуть 'continue', и драг продолжится.

  onBeforeDrop: async (e) => {
    const ok = await confirm(`Перенести ${e.items.length} задач в архив?`);
    return ok ? 'accept' : 'reject';
  },
  • Виртуализация с TanStack Virtual и VueUse, тысячи строк. В доке есть пример таблицы на Nuxt UI с поиском и виртуализацией.

  • Нативный drag and drop: файлы с рабочего стола, ссылки из соседней вкладки и текст из другого приложения работают как обычный драг, цели подсвечиваются, списки раздвигаются. Свои элементы можно вытащить за пределы страницы и даже в другое окно приложения.

  makeDroppable(zone, {
    external: { files: 'image/*' },
    onDrop: (e) => upload(e.external!.files),
  });
  • Клавиатура и скринридеры: пробелом берешь, стрелками двигаешь, каждое действие озвучивается. Тексты по умолчанию английские, можно подставить свои.

  • Никаких компонентов-оберток: элементы ваши, так что компоненты Nuxt UI, Vuetify или любого другого кита таскаются как есть. (Возможно возрожу пакет с компонентами)

  • DevTools: вкладка в Vue DevTools и Nuxt DevTools с живой мини-картой всех списков, логом драгов и правкой опций на лету. И джойстик, которым можно таскать элементы. Зачем? Потому что могу)

  • Модуль для Nuxt и пакет @vue-dnd-kit/actions, чтобы двигать элементы из кода: кнопки "поднять выше", меню, тесты.

А вот разрабатывать стало сложнее

Пользоваться стало проще, а внутри все сильно усложнилось. Ядро сейчас это 108 файлов и около 17 тысяч строк без тестов.

Каждый драг это машина состояний: ожидание, активация, драг, дроп. У каждого выхода из нее (дроп, отмена, ошибка в вашем колбэке, свернутая вкладка, размонтированный провайдер) правило одно: убрать за собой все, что поменял, и отправить ровно одно финальное событие. Легко, пока не появляется мульти-драг между несколькими списками в виртуальном списке с анимациями.

В какой-то момент файл сортировки разросся до двух с лишним тысяч строк, с сорока ветками под разные режимы. Пришлось остановиться и все раскидать: отдельные стратегии под сортировку, swap, копирование и вставку, плюс правило "ни одного файла больше 500 строк". Рефакторинг без единой новой фичи, но без него дальше было не уехать.

Или вот: хотелось, чтобы следующий драг не ждал, пока предыдущий элемент долетит до места. Хватаешь следующую карточку сразу, а ту, что еще летит, можно поймать прямо в полете. Выглядит естественно, а внутри это пачка багов с "прыгающими" элементами при быстрых драгах подряд.

Тесты

  • 628 юнит-тестов в ядре на Vitest, плюс тесты actions, devtools и модуля для Nuxt.

  • Больше 600 e2e на Playwright в Chromium, Firefox и WebKit, плюс отдельный прогон с тачем на эмуляции мобилки.

  • Бюджет размера в CI: если импорт тянет лишний код (условно, простой draggable вдруг подтягивает сортировку), сборка падает.

Пара багов, которые так и нашлись.

В CI на WebKit упал тест автоскролла. Сначала решил, что очередной флакающий тест, а оказалось, реальный баг: если нагруженная машина успевала довести курсор до края контейнера уже к первому кадру драга, автоскролл не включался.

Второй я поймал сам, пока делал пример с таблицей на Nuxt UI. Тащишь строку, держишь курсор ниже последней строки, таблица скроллится, а место вставки стоит на месте. Сначала думал, что это решается модификаторами, но нет, баг ядра. Пофиксил в ядре, а не костылем в примере.

Ну и все примеры я еще руками перетыкал, тесты тестами.

Документация

Доку пересобрал с нуля на Nuxt (Nuxt UI + Nuxt Content):

  • 9 языков, русский тоже есть. Переводит ИИ, и если перевод отстал от английского оригинала, сайт об этом предупреждает.

  • API-справочник генерится из JSDoc типов, а в CI стоит проверка: если у публичной функции нет описания или она не показана ни на одной странице, сборка падает.

  • Больше сотни живых примеров, среди них целые приложения: канбан, календарь, файловый менеджер, блочный редактор, конструктор страниц и шахматы, в которые можно сыграть.

  • Любой пример открывается в StackBlitz в один клик.

  • Дока по v1 и v2 никуда не делась, она в legacy.

Минусы

  1. API сломан, v3 несовместима с v2. Есть гайд по миграции с таблицей старых и новых имен, но переписывать все равно придется. С ^2 в package.json вы остаетесь на второй версии, пока сами не обновитесь.

  2. Размер. Сортируемый список около 49 КБ gzip (с tree-shaking, без Vue), вся библиотека около 57 КБ, голые draggable и droppable около 28 КБ. Не 5 КБ, да. Это цена анимаций, доступности, виртуализации и нативного dnd, но если нужно подвигать один квадратик, будет перебор.

  3. Только свежие браузеры: Chrome и Edge 104+, Safari 15.4+, Firefox 104+.

  4. Мейнтейнер один, это я. Так что issues и PR очень помогают.

Попробовать

npm i @vue-dnd-kit/core

Для Nuxt:

npx nuxi module add @vue-dnd-kit/nuxt

Если пишете с ИИ-ассистентом (интересно кто не пишет), есть скилл, чтобы он знал API v3 и не путал его со второй версией:

npx skills add ZiZIGY/vue-dnd-kit

Ссылки:

Напоследок

Скажу нескромно: другой библиотеки для Vue, где все это есть в одном пакете, я сейчас не знаю. Если знаете, кидайте в комменты, правда интересно.

Попробуете, напишите, что зашло, а что нет: чего не хватает, где неудобно, где сломалось. Ну и звездочка на гитхабе не помешает)