React 可拖拽缩放容器组件 ResizableBox 设计与实现
📌 前因后果:为什么需要这个组件?
在开发 React 应用时,我们经常需要实现可拖拽、可调整大小的 UI 组件,例如:
- 仪表盘面板(Dashboard Panel)
- 可调整大小的弹窗(Resizable Modal)
- 可拖拽的浮动工具栏(Draggable Toolbar)
- 自定义布局编辑器(Layout Editor)
虽然市面上有一些成熟的库(如 react-resizable、react-draggable),但它们往往:
- 功能单一(只支持拖拽或只支持缩放)
- API 复杂(需要组合多个 HOC)
- 不够灵活(难以自定义安全边距、限制范围等)
因此,我决定自己实现一个 ResizableBox,它支持:
✅ 8 个方向调整大小(N, NE, E, SE, S, SW, W, NW)
✅ 自由拖拽移动
✅ 安全边距限制(防止超出视口)
✅ 最小/最大尺寸限制
✅ 自定义边缘手柄(可禁用某些方向的调整)
基本用法
import ResizableBox from "./xxx";
function App() {
return (
<ResizableBox
defaultWidth={300}
defaultHeight={200}
minWidth={100}
minHeight={100}
>
<div>可以拖拽和调整大小的内容区域</div>
</ResizableBox>
);
}
代码如下
import React, { useRef, useState, useCallback, useEffect } from "react";
import { throttle } from "lodash"; // 引入lodash的节流函数
// 定义拖拽方向的类型,包括8个方向和移动
type DragDirection = "n" | "ne" | "e" | "se" | "s" | "sw" | "w" | "nw" | "move";
// 定义组件属性接口
interface StretchableProps {
children: React.ReactNode; // 组件内容
defaultWidth?: number; // 默认宽度
defaultHeight?: number; // 默认高度
defaultX?: number; // 默认X坐标
defaultY?: number; // 默认Y坐标
minWidth?: number; // 最小宽度
minHeight?: number; // 最小高度
maxWidth?: number; // 最大宽度
maxHeight?: number; // 最大高度
onResize?: (width: number, height: number) => void; // 尺寸变化回调
onMove?: (x: number, y: number) => void; // 位置变化回调
style?: React.CSSProperties; // 自定义样式
contentStyle?: React.CSSProperties; // 内容区域样式
enabledEdges?: DragDirection[]; // 启用的边缘
safeArea?: {
// 安全边距
top?: number; // 顶部安全距离
right?: number; // 右侧安全距离
bottom?: number; // 底部安全距离
left?: number; // 左侧安全距离
};
}
/*
@example 一个可以调整大小和拖动的容器。
<DragResizeBox
defaultWidth={300}
defaultHeight={200}
defaultX={0}
defaultY={0}
minWidth={150}
minHeight={150}
safeArea={{ top: 50, right: 50, bottom: 50, left: 50 }}
>
<div>
<h3>内容区域</h3>
<p>这是一个可拖拽和拉伸的面板</p>
<p>尝试拖动标题栏移动面板</p>
<p>尝试拖动边缘或角落调整大小</p>
</div>
</DragResizeBox >
*/
const DragResizeBox : React.FC<StretchableProps> = ({
children,
defaultWidth = 300, // 默认宽度300px
defaultHeight = 200, // 默认高度200px
defaultX = 0, // 默认X位置0
defaultY = 0, // 默认Y位置0
minWidth = 100, // 最小宽度100px
minHeight = 100, // 最小高度100px
maxWidth = Infinity, // 最大宽度无限制
maxHeight = Infinity, // 最大高度无限制
onResize, // 尺寸变化回调函数
onMove, // 位置变化回调函数
style, // 自定义样式
contentStyle, // 内容区域样式
enabledEdges = ["n", "ne", "e", "se", "s", "sw", "w", "nw"], // 默认启用所有边缘
safeArea = {}, // 安全边距默认空对象
}) => {
// 容器元素的引用
const containerRef = useRef<HTMLDivElement>(null);
// 组件尺寸状态
const [size, setSize] = useState({
width: defaultWidth, // 当前宽度
height: defaultHeight, // 当前高度
});
// 组件位置状态
const [position, setPosition] = useState({
x: defaultX, // 当前X坐标
y: defaultY, // 当前Y坐标
});
// 是否正在拖拽的状态
const [isDragging, setIsDragging] = useState(false);
// 当前拖拽方向的状态
const [dragDirection, setDragDirection] = useState<DragDirection | null>(
null
);
// 是否正在拉伸的状态
const [isResizing, setIsResizing] = useState(false);
// 拖拽信息引用,记录拖拽开始时的状态
const dragInfo = useRef({
startX: 0, // 鼠标按下时的X坐标
startY: 0, // 鼠标按下时的Y坐标
startWidth: 0, // 拖拽开始时的宽度
startHeight: 0, // 拖拽开始时的高度
startLeft: 0, // 拖拽开始时的left位置
startTop: 0, // 拖拽开始时的top位置
});
// 解构安全边距,如果没有提供则默认为0
const {
top: safeTop = 0, // 顶部安全距离,默认0
right: safeRight = 0, // 右侧安全距离,默认0
bottom: safeBottom = 0, // 底部安全距离,默认0
left: safeLeft = 0, // 左侧安全距离,默认0
} = safeArea;
/**
* 应用安全边距限制
* @param x 当前X坐标
* @param y 当前Y坐标
* @param width 当前宽度
* @param height 当前高度
* @returns 受安全边距约束后的位置和尺寸
*/
const applySafeAreaConstraints = useCallback(
(x: number, y: number, width: number, height: number) => {
// 计算最大允许的x和y坐标
const maxX = window.innerWidth - width - safeRight;
const maxY = window.innerHeight - height - safeBottom;
// 限制x和y在安全区域内
const constrainedX = Math.max(safeLeft, Math.min(maxX, x));
const constrainedY = Math.max(safeTop, Math.min(maxY, y));
// 如果宽度或高度会导致超出安全区域,则需要调整
let constrainedWidth = width;
let constrainedHeight = height;
// 检查右边是否超出安全区域
if (constrainedX + constrainedWidth > window.innerWidth - safeRight) {
constrainedWidth = window.innerWidth - safeRight - constrainedX;
}
// 检查底部是否超出安全区域
if (constrainedY + constrainedHeight > window.innerHeight - safeBottom) {
constrainedHeight = window.innerHeight - safeBottom - constrainedY;
}
// 确保调整后的尺寸不小于最小尺寸
constrainedWidth = Math.max(minWidth, constrainedWidth);
constrainedHeight = Math.max(minHeight, constrainedHeight);
return {
x: constrainedX,
y: constrainedY,
width: constrainedWidth,
height: constrainedHeight,
};
},
[safeTop, safeRight, safeBottom, safeLeft, minWidth, minHeight]
);
/**
* 判断是否已达到最小尺寸
* @returns 是否达到最小尺寸
*/
const isMinSize = useCallback(() => {
return size.width <= minWidth || size.height <= minHeight;
}, [size.width, size.height, minWidth, minHeight]);
/**
* 鼠标按下事件处理
* @param direction 拖拽方向
* @param e 鼠标事件
*/
const handleMouseDown = useCallback(
(direction: DragDirection, e: React.MouseEvent) => {
e.preventDefault(); // 阻止默认行为
e.stopPropagation(); // 阻止事件冒泡
if (!containerRef.current) return; // 如果容器不存在则返回
// 检查当前方向是否被启用
if (!enabledEdges.includes(direction) && direction !== "move") {
return;
}
// 获取容器当前的位置和尺寸
const rect = containerRef.current.getBoundingClientRect();
// 记录拖拽开始时的信息
dragInfo.current = {
startX: e.clientX,
startY: e.clientY,
startWidth: rect.width,
startHeight: rect.height,
startLeft: rect.left,
startTop: rect.top,
};
// 设置拖拽方向和状态
setDragDirection(direction);
setIsDragging(true);
setIsResizing(direction !== "move");
// 根据拖拽方向设置鼠标样式
let cursor = "";
switch (direction) {
case "n":
case "s":
cursor = "ns-resize"; // 南北方向调整
break;
case "e":
case "w":
cursor = "ew-resize"; // 东西方向调整
break;
case "ne":
case "sw":
cursor = "nesw-resize"; // 东北-西南方向调整
break;
case "nw":
case "se":
cursor = "nwse-resize"; // 西北-东南方向调整
break;
case "move":
cursor = isMinSize() ? "not-allowed" : "move"; // 移动时根据最小尺寸判断
break;
}
// 设置全局鼠标样式和禁用文本选择
document.body.style.cursor = cursor;
document.body.style.userSelect = "none";
},
[isMinSize, enabledEdges]
);
/**
* 鼠标移动事件处理(使用节流函数优化性能)
*/
const handleMouseMove = useCallback(
throttle((e: MouseEvent) => {
// 如果没有拖拽或没有方向或容器不存在则返回
if (!isDragging || !dragDirection || !containerRef.current) return;
// 获取拖拽开始时的信息
const { startX, startY, startWidth, startHeight, startLeft, startTop } =
dragInfo.current;
// 计算鼠标移动的偏移量
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
// 初始化新的尺寸和位置
let newWidth = startWidth;
let newHeight = startHeight;
let newLeft = startLeft;
let newTop = startTop;
// 如果是调整大小操作
if (isResizing) {
switch (dragDirection) {
case "e": // 东边调整
newWidth = Math.max(
minWidth,
Math.min(maxWidth, startWidth + deltaX)
);
break;
case "w": // 西边调整
newWidth = Math.max(
minWidth,
Math.min(maxWidth, startWidth - deltaX)
);
newLeft = startLeft + (startWidth - newWidth); // 保持右边不动
break;
case "s": // 南边调整
newHeight = Math.max(
minHeight,
Math.min(maxHeight, startHeight + deltaY)
);
break;
case "n": // 北边调整
newHeight = Math.max(
minHeight,
Math.min(maxHeight, startHeight - deltaY)
);
newTop = startTop + (startHeight - newHeight); // 保持底边不动
break;
case "ne": // 东北角调整
newWidth = Math.max(
minWidth,
Math.min(maxWidth, startWidth + deltaX)
);
newHeight = Math.max(
minHeight,
Math.min(maxHeight, startHeight - deltaY)
);
newTop = startTop + (startHeight - newHeight);
break;
case "nw": // 西北角调整
newWidth = Math.max(
minWidth,
Math.min(maxWidth, startWidth - deltaX)
);
newHeight = Math.max(
minHeight,
Math.min(maxHeight, startHeight - deltaY)
);
newLeft = startLeft + (startWidth - newWidth);
newTop = startTop + (startHeight - newHeight);
break;
case "se": // 东南角调整
newWidth = Math.max(
minWidth,
Math.min(maxWidth, startWidth + deltaX)
);
newHeight = Math.max(
minHeight,
Math.min(maxHeight, startHeight + deltaY)
);
break;
case "sw": // 西南角调整
newWidth = Math.max(
minWidth,
Math.min(maxWidth, startWidth - deltaX)
);
newHeight = Math.max(
minHeight,
Math.min(maxHeight, startHeight + deltaY)
);
newLeft = startLeft + (startWidth - newWidth);
break;
}
} else if (dragDirection === "move") {
// 如果是移动操作
newLeft += deltaX;
newTop += deltaY;
}
// 应用安全边距限制
const constrained = applySafeAreaConstraints(
newLeft,
newTop,
newWidth,
newHeight
);
// 更新容器样式
containerRef.current.style.width = `${constrained.width}px`;
containerRef.current.style.height = `${constrained.height}px`;
containerRef.current.style.left = `${constrained.x}px`;
containerRef.current.style.top = `${constrained.y}px`;
// 更新状态
if (dragDirection === "move") {
// 如果是移动操作,只更新位置
setPosition({ x: constrained.x, y: constrained.y });
} else {
// 如果是调整大小操作,更新尺寸和位置
setSize({ width: constrained.width, height: constrained.height });
setPosition({ x: constrained.x, y: constrained.y });
}
}, 16), // 节流时间16ms
[
isDragging,
dragDirection,
minWidth,
maxWidth,
minHeight,
maxHeight,
isResizing,
applySafeAreaConstraints,
]
);
/**
* 鼠标释放事件处理
*/
const handleMouseUp = useCallback(() => {
if (!isDragging) return; // 如果没有拖拽则返回
// 重置拖拽状态
setIsDragging(false);
setIsResizing(false);
setDragDirection(null);
// 恢复鼠标样式和文本选择
document.body.style.cursor = "";
document.body.style.userSelect = "";
// 调用回调函数
if (dragDirection === "move") {
onMove?.(position.x, position.y); // 如果是移动操作,调用onMove
} else {
onResize?.(size.width, size.height); // 如果是调整大小操作,调用onResize
onMove?.(position.x, position.y); // 同时调用onMove
}
}, [isDragging, dragDirection, position, size, onResize, onMove]);
// 监听拖拽状态变化,添加/移除鼠标移动和释放事件
useEffect(() => {
if (isDragging) {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
handleMouseMove.cancel(); // 取消节流函数
};
}, [isDragging, handleMouseMove, handleMouseUp]);
// 初始化时应用安全边距
useEffect(() => {
if (containerRef.current) {
// 计算受约束的位置和尺寸
const constrained = applySafeAreaConstraints(
position.x,
position.y,
size.width,
size.height
);
// 更新容器样式
containerRef.current.style.width = `${constrained.width}px`;
containerRef.current.style.height = `${constrained.height}px`;
containerRef.current.style.left = `${constrained.x}px`;
containerRef.current.style.top = `${constrained.y}px`;
// 更新状态
setSize({ width: constrained.width, height: constrained.height });
setPosition({ x: constrained.x, y: constrained.y });
}
}, []); // 空依赖数组表示只在组件挂载时执行
/**
* 渲染可拉伸的边缘手柄
* @returns 边缘手柄元素数组
*/
const renderEdgeHandles = () => {
// 定义所有可拉伸边缘的配置
const edges = [
{
direction: "n", // 北边
style: {
top: 0,
left: 0,
right: 0,
height: "5px",
cursor: "ns-resize",
},
},
{
direction: "e", // 东边
style: {
top: 0,
right: 0,
bottom: 0,
width: "5px",
cursor: "ew-resize",
},
},
{
direction: "s", // 南边
style: {
bottom: 0,
left: 0,
right: 0,
height: "5px",
cursor: "ns-resize",
},
},
{
direction: "w", // 西边
style: {
top: 0,
left: 0,
bottom: 0,
width: "5px",
cursor: "ew-resize",
},
},
{
direction: "ne", // 东北角
style: {
top: 0,
right: 0,
width: "10px",
height: "10px",
cursor: "nesw-resize",
},
},
{
direction: "se", // 东南角
style: {
bottom: 0,
right: 0,
width: "10px",
height: "10px",
cursor: "nwse-resize",
},
},
{
direction: "sw", // 西南角
style: {
bottom: 0,
left: 0,
width: "10px",
height: "10px",
cursor: "nesw-resize",
},
},
{
direction: "nw", // 西北角
style: {
top: 0,
left: 0,
width: "10px",
height: "10px",
cursor: "nwse-resize",
},
},
];
// 过滤出启用的边缘并渲染
return edges
.filter((edge) => enabledEdges.includes(edge.direction as DragDirection))
.map((edge) => (
<div
key={edge.direction} // 唯一key
onMouseDown={(e) =>
handleMouseDown(edge.direction as DragDirection, e)
}
style={{
position: "absolute",
zIndex: edge.direction.length === 1 ? 10 : 20, // 角落手柄z-index更高
...edge.style,
}}
/>
));
};
// 组件渲染
return (
<div
ref={containerRef} // 容器引用
style={{
position: "absolute", // 绝对定位
width: `${size.width}px`, // 当前宽度
height: `${size.height}px`, // 当前高度
left: `${position.x}px`, // 当前X位置
top: `${position.y}px`, // 当前Y位置
border: "1px solid #ccc", // 边框样式
background: "#fff", // 背景色
boxShadow: "0 2px 10px rgba(0,0,0,0.1)", // 阴影
borderRadius: "4px", // 圆角
overflow: "hidden", // 溢出隐藏
userSelect: "none", // 禁用文本选择
cursor: isResizing ? "default" : "move", // 鼠标样式
...style, // 合并自定义样式
}}
onMouseDown={(e) => handleMouseDown("move", e)} // 移动事件
>
{/* 内容区域 */}
<div
style={{
padding: "12px", // 内边距
height: "100%", // 高度100%
overflow: "auto", // 溢出滚动
...contentStyle, // 合并内容样式
}}
>
{children} {/* 渲染子内容 */}
</div>
{/* 渲染启用的边缘手柄 */}
{(!isDragging || isResizing) && renderEdgeHandles()}
</div>
);
};
export default DragResizeBox;