本文是《前端大屏原理系列》第一篇:高性能拖拽系统的实现。
本系列所有技术点,均经过本人开源项目 react-big-screen 实际应用,欢迎 star (*´▽`)ノノ.
一、效果演示

二、拖拽移动
点击鼠标拖拽移动元素,就是将元素从一个坐标移到另一个坐标。这里,我们需要通过 position:absolute
和top
、left
属性去定位元素。
拖拽的元素需要脱离文档流,避免移动造成整个页面的回流(reflow),导致巨大性能损失。
拖拽过程很简单:
- 鼠标按下时记录
鼠标初始坐标
。 - 鼠标移动时计算
鼠标的偏移量
,根据偏移量计算元素新坐标
。 - 鼠标松开结束移动。
这是一段实现拖拽移动的 js 代码:
js
const dom = document.getElementById('dom'); // 元素dom (position:absolute定位)
const domPos = {x: 100, y: 100}; // 元素位置坐标
const startPos = {x: 0, y: 0}; // 鼠标初始坐标
// dom元素添加鼠标按下事件监听器
dom.addEventListener('mousedown', mousedown)
function mousedown (e) {
// 鼠标按下时,保存初始坐标
startPos.x = e.x;
startPos.y = e.y;
// 采用全局鼠标移动监听器,避免鼠标超出了当前dom移动就失效的问题
window.addEventListener('mousemove', mousemove)
window.addEventListener('mouseup', mouseup)
}
function mousemove (e) {
// 计算鼠标移动偏移量
const deltaX = e.x - startPos.x;
const deltaY = e.y - startPos.y;
// 更新元素的实时位置
dom.style.left = `${domPos.x + deltaX}px`;
dom.style.top = `${domPos.y + deltaY}px`;
}
function mouseup (e) {
// 鼠标松开结束移动,移出移动事件监听器
window.removeEventListener("mouseup", mouseup);
window.removeEventListener("mousemove", mousemove);
// 更新最后一次移动坐标位置
const deltaX = e.x - startPos.x;
const deltaY = e.y - startPos.y;
dom.style.left = `${domPos.x += deltaX}px`;
dom.style.top = `${domPos.y += deltaY}px`;
}
到这里,一个拥有简单性能优化的拖拽功能就实现了。但若是遇到页面上同时移动大量独立元素的情况,还是会遇到性能瓶颈的(例如:前端大屏全选一两百的组件,同时拖拽移动就会卡顿)。想要更好的优化性能,可以看文末的性能优化段落。
三、拖拽大小
实现拖拽组件大小,常见的方案是在元素四周增加8个拖拽点。和拖拽位移只需要计算左上角坐标不同,拖拽大小需要同时考虑到左上角坐标、自身宽度高度的变化。设计类似于下图:

例如:拖拽左上角拖拽点,元素的宽度高度会变化,而且左上角坐标x、y也会同时变化。如果你拖拽右下角标记点,则只会让元素宽度高度变化。
tips:悄悄告诉你一个小技巧!拖拽左边会同时修改左上角x坐标、元素宽度;拖拽右边只会修改元素宽度;拖拽上方会同时修改左上角y坐标、元素高度;拖拽下方则只会修改元素高度。所以实现8个方向上的拖拽点,只需要排列组合就可以了。
拖拽元素的拖拽点,实际上就是前面所说的拖拽移动,只不过拖拽对象从元素换成了拖拽点,更新位移换成了同时更新位移和宽高。
js
// 一段伪代码 (拖拽「左上方」拖拽点)
function mousemove () {
const deltaX = ...
const deltaX = ...
dom.style.top = `${deltaY + domPos.y}px`;
dom.style.left = `${deltaX + domPos.x}px`;
dom.style.height = `${deltaY + domPos.height}px`;
dom.style.width = `${deltaX + domPos.width}px`;
}
这样实现了一个基础版本的拖拽大小方案,但每次都要同时对8个拖拽点创建事件监听器监听移动。其还可以继续优化的,详见下一段落"性能优化"。
四、性能优化
有了前两个段落解释拖拽移动
、拖拽大小
的原理,我们也能发现原来拖拽系统的实现如此简单啊!如果只是移动一个元素,这样做也足够了。但如果换成50个?换成100个元素?200个元素?亦或者同时拖拽500个元素呢?这时就会出现肉眼可见的卡顿了。
GPU硬件加速
在前面基于 position:absolute
的拖拽移动实现下,不用触发整个页面的回流,但是会导致这个元素的不断回流。如果你快速不停拖拽移动这个组件,会发现有细微的抖动,这是浏览器渲染速度跟不上元素几何信息改变的速度导致的。
css有个属性transform
可以用来触发GPU硬件加速,因为其不会改变元素在文档中的位置,所以不会触发回流,而由GPU单独渲染在复合层(Composited Layer),而非渲染层(Render Layer)。
但是GPU渲染不能滥用,因为其会占用更多的内存(尤其是一些没有独显的笔记本用户来说)。所以我们不能给每个元素都用transform
进行定位,否则可能会导致用户设备电量消耗过快、更易发热等由GPU大量运行导致的硬件问题。
所以,我们给元素使用position:absolute进行定位
,并仅在移动时采用transform定位触发GPU加速
提高拖拽流畅度,移动结束时删除transform并重置绝对定位坐标
。
js
function mousemove () {
...
dom.style.transform=`translate3d(${domPos.x + deltaX}px, ${domPos.y + deltaY}px, 0)`
}
function mousedown () {
...
dom.style.removeProperty('transform');
dom.style.top = `${domPos.x += deltaX}px`
dom.style.left = `${domPos.y += deltaY}px`
}
事件委托
前面提到的拖拽方式,会给每个拖拽元素添加一个事件监听器,并给每个元素的8个拖拽点各添加一个事件监听器。也就是说,想要一个元素完整实现拖拽移动、拖拽大小,必须绑定9个事件监听器。那如果一个页面同时选中300个元素,那就绑定了2700个事件监听器!这简直可怕!
我们可以利用事件委托来处理,不管选中300个、还是1000个元素,都只需要绑定1个事件监听器! 我们给容器元素添加一个事件监听器,并给拖拽元素和其拖拽点添加标志符。根据事件冒泡由内而外的特性,所有子元素事件都可以被父元素接受到。当鼠标点击拖拽元素或其拖拽点时,我们在容器元素中就能取到这个值,并做出相应的移动处理。
一个拖拽场景的html布局:
html
<!-- 容器元素 -->
<div class="container" id="container">
<!-- 拖拽元素 -->
<div class="child" data-id="1">
<!-- 拖拽元素的8个拖拽点 -->
<div class="child-point" data-dir="top"></div>
<div class="child-point" data-dir="top-left"></div>
...
<div class="child-point" data-dir"bottom"></div>
</div>
<div class="child" data-id="2">...</div>
...
<!-- 总共1000个拖拽元素 -->
...
<div class="child" data-id="1000">...</div>
</div>
事件委托处理伪代码:
ts
const container = document.getElementById('container');
// 容器组件添加唯一事件监听器(鼠标按下)
container.addEventListener('mousedown', mousedown);
function mousedown (e) {
// 查找点击元素是否是拖拽元素,或处于拖住元素内部。
// 如果找到id,说明点击的事拖拽元素。
const id = getHTMLElementDataSet(e.target, 'id', true);
// 点击拖拽元素
if (id) {
// 判断当前点击元素是否拖拽元素的8个拖拽点之一。
const direction = getHTMLElementDataSet(e.target, 'dir')
if (direction) {
// 如果点击其包含的拖拽点,则拖拽元素大小
// ...
} else {
// 如果点击拖拽元素自身,则拖拽移动元素
// ...
}
return
}
// 点击容器元素
// ...
}
// 获取 <div data-[propName]="xxx">...<div>的值 xxx(并支持向父组件循环查找该值)
function getHTMLElementDataSet(
dom: HTMLElement,
propName: string,
findParent?: boolean,
): any {
let id: any = dom?.dataset?.[propName];
if (!findParent) {
return id;
}
while ((id === undefined || id === null) && dom) {
dom = dom.parentElement as any;
id = dom?.dataset?.[propName];
}
return id;
}
只要确定了拖拽的元素,以及拖拽移动还是拖拽大小,处理逻辑就和之前一样了。
关于节流?
面对拖拽这种瞬时大量更新的操作来说,按理说是需要做节流减少性能损耗。但是个人认为,拖拽的流畅度对于用户来说是很重要的,所以没必要做节流。
拖拽时,一般也不会同时进行其他操作,用户的关注点始终在于将页面元素拖拽放到想要的位置上,一个像素点都不能错位。这期间一丝一毫的卡顿都会让其觉得不流畅,继而影响对这个大屏系统的观感。他们可能会觉得:这个网站用起来为啥总是卡卡的,和别人一对比也太慢吧!
所以,即使产生性能损耗(在可接受范围内),也要让用户拥有良好的使用体验!
【前端大屏原理系列】
react-big-screen 是一个从0到1使用React开发的前端拖拽大屏开源项目。此系列将对大屏的关键技术点一一解析。包含了:拖拽系统实现、自定义组件、收藏夹、快捷键、可撤销历史记录、加载远程组件/本地组件、自适应预览页、布局容器组件、多组件联动(基于事件机制)、成组/取消成组、多子页面切换、i18n国际化语言、鼠标范围框选、... ... 等等。
演示地址:点击访问