
字节笔记本
2026年10月7日 · 约 10 分钟读完
用 react-rnd 实现可拖拽可缩放的自由布局
Figma 的浮层面板能拉到画布的任何角落,视频剪辑软件的素材窗可以随意摆放,低代码编辑器里组件拖着就走——这类「自由定位式布局」看似只是个拖移动作,背后却要处理指针事件捕获、边界约束、八方向缩放手柄、触摸屏兼容等一整套细节。自己从零写不难写出来,难的是写稳。react-rnd 的思路是把 react-draggable 的拖动能力和 react-resizable 的缩放能力合并成一个组件,一个 API 同时解决两件事,是 React 生态里做自由布局最省事的选择之一。本文从一个最小可运行示例入手,讲清它的核心配置、底层原理、受控用法、常见的坑,以及和同类库的选型边界。
一、十行代码跑起来
import React from 'react';
import { Rnd } from 'react-rnd';
const DraggableLayout = () => (
<div className="w-full h-screen bg-gray-100 p-4">
<Rnd
default={{ x: 0, y: 0, width: 320, height: 200 }}
minWidth={200}
minHeight={100}
bounds="parent"
className="bg-white shadow-lg rounded-lg"
>
<div className="p-4">
<h2 className="text-lg font-semibold mb-2">面板标题</h2>
<p>这个面板可以拖动,也可以拉角缩放。</p>
</div>
</Rnd>
</div>
);Rnd 是 react-rnd 的唯一核心导出。上面几个属性各管一件事:default 给出初始位置和尺寸,属于非受控写法,只在组件挂载时生效一次;minWidth 与 minHeight 划出缩放下限,防止面板被拉成一像素;bounds="parent" 把拖动范围锁在父元素之内,面板不会被拖出可视区;className 负责外观。

不方便引入构建工具时,用 CDN 也能直接验证。在 HTML 头部加一行:
<script src="https://unpkg.com/react-rnd/dist/react-rnd.min.js"></script>这种 UMD 方式适合原型验证和调试页,注意页面上还得先有 React 本体;正式项目仍建议走 npm 安装,版本管理和类型提示都更省心。
二、它为什么流畅:三层机制
这些配置背后是三层简单机制。拖动层面,react-rnd 在按下(触摸屏则是 touchstart)时记录起点,监听 document 级别的 move 事件累计位移,用 transform 平移元素而不触发重排;缩放层面,组件在四角四边渲染八个手柄,拖哪个方向就往哪边改宽高,同时按 minWidth 一类限制收口;约束层面,bounds 只是给位移计算多套了一层父容器矩形的钳制。理解这个分层有两个实际好处:出问题时能判断故障在事件层、计算层还是约束层;也解释了它为什么流畅——整个拖动过程不触发布局重算,浏览器只需重新合成。
三、受控与非受控:布局持久化的分水岭
default 是非受控的:React 不追踪它,用户拖到哪算哪,刷新页面布局即丢。真实产品里布局通常要记住——用户下次打开,面板还在原来的位置。这时切换到受控写法:
const [box, setBox] = useState({ x: 0, y: 0, width: 320, height: 200 });
<Rnd
size={{ width: box.width, height: box.height }}
position={{ x: box.x, y: box.y }}
bounds="parent"
onDragStop={(e, d) => setBox(b => ({ ...b, x: d.x, y: d.y }))}
onResizeStop={(e, dir, ref, delta, pos) =>
setBox(b => ({
...b,
width: parseInt(ref.style.width, 10),
height: parseInt(ref.style.height, 10),
x: pos.x,
y: pos.y,
}))
}
/>关键在 onDragStop 与 onResizeStop 这两个回调:拖拽结束后才把最终位置写回 state,再顺手存入 localStorage 或提交给后端,布局持久化就完成了。典型的例子是监控大屏:工程师把关注的服务卡片拖成自己的专属排布,这套排布跟着账号走。反过来要避免在 onDrag 里高频 setState——拖拽过程中 react-rnd 自己直接改 DOM 的 transform,不经过 React 渲染,帧率有保障;把状态更新留到 stop 时机,既省渲染又拿得到准确终值。
四、实战中常踩的四个坑
一、bounds 不生效。 bounds="parent" 依赖父元素存在定位上下文,也就是 position 为 relative、absolute 或 fixed。父容器只是个普通 div 时,约束会静默失效,面板一路拖出屏幕,很多人以为撞上了库的 bug,其实是外层忘了加 relative。
二、整块可拖导致内容无法交互。 默认整个面板都是拖拽热区,面板里一旦放了输入框、按钮,点击就变成拖动。解决办法是用 dragHandleClassName 指定标题栏作为唯一拖拽把手,内容区恢复正常交互;enableResizing 还能按方向关掉不需要的缩放手柄。
三、受控值互相打架。 size/position 与 default 混用,或每次渲染都传一个新建的对象字面量,会出现「拖完弹回原地」的诡异现象。原则很简单:要么全交给 default,要么 size 加 position 配套回调全受控,不要半受控。
四、多面板叠放的层级管理。 自由布局里面板互相覆盖,合理行为是点谁谁到最上层。常见做法是维护一个递增的 zCounter,在 onDragStart 时把当前面板的 z-index 置为最大值。
移动端另有一点要确认:react-rnd 内部同时处理 mouse 与 touch 事件,但若外层代码阻止了 touchmove 的默认行为(例如某些轮播或手势库),手机上面板会拖不动,排查时先看这一处。
五、布局要落库,先定数据结构
受控写法只是第一步,产品化的自由布局最终都要回答一个问题:布局长什么样、存在哪。推荐把每个面板抽象成一个对象:id 标识、x 与 y 位置、width 与 height 尺寸、zIndex 层级,整个布局就是这样一个数组。初始化时把数组映射成一列 Rnd,把四个值喂给受控属性;每次拖拽或缩放结束,只更新数组里对应 id 的那一项,然后整体序列化。单机场景 localStorage 就够,多端同步才需要后端接口。恢复时有个细节:受控属性要求 x、y 是数字,从 localStorage 读回的却是 JSON 字符串,记得先 parse 再逐字段校验,遇到脏数据退回默认值,别让一张坏快照把整个页面拖垮。这份结构还有两个隐含收益:撤销重做就是把数组的历史版本压栈;协作编辑时,冲突处理的粒度也天然落在单个面板上,不会牵一发动全身。
六、选型:它不是万能拖拽

自由定位和列表排序是两类问题,选错库会事倍功半。判断标准可以概括成三个特征:位置完全由用户自由决定、元素之间没有顺序语义、大概率还要能调大小。仪表盘的可拖卡片、在线白板和仿 IDE 工作台的浮动窗口、大屏上的告警弹窗,全都命中;反之,导航菜单排序、看板列内拖拽是排序问题,dnd-kit 更合适(老牌的 react-beautiful-dnd 已停止维护,新项目不必再选)。还有一类「网格吸附」需求——面板拖过去自动对齐成整齐栅格——gridstack 这类网格引擎更对路,react-rnd 要做到得自己补碰撞与吸附逻辑。当然这不是单选题:同一个产品里,工作区的自由面板交给 react-rnd、左侧素材列表的排序交给 dnd-kit,两者共存是常见组合。
性能上限方面,几十个面板毫无压力;到上百个可交互节点时,可以对不可见面板用 disableDragging 关掉事件监听,再给正在拖动的节点加 will-change: transform 提升合成层。
收尾
到这里最小示例已经能跑起来,往下走还有几个自然的延伸方向:吸附对齐线(拖近相邻元素时显示参考线并自动贴合)、碰撞检测(禁止面板重叠)、撤销重做(复用第五节那份快照栈)。这些功能都建立在同一份布局数据之上——位置、尺寸、层级的三元组。先把这份结构定义清楚并落库,交互再复杂也只是在这份数据上做文章。react-rnd 不是最炫的库,但它把拖拽布局里八成的脏活干完了,值得放进每个前端的工具箱。



