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

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

本系列所有技术点,均经过本人开源项目 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国际化语言、鼠标范围框选、... ... 等等。

演示地址:点击访问

相关推荐
坊钰20 分钟前
【MySQL 数据库】增删查改操作CRUD(下)
java·前端·数据库·学习·mysql·html
excel23 分钟前
webpack 模块 第 六 节
前端
Watermelo61724 分钟前
Vue3+Vite前端项目部署后部分图片资源无法获取、动态路径图片资源报404错误的原因及解决方案
前端·vue.js·数据挖掘·前端框架·vue·运维开发·持续部署
好_快25 分钟前
Lodash源码阅读-flattenDepth
前端·javascript·源码阅读
好_快25 分钟前
Lodash源码阅读-baseWhile
前端·javascript·源码阅读
好_快26 分钟前
Lodash源码阅读-flatten
前端·javascript·源码阅读
好_快27 分钟前
Lodash源码阅读-flattenDeep
前端·javascript·源码阅读
excel29 分钟前
webpack 模块图 第 一 节
前端
Allen Bright1 小时前
【XML基础-2】深入理解XML中的语义约束:DTD详解
xml·前端
Attacking-Coder2 小时前
前端面试宝典---闭包
前端