Vue DnD Kit 3.0: переписал свою dnd-либу для Vue с нуля
- среда, 4 ноября 2026 г. в 00:00:13
Я уже писал тут про 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 Code, скрывать не буду.
Аудит v2 и разбор того, как устроены dnd-kit и библиотеки виртуализации, делали агенты, из этого и собрался RFC. Решения были за мной: что входит в 3.0, как называются опции, какое поведение по умолчанию. Обычно это выглядело как раунды вопросов: вот варианты, вот плюсы и минусы, выбирай. Код писали агенты, иногда несколько параллельно, дальше тесты, дальше я ручками все тыкал.
ИИ сильно ускоряет, но чего хотят пользователи, он не знает, это все равно решаешь сам. И баги он находит не все, много багов в ядре я нашел сам что в коде, что в примерах.
Сортируемый список во второй версии:
<!-- 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.
API сломан, v3 несовместима с v2. Есть гайд по миграции с таблицей старых и новых имен, но переписывать все равно придется. С ^2 в package.json вы остаетесь на второй версии, пока сами не обновитесь.
Размер. Сортируемый список около 49 КБ gzip (с tree-shaking, без Vue), вся библиотека около 57 КБ, голые draggable и droppable около 28 КБ. Не 5 КБ, да. Это цена анимаций, доступности, виртуализации и нативного dnd, но если нужно подвигать один квадратик, будет перебор.
Только свежие браузеры: Chrome и Edge 104+, Safari 15.4+, Firefox 104+.
Мейнтейнер один, это я. Так что 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
Ссылки:
дока и примеры: https://zizigy.github.io/vue-dnd-kit/
гайд по миграции с v2: https://zizigy.github.io/vue-dnd-kit/docs/getting-started/migrating-from-v2
Скажу нескромно: другой библиотеки для Vue, где все это есть в одном пакете, я сейчас не знаю. Если знаете, кидайте в комменты, правда интересно.
Попробуете, напишите, что зашло, а что нет: чего не хватает, где неудобно, где сломалось. Ну и звездочка на гитхабе не помешает)