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

Я уже писал тут про 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.
Минусы
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, где все это есть в одном пакете, я сейчас не знаю. Если знаете, кидайте в комменты, правда интересно.
Попробуете, напишите, что зашло, а что нет: чего не хватает, где неудобно, где сломалось. Ну и звездочка на гитхабе не помешает)
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.