普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月26日首页

使用 Hooks 构建无障碍 React 组件

2026年3月26日 14:39

无障碍不是上线前才需要检查的清单,而是从第一行代码开始就需要贯彻的设计约束。谈到 React 中的无障碍,大多数开发者会想到 ARIA 属性、语义化 HTML 和屏幕阅读器支持。这些确实重要。但还有一个完整的无障碍类别很少受到关注:尊重用户在操作系统层面已经设置好的偏好。

每个主流操作系统都允许用户配置减少动画、高对比度、深色模式和文本方向等偏好。这些不是装饰性的选择。启用”减少动画”的用户可能患有前庭功能障碍,动画过渡会让他们感到身体不适。启用高对比度的用户可能视力低下。当你的 React 应用忽略这些信号时,这不仅仅是功能缺失——而是一道屏障。

本文将向你展示如何使用 ReactUse 的 hooks 在 React 中检测和响应这些操作系统级别的偏好。我们将覆盖减少动画、对比度偏好、颜色方案检测、焦点管理和文本方向——然后将所有内容整合到一个实际的组件中。

手动监听媒体查询的问题

浏览器通过 CSS 媒体查询(如 prefers-reduced-motionprefers-contrast 和 prefers-color-scheme)暴露操作系统级别的偏好。你可以在 JavaScript 中使用 window.matchMedia 来读取这些值。手动实现的方式如下:

import { useState, useEffect } from "react";

function useManualReducedMotion(): boolean {
  const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);

  useEffect(() => {
    const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
    setPrefersReducedMotion(mediaQuery.matches);

    const handler = (event: MediaQueryListEvent) => {
      setPrefersReducedMotion(event.matches);
    };

    mediaQuery.addEventListener("change", handler);
    return () => mediaQuery.removeEventListener("change", handler);
  }, []);

  return prefersReducedMotion;
}

这段代码能工作,但存在问题。你需要处理 SSR(window 不存在的情况)、管理事件监听器的清理,并且需要为每个想要跟踪的媒体查询重复这个模式。将这个模式乘以减少动画、对比度、颜色方案和其他查询,你最终会得到大量容易出错的样板代码。

ReactUse 提供的 hooks 封装了这个模式,包含正确的 SSR 处理、适当的清理逻辑,以及当用户更改系统偏好时的实时更新。

useReducedMotion:尊重动画偏好

useReducedMotion hook 检测用户是否在设备上启用了”减少动画”设置。这是你能使用的最具影响力的无障碍 hooks 之一,因为动画可能会给前庭功能障碍的用户带来真实的身体不适。

import { useReducedMotion } from "@reactuses/core";

function AnimatedCard({ children }: { children: React.ReactNode }) {
  const prefersReducedMotion = useReducedMotion();

  return (
    <div
      style={{
        transition: prefersReducedMotion
          ? "none"
          : "transform 0.3s ease, opacity 0.3s ease",
        animation: prefersReducedMotion ? "none" : "fadeIn 0.5s ease-in",
      }}
    >
      {children}
    </div>
  );
}

这里的关键不是简单地禁用动画——而是在没有动画的情况下提供等价的体验。对于大多数用户需要 500ms 淡入的卡片,对于偏好减少动画的用户应该立即显示。内容相同,只是呈现方式不同。

你还可以使用这个 hook 在不同的动画策略之间切换:

import { useReducedMotion } from "@reactuses/core";

function PageTransition({ children }: { children: React.ReactNode }) {
  const prefersReducedMotion = useReducedMotion();

  if (prefersReducedMotion) {
    // 即时过渡——没有动画,但仍然有视觉变化
    return <div style={{ opacity: 1 }}>{children}</div>;
  }

  // 为未选择减少动画的用户提供完整的滑入动画
  return (
    <div
      style={{
        animation: "slideInFromRight 0.4s ease-out",
      }}
    >
      {children}
    </div>
  );
}

usePreferredContrast:适应对比度需求

usePreferredContrast hook 读取 prefers-contrast 媒体查询,告诉你用户想要更多对比度、更少对比度,还是没有偏好。这对视力低下的用户至关重要。

import { usePreferredContrast } from "@reactuses/core";

function ThemedButton({ children, onClick }: {
  children: React.ReactNode;
  onClick: () => void;
}) {
  const contrast = usePreferredContrast();

  const getButtonStyles = () => {
    switch (contrast) {
      case "more":
        return {
          backgroundColor: "#000000",
          color: "#FFFFFF",
          border: "3px solid #FFFFFF",
          fontWeight: 700 as const,
        };
      case "less":
        return {
          backgroundColor: "#E8E8E8",
          color: "#333333",
          border: "1px solid #CCCCCC",
          fontWeight: 400 as const,
        };
      default:
        return {
          backgroundColor: "#3B82F6",
          color: "#FFFFFF",
          border: "2px solid transparent",
          fontWeight: 500 as const,
        };
    }
  };

  return (
    <button onClick={onClick} style={getButtonStyles()}>
      {children}
    </button>
  );
}

当用户请求更高对比度时,你应该增大前景和背景颜色之间的差异、使用更粗的字体粗细、让边框更明显。当他们请求更低对比度时,柔化视觉强度。默认分支处理未设置偏好的用户。

usePreferredColorScheme:系统主题检测

usePreferredColorScheme hook 告诉你用户的操作系统是设置为浅色模式、深色模式,还是没有偏好。这是构建主题感知组件的基础。

import { usePreferredColorScheme } from "@reactuses/core";

function AdaptiveCard({ title, body }: { title: string; body: string }) {
  const colorScheme = usePreferredColorScheme();

  const isDark = colorScheme === "dark";

  return (
    <div
      style={{
        backgroundColor: isDark ? "#1E293B" : "#FFFFFF",
        color: isDark ? "#E2E8F0" : "#1E293B",
        border: `1px solid ${isDark ? "#334155" : "#E2E8F0"}`,
        borderRadius: "8px",
        padding: "24px",
      }}
    >
      <h3 style={{ marginTop: 0 }}>{title}</h3>
      <p>{body}</p>
    </div>
  );
}

如果你只需要一个简单的布尔值判断,ReactUse 还提供了 usePreferredDark,当用户偏好深色方案时返回 true。如果你需要一个完整的深色模式切换并持久化用户的选择,useDarkMode 可以开箱即用。

对于更细粒度的媒体查询控制,useMediaQuery 让你订阅任何 CSS 媒体查询字符串并获得实时更新。

useFocus:键盘导航和焦点管理

键盘导航是核心无障碍要求。无法使用鼠标的用户依赖 Tab 键在交互元素之间移动。useFocus hook 提供了对焦点的编程控制,这对于模态对话框、下拉菜单和动态内容至关重要。

import { useRef } from "react";
import { useFocus } from "@reactuses/core";

function SearchBar() {
  const inputRef = useRef<HTMLInputElement>(null);
  const [focused, setFocused] = useFocus(inputRef);

  return (
    <div>
      <input
        ref={inputRef}
        type="search"
        placeholder="Search..."
        style={{
          outline: focused ? "2px solid #3B82F6" : "1px solid #D1D5DB",
          padding: "8px 12px",
          borderRadius: "6px",
          width: "100%",
        }}
      />
      <button onClick={() => setFocused(true)}>
        Focus Search (Ctrl+K)
      </button>
    </div>
  );
}

这个 hook 同时返回当前焦点状态和一个设置函数。你可以使用焦点状态来应用视觉指示器(超出浏览器默认样式),并使用设置函数来编程式地移动焦点——例如,当模态框打开时或当触发键盘快捷键时。

将此与 useActiveElement 配合使用,可以跟踪整个应用中当前拥有焦点的元素,这对于构建焦点陷阱和跳过导航链接非常有用。

useTextDirection:RTL 和 LTR 支持

国际化和无障碍有很大的重叠。useTextDirection hook 检测和管理文档的文本方向,支持从左到右(LTR)和从右到左(RTL)布局。

import { useTextDirection } from "@reactuses/core";

function NavigationMenu() {
  const [dir, setDir] = useTextDirection();

  return (
    <nav
      style={{
        display: "flex",
        flexDirection: dir === "rtl" ? "row-reverse" : "row",
        gap: "16px",
        padding: "12px 24px",
      }}
    >
      <a href="/">Home</a>
      <a href="/about">About</a>
      <a href="/contact">Contact</a>
      <button onClick={() => setDir(dir === "rtl" ? "ltr" : "rtl")}>
        Toggle Direction
      </button>
    </nav>
  );
}

RTL 支持影响的不仅仅是文本对齐。导航顺序、图标位置和 margin/padding 方向都需要翻转。通过使用 useTextDirection 作为唯一数据源,你可以构建自动适应的布局逻辑。

综合示例:无障碍通知组件

下面是一个将多个无障碍 hooks 整合到单个组件中的实际示例——一个尊重动画偏好、适应对比度设置、跟随系统颜色方案并正确管理焦点的通知提示:

import { useRef, useEffect } from "react";
import {
  useReducedMotion,
  usePreferredContrast,
  usePreferredColorScheme,
  useFocus,
} from "@reactuses/core";

interface NotificationProps {
  message: string;
  type: "success" | "error" | "info";
  visible: boolean;
  onDismiss: () => void;
}

function AccessibleNotification({
  message,
  type,
  visible,
  onDismiss,
}: NotificationProps) {
  const prefersReducedMotion = useReducedMotion();
  const contrast = usePreferredContrast();
  const colorScheme = usePreferredColorScheme();
  const dismissRef = useRef<HTMLButtonElement>(null);
  const [, setFocused] = useFocus(dismissRef);

  const isDark = colorScheme === "dark";
  const isHighContrast = contrast === "more";

  // 通知出现时将焦点移至关闭按钮
  useEffect(() => {
    if (visible) {
      setFocused(true);
    }
  }, [visible, setFocused]);

  if (!visible) return null;

  const colors = {
    success: {
      bg: isDark ? "#064E3B" : "#ECFDF5",
      border: isHighContrast ? "#FFFFFF" : isDark ? "#10B981" : "#6EE7B7",
      text: isDark ? "#A7F3D0" : "#065F46",
    },
    error: {
      bg: isDark ? "#7F1D1D" : "#FEF2F2",
      border: isHighContrast ? "#FFFFFF" : isDark ? "#EF4444" : "#FCA5A5",
      text: isDark ? "#FECACA" : "#991B1B",
    },
    info: {
      bg: isDark ? "#1E3A5F" : "#EFF6FF",
      border: isHighContrast ? "#FFFFFF" : isDark ? "#3B82F6" : "#93C5FD",
      text: isDark ? "#BFDBFE" : "#1E40AF",
    },
  };

  const scheme = colors[type];

  return (
    <div
      role="alert"
      aria-live="assertive"
      style={{
        position: "fixed",
        top: "16px",
        right: "16px",
        backgroundColor: scheme.bg,
        color: scheme.text,
        border: `${isHighContrast ? "3px" : "1px"} solid ${scheme.border}`,
        borderRadius: "8px",
        padding: "16px 20px",
        maxWidth: "400px",
        display: "flex",
        alignItems: "center",
        gap: "12px",
        fontWeight: isHighContrast ? 700 : 400,
        // 尊重动画偏好
        animation: prefersReducedMotion ? "none" : "slideIn 0.3s ease-out",
        transition: prefersReducedMotion ? "none" : "opacity 0.2s ease",
      }}
    >
      <span style={{ flex: 1 }}>{message}</span>
      <button
        ref={dismissRef}
        onClick={onDismiss}
        aria-label="关闭通知"
        style={{
          background: "none",
          border: `1px solid ${scheme.text}`,
          color: scheme.text,
          cursor: "pointer",
          borderRadius: "4px",
          padding: "4px 8px",
          fontWeight: isHighContrast ? 700 : 500,
        }}
      >
        关闭
      </button>
    </div>
  );
}

这个组件展示了几个无障碍原则的协同工作:

  1. role="alert" 和 aria-live="assertive"  确保屏幕阅读器立即播报通知。
  2. useReducedMotion 为偏好减少动画的用户禁用滑入动画。
  3. usePreferredContrast 为需要更高对比度的用户增加边框宽度和字体粗细。
  4. usePreferredColorScheme 根据用户的浅色或深色主题适配所有颜色。
  5. useFocus 将键盘焦点移至关闭按钮,使用户无需使用鼠标就能操作通知。

为什么 Hooks 是无障碍的正确抽象

Hooks 具有可组合性。每个无障碍关注点都封装在自己的 hook 中,你可以按需组合它们。一个简单的按钮可能只使用 usePreferredContrast。一个复杂的模态框可能使用我们介绍的全部五个 hooks。这些 hooks 互相独立,这意味着你可以逐步采用它们,无需重构现有代码。

Hooks 还能实时响应变化。如果用户在你的应用打开时从浅色切换到深色模式,hooks 会更新,你的组件会使用新的偏好重新渲染。这是仅使用 CSS 的方案(依赖静态类名)难以实现的。

安装

通过包管理器安装 ReactUse:

npm install @reactuses/core

然后导入你需要的 hooks:

import {
  useReducedMotion,
  usePreferredContrast,
  usePreferredColorScheme,
  useFocus,
  useTextDirection,
} from "@reactuses/core";

相关 Hooks

ReactUse 提供了 100 多个 React hooks。探索全部 →

昨天 — 2026年3月25日首页

React 拖拽:无需第三方库的完整方案

2026年3月25日 09:50

拖拽是用户期望"理所当然能用"的交互之一。无论是对任务看板重新排序、通过拖动文件上传,还是让用户在仪表盘中重新排列小组件,抓取并移动的操作都让人感觉自然流畅。然而大多数 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 →


本文最初发布于 ReactUse 博客

❌
❌