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

boyanx2周前技术教程3

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

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

一、效果演示


二、拖拽移动

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

拖拽的元素需要脱离文档流,避免移动造成整个页面的回流(reflow),导致巨大性能损失。


拖拽过程很简单:

  • 鼠标按下时记录鼠标初始坐标
  • 鼠标移动时计算鼠标的偏移量,根据偏移量计算元素新坐标
  • 鼠标松开结束移动。

这是一段实现拖拽移动的 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个方向上的拖拽点,只需要排列组合就可以了。

拖拽元素的拖拽点,实际上就是前面所说的拖拽移动,只不过拖拽对象从元素换成了拖拽点,更新位移换成了同时更新位移和宽高。

// 一段伪代码 (拖拽「左上方」拖拽点)
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并重置绝对定位坐标。这样当该元素结束移动后,所处块重新渲染不会触发GPU调用。
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布局:

<!-- 容器元素 -->
<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>

事件委托处理伪代码:

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国际化语言、鼠标范围框选、... ... 等等。

相关文章

SpringMVC流程及源码分析(springmvc流程和原理)

前言学了一遍SpringMVC以后,想着做一个总结,复习一下。复习写下面的总结的时候才发现,其实自己学得并不彻底、牢固、也没有学全,视频跟书本是要结合起来一起,每一位老师的视频可能提到的东西都不一致,...

i18n 居然还能玩出花(i18n汉化模组怎么用)

众所周知,前端项目想接入国际化,基本就是 i18n 这个方案。吐槽一下,i18n 的官网居然不支持国际化,仿佛买剪刀来拆包装盒,发现剪刀外面也有包装i18n 的使用方式如下:public/ │ └─...

推荐一款经典的.NET后台管理系统(.net 系统)

基于.NET5/.Net7 + vue2.x/vue3.x前后端分离的.net快速开发框架概述本项目适合有一定NetCore和 vue基础的开发人员基于.NET5/.NET7实现的通用权限管理平台(R...

从 Element UI 源码的构建流程来看前端 UI 库设计

作者:前端森林转发链接:https://mp.weixin.qq.com/s/ziDMLDJcvx07aM6xoEyWHQ引言由于业务需要,近期团队要搞一套自己的UI组件库,框架方面还是Vue。而业界...

vue3使用vue-i18n国际化(vue3 global)

最近公司项目开发完了, 准备把公司脚手架升级成vue3.x版本的.看了一下公司需要用到的类库.由于不知道对vue3.x支持如何, 所以建了vue3.x的demo.由于跟2.x版本配置不一样, 所以记录...

一站式解决方案!Electron、Vite和Vue 3助你打造功能丰富桌面应用

背景结合Electron Forge、Vite和Vue 3,你可以快速构建功能丰富的跨平台桌面应用程序,尽管你可能只懂web开发,你一样可以轻松的开发出各式各样的桌面应用。而且Vite的快速热更新能力...

发表评论    

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。