前端大屏原理系列:高性能拖拽系统的实现

本文是《前端大屏原理系列》第一篇:高性能拖拽系统的实现。

本系列所有技术点,均经过本人开源项目 react-big-screen 实际应用,欢迎 star (*´▽`)ノノ.

一、效果演示

二、拖拽移动

点击鼠标拖拽移动元素,就是将元素从一个坐标移到另一个坐标。这里,我们需要通过 position:absolutetopleft属性去定位元素。

拖拽的元素需要脱离文档流,避免移动造成整个页面的回流(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国际化语言、鼠标范围框选、... ... 等等。

演示地址:点击访问

相关推荐
萌萌哒草头将军4 分钟前
Rspack 1.5 版本更新速览!🚀🚀🚀
前端·javascript·vue.js
阿卡不卡8 分钟前
基于多场景的通用单位转换功能实现
前端·javascript
♡喜欢做梦20 分钟前
jQuery 从入门到实践:基础语法、事件与元素操作全解析
前端·javascript·jquery
flyliu24 分钟前
前端权限控制应该怎么做
前端·前端工程化
酸菜土狗27 分钟前
gitignor配置禁止上传文件目录到 Git
前端·javascript
小猪猪屁27 分钟前
告别依赖地狱!Monorepo 打造高效 Vue3 项目体系
前端·前端框架
前端老爷更车28 分钟前
深度解析VUE3 Composition API 中的setup 函数
前端
王六岁29 分钟前
JavaScript 运算符的那些"坑"与技巧
前端·javascript
酸菜土狗29 分钟前
nvm常用命令行操作
前端·javascript
Danny_FD31 分钟前
解决 null byte is not allowed in input:PNPM/npm 下载报错的编码陷阱
前端·程序员