2026年3月25日

React 拖放:無需第三方函式庫的完整方案

拖放是使用者期望「理所當然能用」的互動之一。無論是對任務看板重新排序、透過拖動檔案上傳,還是讓使用者在儀表板中重新排列小工具,抓取並移動的操作都讓人感覺自然流暢。然而大多數 React 教學一開始就引入像 react-dnddnd-kit 這樣的重量級函式庫——它們功能強大,但對許多常見場景來說增加了過多的套件體積和概念負擔。

如果只需一次 Hook 呼叫就能獲得流暢、可用於生產的拖放行為呢?本文將從原生瀏覽器 API 出發,分析它們為何難用,然後用 ReactUse 中的兩個輕量 Hook:useDraggableuseDropZone 來解決同樣的問題。

手動實作:自行處理指標事件

讓元素可拖曳的最基本方式是手動監聽 pointerdownpointermovepointerup 事件。通常的寫法如下:

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 等級就會立刻暴露出來:

  1. 容器邊界。 如果你想讓元素保持在父容器內部,就需要在每次移動時讀取容器尺寸並限制位置。這意味著每幀都要在兩個元素上呼叫 getBoundingClientRect

  2. 指標類型。 上面的程式碼處理了滑鼠事件,但觸控和觸控筆呢?PointerEvent API 統一了它們,但按指標類型過濾(例如停用觸控筆拖動)需要額外的條件判斷。

  3. 拖曳控制把手。 有時可拖曳的觸發區域只是卡片內部的一個標題列。你需要將「觸發」元素和「移動」元素分離,並相應地連接事件。

  4. 事件清理。 忘記移除監聽器——或者在 useEffect 中使用了錯誤的依賴——會導致諸如放開滑鼠後元素仍在移動之類的隱蔽 Bug。

  5. 放置區域。 HTML5 拖放 API 引入了 dragenterdragoverdragleavedrop 事件。協調這些事件——尤其是子元素上惡名昭彰的 dragenter/dragleave 閃爍問題——非常容易出錯。

這些正是 useDraggableuseDropZone 開箱即用要解決的問題。

useDraggable:一個 Hook,完全掌控

useDraggable 接受一個目標元素的 ref 和一個可選的設定物件。它回傳當前的 xy 位置、一個表示元素是否正在被拖曳的布林值,以及一個 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 解決拖放的另一半:接收放置。它處理全部四個拖曳事件(dragenterdragoverdragleavedrop),阻止瀏覽器預設開啟檔案的行為,並透過內部計數器解決了 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 為我們提供了「完成」欄的即時 leftrighttopbottom 值,以便在拖曳結束時進行碰撞偵測。
  • onEnd 回呼在未落在目標上時將卡片彈回 { x: 0, y: 0 }。搭配 CSS transition 產生令人滿意的橡皮筋效果。
  • 無需外部狀態函式庫。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>
  );
}

什麼時候仍然需要完整的函式庫

useDraggableuseDropZone 用最少的程式碼涵蓋了絕大多數拖放場景。然而,如果你的需求包含複雜的可排序清單(帶動畫過場)、具有鍵盤無障礙存取的多容器排序,或包含上千項的虛擬化清單,像 dnd-kit 這樣的專用函式庫仍然是更好的選擇。關鍵在於,你並不需要在每種情況下都引入一個——對許多專案來說,一對 Hook 就足夠了。

安裝

npm i @reactuses/core

相關 Hook


ReactUse 提供了 100+ 個 React Hook。探索所有 Hook →