2026年3月25日
React 拖放:無需第三方函式庫的完整方案
拖放是使用者期望「理所當然能用」的互動之一。無論是對任務看板重新排序、透過拖動檔案上傳,還是讓使用者在儀表板中重新排列小工具,抓取並移動的操作都讓人感覺自然流暢。然而大多數 React 教學一開始就引入像 react-dnd 或 dnd-kit 這樣的重量級函式庫——它們功能強大,但對許多常見場景來說增加了過多的套件體積和概念負擔。
如果只需一次 Hook 呼叫就能獲得流暢、可用於生產的拖放行為呢?本文將從原生瀏覽器 API 出發,分析它們為何難用,然後用 ReactUse 中的兩個輕量 Hook:useDraggable 和 useDropZone 來解決同樣的問題。
手動實作:自行處理指標事件
讓元素可拖曳的最基本方式是手動監聽 pointerdown、pointermove 和 pointerup 事件。通常的寫法如下:
import { useEffect, useRef, useState } from "react";
function ManualDraggable() {
const ref = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const delta = useRef({ x: 0, y: 0 });
useEffect(() => {
const el = ref.current;
if (!el) return;
const onPointerDown = (e: PointerEvent) => {
const rect = el.getBoundingClientRect();
delta.current = { x: e.clientX - rect.left, y: e.clientY - rect.top };
setIsDragging(true);
};
const onPointerMove = (e: PointerEvent) => {
if (!isDragging) return;
setPosition({
x: e.clientX - delta.current.x,
y: e.clientY - delta.current.y,
});
};
const onPointerUp = () => setIsDragging(false);
el.addEventListener("pointerdown", onPointerDown);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
return () => {
el.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
};
}, [isDragging]);
return (
<div
ref={ref}
style={{
position: "fixed",
left: position.x,
top: position.y,
cursor: isDragging ? "grabbing" : "grab",
padding: 16,
background: "#4f46e5",
color: "#fff",
borderRadius: 8,
}}
>
拖動我
</div>
);
}
能跑起來——但看看你需要管理多少狀態。而這還只是最簡單的版本。實際需求會迅速疊加更多複雜性。
為什麼手動實作拖放很難
上面的程式碼有幾個不足之處,一旦超出 Demo 等級就會立刻暴露出來:
-
容器邊界。 如果你想讓元素保持在父容器內部,就需要在每次移動時讀取容器尺寸並限制位置。這意味著每幀都要在兩個元素上呼叫
getBoundingClientRect。 -
指標類型。 上面的程式碼處理了滑鼠事件,但觸控和觸控筆呢?
PointerEventAPI 統一了它們,但按指標類型過濾(例如停用觸控筆拖動)需要額外的條件判斷。 -
拖曳控制把手。 有時可拖曳的觸發區域只是卡片內部的一個標題列。你需要將「觸發」元素和「移動」元素分離,並相應地連接事件。
-
事件清理。 忘記移除監聽器——或者在
useEffect中使用了錯誤的依賴——會導致諸如放開滑鼠後元素仍在移動之類的隱蔽 Bug。 -
放置區域。 HTML5 拖放 API 引入了
dragenter、dragover、dragleave和drop事件。協調這些事件——尤其是子元素上惡名昭彰的dragenter/dragleave閃爍問題——非常容易出錯。
這些正是 useDraggable 和 useDropZone 開箱即用要解決的問題。
useDraggable:一個 Hook,完全掌控
useDraggable 接受一個目標元素的 ref 和一個可選的設定物件。它回傳當前的 x 和 y 位置、一個表示元素是否正在被拖曳的布林值,以及一個 setter(用於程式化地移動元素)。
import { useDraggable } from "@reactuses/core";
import { useRef } from "react";
function DraggableCard() {
const el = useRef<HTMLDivElement>(null);
const [x, y, isDragging] = useDraggable(el, {
initialValue: { x: 100, y: 100 },
});
return (
<div
ref={el}
style={{
position: "fixed",
left: x,
top: y,
cursor: isDragging ? "grabbing" : "grab",
padding: 16,
background: isDragging ? "#4338ca" : "#4f46e5",
color: "#fff",
borderRadius: 8,
transition: isDragging ? "none" : "box-shadow 0.2s",
boxShadow: isDragging ? "0 8px 24px rgba(0,0,0,0.2)" : "none",
userSelect: "none",
touchAction: "none",
}}
>
隨意拖動我
</div>
);
}
這就是整個元件。無需手動事件監聽器。無需清理邏輯。觸控、滑鼠和觸控筆預設都能運作。
限制在容器內
傳入一個 containerElement ref,Hook 會自動夾緊位置,使元素不會離開容器:
import { useDraggable } from "@reactuses/core";
import { useRef } from "react";
function BoundedDrag() {
const container = useRef<HTMLDivElement>(null);
const el = useRef<HTMLDivElement>(null);
const [x, y, isDragging] = useDraggable(el, {
containerElement: container,
initialValue: { x: 0, y: 0 },
});
return (
<div
ref={container}
style={{
position: "relative",
width: 400,
height: 300,
border: "2px dashed #cbd5e1",
borderRadius: 8,
}}
>
<div
ref={el}
style={{
position: "absolute",
left: x,
top: y,
width: 80,
height: 80,
background: "#4f46e5",
borderRadius: 8,
cursor: isDragging ? "grabbing" : "grab",
touchAction: "none",
}}
/>
</div>
);
}
無需手動的夾緊計算。Hook 會讀取容器的捲動和客戶端尺寸,自動限制元素位置。
使用拖曳控制把手
通常你只想讓元素的特定部分——比如一個標題列——觸發拖曳。傳入 handle ref 即可:
import { useDraggable } from "@reactuses/core";
import { useRef } from "react";
function DraggablePanel() {
const panel = useRef<HTMLDivElement>(null);
const handle = useRef<HTMLDivElement>(null);
const [x, y, isDragging] = useDraggable(panel, {
handle,
initialValue: { x: 200, y: 150 },
});
return (
<div
ref={panel}
style={{
position: "fixed",
left: x,
top: y,
width: 280,
background: "#fff",
borderRadius: 8,
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
overflow: "hidden",
touchAction: "none",
}}
>
<div
ref={handle}
style={{
padding: "8px 12px",
background: "#4f46e5",
color: "#fff",
cursor: isDragging ? "grabbing" : "grab",
userSelect: "none",
}}
>
從這裡拖動
</div>
<div style={{ padding: 12 }}>
<p>此內容區域不會觸發拖曳。</p>
</div>
</div>
);
}
面板的主體仍然是可互動的——你可以選取文字、點擊按鈕或捲動——而只有標題列是拖曳觸發器。
useDropZone:輕鬆實現檔案拖放
useDropZone 解決拖放的另一半:接收放置。它處理全部四個拖曳事件(dragenter、dragover、dragleave、drop),阻止瀏覽器預設開啟檔案的行為,並透過內部計數器解決了 dragleave 閃爍問題。
import { useDropZone } from "@reactuses/core";
import { useRef, useState } from "react";
function FileUploader() {
const dropRef = useRef<HTMLDivElement>(null);
const [files, setFiles] = useState<File[]>([]);
const isOver = useDropZone(dropRef, (droppedFiles) => {
if (droppedFiles) {
setFiles((prev) => [...prev, ...droppedFiles]);
}
});
return (
<div
ref={dropRef}
style={{
padding: 40,
border: `2px dashed ${isOver ? "#4f46e5" : "#cbd5e1"}`,
borderRadius: 8,
background: isOver ? "#eef2ff" : "#f8fafc",
textAlign: "center",
transition: "all 0.15s",
}}
>
{isOver ? (
<p>放開以上傳</p>
) : (
<p>將檔案拖到這裡上傳</p>
)}
{files.length > 0 && (
<ul style={{ textAlign: "left", marginTop: 16 }}>
{files.map((f, i) => (
<li key={i}>
{f.name} ({(f.size / 1024).toFixed(1)} KB)
</li>
))}
</ul>
)}
</div>
);
}
isOver 布林值讓你在檔案進入時立即重新設定區域樣式,給使用者清晰的視覺回饋。無需 e.preventDefault() 樣板程式碼,不用和閃爍的 dragleave 事件搏鬥。
建構看板風格的卡片拖動
讓我們在一個更貼近實際的例子中結合兩個 Hook——一個可拖曳的卡片,鬆開時彈回原位,以及一個接受它的放置區域。我們還將使用 useElementBounding 來讀取區域位置以做視覺回饋。
import { useDraggable, useDropZone, useElementBounding } from "@reactuses/core";
import { useRef, useState } from "react";
interface Task {
id: string;
title: string;
}
function KanbanBoard() {
const [todo, setTodo] = useState<Task[]>([
{ id: "1", title: "設計原型" },
{ id: "2", title: "撰寫 API 規範" },
]);
const [done, setDone] = useState<Task[]>([
{ id: "3", title: "建置 CI 管線" },
]);
const doneZoneRef = useRef<HTMLDivElement>(null);
const todoZoneRef = useRef<HTMLDivElement>(null);
const isOverDone = useDropZone(doneZoneRef, (files) => {
// 此範例忽略檔案拖放
});
const isOverTodo = useDropZone(todoZoneRef, (files) => {
// 此範例忽略檔案拖放
});
const doneBounds = useElementBounding(doneZoneRef);
return (
<div style={{ display: "flex", gap: 24, padding: 24 }}>
<div>
<h3>待辦</h3>
<div
ref={todoZoneRef}
style={{
minHeight: 200,
padding: 12,
background: isOverTodo ? "#fef3c7" : "#f1f5f9",
borderRadius: 8,
}}
>
{todo.map((task) => (
<TaskCard
key={task.id}
task={task}
onDrop={() => {
setTodo((prev) => prev.filter((t) => t.id !== task.id));
setDone((prev) => [...prev, task]);
}}
targetBounds={doneBounds}
/>
))}
</div>
</div>
<div>
<h3>完成</h3>
<div
ref={doneZoneRef}
style={{
minHeight: 200,
padding: 12,
background: isOverDone ? "#d1fae5" : "#f1f5f9",
borderRadius: 8,
}}
>
{done.map((task) => (
<div
key={task.id}
style={{
padding: 12,
marginBottom: 8,
background: "#fff",
borderRadius: 6,
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
}}
>
{task.title}
</div>
))}
</div>
</div>
</div>
);
}
function TaskCard({
task,
onDrop,
targetBounds,
}: {
task: Task;
onDrop: () => void;
targetBounds: ReturnType<typeof useElementBounding>;
}) {
const el = useRef<HTMLDivElement>(null);
const [x, y, isDragging, setPosition] = useDraggable(el, {
initialValue: { x: 0, y: 0 },
onEnd: (pos) => {
// 檢查卡片是否在「完成」欄上方釋放
if (
targetBounds &&
pos.x >= targetBounds.left &&
pos.x <= targetBounds.right &&
pos.y >= targetBounds.top &&
pos.y <= targetBounds.bottom
) {
onDrop();
}
// 彈回原始位置
setPosition({ x: 0, y: 0 });
},
});
return (
<div
ref={el}
style={{
position: "relative",
left: x,
top: y,
padding: 12,
marginBottom: 8,
background: isDragging ? "#e0e7ff" : "#fff",
borderRadius: 6,
boxShadow: isDragging
? "0 8px 24px rgba(0,0,0,0.15)"
: "0 1px 3px rgba(0,0,0,0.1)",
cursor: isDragging ? "grabbing" : "grab",
zIndex: isDragging ? 50 : 1,
touchAction: "none",
userSelect: "none",
transition: isDragging ? "none" : "all 0.2s ease",
}}
>
{task.title}
</div>
);
}
幾個值得注意的關鍵點:
useElementBounding為我們提供了「完成」欄的即時left、right、top和bottom值,以便在拖曳結束時進行碰撞偵測。onEnd回呼在未落在目標上時將卡片彈回{ x: 0, y: 0 }。搭配 CSStransition產生令人滿意的橡皮筋效果。- 無需外部狀態函式庫。React 的
useState對於這個複雜度完全足夠。
搭配其他 Hook 增強體驗
ReactUse 的 Hook 天然可組合。以下是擴展上述範例的幾種方式:
useMouse——全域追蹤游標位置,在拖曳過程中顯示自訂拖曳游標或跟隨指標的浮動提示。useEventListener——附加一個keydown監聽器,在使用者按下 Escape 時取消拖曳。useElementSize——動態讀取容器的寬高以計算網格對齊位置(例如將x捨入到儲存格寬度的最近倍數)。
例如,使用 useEventListener 加入 Escape 取消只需幾行程式碼:
import { useDraggable, useEventListener } from "@reactuses/core";
import { useRef } from "react";
function CancelableDrag() {
const el = useRef<HTMLDivElement>(null);
const [x, y, isDragging, setPosition] = useDraggable(el);
useEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Escape" && isDragging) {
setPosition({ x: 0, y: 0 });
}
});
return (
<div
ref={el}
style={{
position: "fixed",
left: x,
top: y,
padding: 16,
background: "#4f46e5",
color: "#fff",
borderRadius: 8,
cursor: isDragging ? "grabbing" : "grab",
touchAction: "none",
}}
>
拖動我(按 Esc 重置)
</div>
);
}
什麼時候仍然需要完整的函式庫
useDraggable 和 useDropZone 用最少的程式碼涵蓋了絕大多數拖放場景。然而,如果你的需求包含複雜的可排序清單(帶動畫過場)、具有鍵盤無障礙存取的多容器排序,或包含上千項的虛擬化清單,像 dnd-kit 這樣的專用函式庫仍然是更好的選擇。關鍵在於,你並不需要在每種情況下都引入一個——對許多專案來說,一對 Hook 就足夠了。
安裝
npm i @reactuses/core
相關 Hook
useDraggable——使用指標事件讓任意元素可拖曳useDropZone——為檔案上傳和拖放操作建立放置區域useElementBounding——取得元素的即時邊界矩形useMouse——全域追蹤滑鼠位置useEventListener——宣告式地附加事件監聽器useElementSize——響應式追蹤元素尺寸
ReactUse 提供了 100+ 個 React Hook。探索所有 Hook →