在前端開發中,「拖拉互動(Drag and Drop)」是提升使用者體驗的常見需求。最近T編在開發「拖拉式表單建構器」專案時,深入研究了如何更有效率、也更容易維護地實作這類較複雜的互動功能。dndKit是一個前端用來實現拖拉功能的library,能夠讓開發者更快速的開發一個拖拉功能的原型。
一、架構設計

整體架構大致如下:從「組件庫(Palette)」把表單組件拖到「畫布(Canvas)」上完成排版,最後再生成一個「預覽表單(Preview)」。
T編的做法是把每一個表單組件都封裝成一個物件,並用一個陣列來保存畫布上的內容與順序;預覽頁則根據這個陣列去 render 出實際表單。
拖拉功能當然可以自行實作,不過也可以直接使用成熟的套件。T編這次選擇的是 Claudéric Demers 開發的 dnd-kit。dnd-kit 功能完整,且支援多種前端框架(Vue、React 等)。本篇會以 React 為例來示範。

二、dnd-kit 套件概念
DnD(Drag and Drop)最核心的兩件事是:
- Draggable:可被拖拉的元素(Drag)
- Droppable:可放置的目標區域(Drop)
以表單建構器為例:
- Drag:表單組件(下拉選單、單行文字等)
- Drop:放置組件的位置(畫布)
以下會依照官方文件的典型流程,用官方範例程式做簡要示範。
1. 安裝套件(React)
npm install @dnd-kit/react
2. 將組件變成 Draggable(可拖拉)
使用 useDraggable 這個 hook,並傳入一個獨特的 id。這個 hook 會回傳一個 ref,把它綁到要拖拉的元素上即可。
import {useDraggable} from '@dnd-kit/react';
export function Draggable(props) {
const {ref} = useDraggable({
id: props.id,
});
return <button ref={ref} className="btn">draggable</button>;
}
3. 將組件變成 Droppable(可放置)
使用 useDroppable 這個 hook,並傳入一個獨特的 id。同樣地,這個 hook 會回傳 ref,綁到放置區的元素即可。
import {useDroppable} from '@dnd-kit/react';
function Droppable(props) {
const {isDropTarget, ref} = useDroppable({
id: props.id,
});
return (
<div ref={ref}>
{isDropTarget ? 'Draggable element is over me' : 'Drag something over me'}
</div>
);
}
isDropTarget 是 hook 提供的狀態,用來判斷目前是否有 draggable 元素「移動到 droppable 上方」。你可以用它來切換樣式或提示文字。

4. 將組件變成 Sortable(可排序)
若要在畫布內「拖拉排序」,可以使用 useSortable。傳入一個獨特 id,以及目前元素的 index。
import {useSortable} from '@dnd-kit/react/sortable';
function Sortable({id, index}) {
const {ref} = useSortable({id, index});
return (
<li ref={ref} className="item">Item {id}</li>
);
}
export default function App() {
const items = [1, 2, 3, 4];
return (
<ul className="list">
{items.map((id, index) =>
<Sortable key={id} id={id} index={index} />
)}
</ul>
);
}

5. 整合:用 DragDropProvider 包起來
完成 draggable / droppable 等組件後,需要用 DragDropProvider 包住最上層,才能在裡面統一監聽拖拉事件。
import {DragDropProvider} from '@dnd-kit/react';
import Draggable from './Draggable';
import Droppable from './Droppable';
function App() {
const [isDropped, setIsDropped] = useState(false);
return (
<DragDropProvider
onDragEnd={(event) => {
if (event.canceled) return;
const {target} = event.operation;
setIsDropped(target?.id === 'droppable');
}}
>
{!isDropped && <Draggable />}
<Droppable id="droppable">
{isDropped && <Draggable />}
</Droppable>
</DragDropProvider>
);
}
常用的事件包含:
onBeforeDragStart:拖拉開始前onDragStart:拖拉開始onDragMove:拖拉移動中onDragOver:拖拉經過 droppable 時onDragEnd:拖拉結束
三、開始實作:拖拉式表單建構器
接下來進入本次專案的實作重點。T編會附上部分程式碼與影片,協助理解整體流程。
1. 建立可拖拉的表單組件(Palette Item)
function PaletteItem({ type, label }: { type: FormFieldType; label: string }) {
const id = `palette-${type}`;
const { ref } = useDraggable({
id,
data: { type, source: "palette" as const }, // <--- 攜帶來源與型別
});
return (
<Paper
sx={{
p: 2,
cursor: "grab",
"&:active": { cursor: "grabbing" },
display: "flex",
alignItems: "center",
gap: 1,
}}
ref={ref}
>
<NotesIcon />
<Typography variant="body2">{label}</Typography>
</Paper>
);
}
這裡使用 useDraggable 來讓組件具備拖拉能力。T編額外帶入 data,目的是「攜帶這次拖拉的資訊」。
在後續的畫布中會有兩種可以被拖動的元素:
- 從組件庫拖進畫布的新組件
- 已經在畫布上的組件(用於排序)
因此T編用 source 來區分拖拉來源,以便在事件處理(例如 onDragOver / onDragEnd)時,套用不同的邏輯。

2. 建立畫布(Canvas)
畫布本質上就是一個 droppable 區域,因此做法和前面 useDroppable 的示範一致:把畫布容器變成可放置區即可。

3. 讓畫布上的組件可排序(Sortable)
為了讓畫布上的組件可以自由調整順序,使用 useSortable。
export default function SortableFieldItem({
field,
index,
isSelected,
onSelect,
onDelete,
onChange,
}: SortableFieldItemProps) {
const { isDragging, ref, handleRef, sourceRef, targetRef } = useSortable({
id: field.id,
index,
});
}
這裡T編額外使用了 handleRef、sourceRef、targetRef,原因如下。
handleRef:限制「只能拖拉把手」才能排序
{/* 拖拉把手:只有此區可拖動排序 */}
<IconButton
ref={handleRef}
sx={{
cursor: "grab",
color: "text.secondary",
"&:active": { cursor: "grabbing" },
}}
aria-label="拖動排序"
>
<DragIndicatorIcon fontSize="small" />
</IconButton>
T編不希望整個組件都能被拖動,而是只允許在特定的拖拉把手區域觸發排序。dnd-kit 提供handleRef 來滿足這個需求:只要把 handleRef 綁到把手元素上即可。

sourceRef / targetRef:同時支援「拖拉排序」與「插入新組件」因此這個項目本身同時是 Draggable 也是 Droppable。官方文件也有提到,搭配 useSortable 時可以使用這些 ref 來處理類似情境。因為設計的畫布項目同時需要具備兩種能力:
- 已在畫布上的項目可以被拖動來排序(Draggable)
- 從組件庫拖進來的新組件,也可以「丟到某個項目上方」以控制插入位置(Droppable)

4. 預覽表單(Preview)
最後別忘了把最上層用 DragDropProvider 包起來。T編另外寫了兩個自訂監聽函式,主要用來處理「排序」與「插入位置」相關的邏輯。
<DragDropProvider onDragEnd={handleDragEnd} onDragOver={handleDragOver}>
{內容省略...}
</DragDropProvider>
完成後,把欄位陣列 fields 傳入預覽頁。
export default function FormPreview({ fields, formTitle }: FormPreviewProps) {
預覽頁只要把 fields map 成你需要的 UI,就能產出最終表單。

四、結語
這次用 dnd-kit 實作拖拉式表單建構器,T編覺得最關鍵的核心是:
- 把「來源(Palette)」與「畫布(Canvas)」的資料流切清楚
- 用
data來區分拖拉來源 - 搭配
useDroppable建立放置區 - 用
useSortable處理畫布內排序與插入位置
以上就是 dnd-kit 介紹與本次前端拖拉式表單實作的整理。
![]()