大盘开发从入门到所见即所得

作者:米兰的小铁匠
来源:https://mp.weixin.qq.com/s/HV...

Hi~ 豆皮粉们!刚过完年,去年年终总结有小伙伴没有写吗?有写新的一年的规划吗?2021又准备立哪些Flag?
今回就请大家来读读由字节跳动的“米兰的小铁匠” 精心制作的《大盘开发从入门到所见即所得》,见识一下大盘开发中需要用的知识,让你不懂的知识+1。

本文主要内容

  1. 拖拽的原理
  2. 常见拖拽组件库比较
  3. React-DnD快速上手
  4. Re-resizable快速上手
  5. 如何实现一个最简单的拖拽大盘系统

最近给我们的后台系统做了一个所见即所得的大盘编辑器,颇有收获,写篇文章做个全面的回顾

一、基本原理

对一个DOM元素而言,完整的拖拽流程分为两部分,即 拖动 + 放置

让一个元素支持拖动是一件非常容易做到的事情,我们只需要在对应的HTML结点新增一个draggable="true"的属性即可,另外,超链接和图像都是默认可拖动的。

真正麻烦的是放置部分,我们需要监听ondragstartondragenterondragoverondragleave等等各阶段发生在元素上的拖动事件,最后还需要处理ondrop事件完成最终的放置,我们需要做好数据的传递,可放置区域的识别、最终位置的处理,页面的更新等等一系列细小繁琐的工作。

所幸的是,已经有成熟的库来帮助我们完善这些细节了,让我们只需要关注于渲染逻辑即可。

下面列出了常见的React拖拽相关的库:

React DnD 是由Redux作者Dan Abramov主导开发,也是非常老牌的React拖拽工具库,提供了对底层的拖拽的一层封装。

React-Beautiful-DnD 是由Alassian团队(没错,就是开发Jira的团队)贡献的React拖拽工具库。相比于React-DnD,提供了更高层级功能的封装,如动画、虚拟列表、移动端等功能。也是Github上Star最多的React 拖拽库

React-Grid-Layout是由一家比特币交易公司BitMex开源的,可谓栅格布局模式下集成最好的框架库,支持放大缩小,自动布局,在AWS控制台与Grafana中已经使用了此框架,对初学者非常友好。

由于这里我并不想把自己的命运交给比特币公司,更想从偏底层来实现自己的一整套拖拽逻辑,故此选用了React-DnD库来完成页面拖动功能的开发。

二、React-DnD 快速入门

React-dnd中,包含四个核心概念:backendmonitordragdrop

下面是一个最简单最基本的例子:

import { HTML5Backend } from 'react-dnd-html5-backend'
import { DndProvider, useDrag, useDrop } from 'react-dnd'

function Drag() {
        const [collectedProps, drag] = useDrag({
                item: { values, type: 'KEY' }
        })
        return (
                
Drag
) } function Drop() { const [collectedProps, drop] = useDrop({ accept: 'KEY' }) return (
Drop Area
) } export default function Demo() { return ( ) }

1. Backend

此处的backend,可以理解为拖拽背后的实现的逻辑,此处主要是用来区分PC端和移动端不同的事件监听和处理方式,如果是运行在PC端的,使用react-dnd-html5-backend,否则就使用react-dnd-touch-backend,注意DndProvider一定是在Drag和Drop的最外层使用的。

2. Monitor

monitor一眼看上去其实并不好理解,但是确实没有更贴切的单词了。monitor是监控整个拖动事件的总状态数据,主要分为sourceMonitor和targetMonitor,分别代表Drag和Drop元素当前的状态数据,如偏移距离、是否浮于上层等等。我们在使用useDrag和useDrop的时候,可以通过对应的monitor数据进行状态判定或者预置切换等等丰富功能。

3. Drag

drag即允许拖动的元素(source),我们通过useDrag生成的ref指向给了某一个DIV,此DIV便会被设置draggable=true的属性,同时拖动的所有事件都会被我们监听到。使用方法可以参考上面例子。

const [collectedProps, drag] = useDrag({item, canDrag, collect})

useDrag返回的数组一共有三个元素,我们只说前两个:

collectedProps: 这其实是React-DnD一个很精妙的设计,组件在拖动的时候,此变量便代表着需要监听的数据
drag: 即拖动元素的Ref引用,赋给对应的DOM元素即可

useDrag的函数参数也很多,这里只挑重要的说一下:

item: 必填,即包含的数据对象,必须字段type,与drop对象对应,只有同一个type值的才能被放置进去
canDrag: 选填,(monitor) => boolean,表示是否可拖拽,这在区分编辑与只读模式非常有用
collect: 选填,(monitor) => object,通过此方法返回的值可以从上述的collectedProps中取到, 通过使用monitor判断状态,我们可以返回如opacity、hightlighted等属性用来给拖动元素添加样式

4. Drop

drop即可以拖动到的元素(target),它的返回数组有两个元素,而且与useDrag返回值作用几乎一致

const [collectedProps, drop] = useDrop({ accept, hover, drop, collect })

其中参数和返回值如下:

collectedProps: 同上,也是collect函数返回的object
drop: 即放置元素的Ref引用,赋给对应的DOM元素即可

useDrop的参数也很多,我们也挑重点的说明一下:

accept: 必填,支持字符串或者字符串数组,对应于drag的type值,同样的值才可被拖入此元素中
hover: 选填,(item, monitor) => void,item即拖动到此drop上drag对象的值,通过用于展示滑上后的预览效果
drop: 选填,(item, monitor) => void,同上,此事件在鼠标放开后触发
collect: 选填,(monitor) => object,作用同上,也可以用来表达drag进来和离开事件

至此所有的react-dnd基本概念已经介绍完了,正所谓“九层之台起于累土,千里之行始于足下”,页面上的所有交互都是基于这些最基本的功能实现的,也许你仍然觉得很抽象,不妨参考下官网的Demo其中Sandbox的代码例子来学习一下,挤需体验十番钟,里造会干我一样,爱象节款工具!

三、Re-Resizable与宽高吸附

上面说完了拖拽,下面该说一下拉伸了。

拉伸是可通过在CSS属性中指定resize来支持拉伸,比如常见的textarea就是默认内置了此属性,但是浏览器并未像drag一样提供resize专门的API,故大部分库都是通过监听mousedownmousemovemouseup这种有些hack的方式完成的。

re-resizable 也是React体系下支持拉伸的库,这个库入门非常简单,只看官方文档就能很快理解。

我们可以像表单组件一样给它设置value(也就是size)和onChange(也就是onRisizeStop)即可完成拉伸的功能,比较麻烦的是enable如果指定了则八个方向都需指定一遍。

值得一提的是如何去做宽高的辅助吸附,简单点可以使用grid来设置步长,如果要做定制化的对齐就麻烦了,这里分享一个思路,我们可以在onResize或onResizeStop的时候,通过参数我们可以获取偏移位置,此时可以对偏移位置进行计算后四舍五入,便可保证按比例变化。

如果想做类似Photoshop(不是PS)或者CAD那种横轴纵轴吸附的,可以参考document.elementFromPoint(x,y)方法,通过不断加步长迭代的方式应该可以找到最近的子元素并获取对应的宽高。

四、如何实现一个拖拽系统的最小集的?

我把整个拖拽系统分成四部分:

  1. 拖拽源容器区域,2. 拖拽源组件区域,3. 画布上的容器区域,4. 画布上的组件。

下面的TYPE即表示useDrag中的type值,ACCEPT即表示useDrop中的accept的值。

1. 拖拽源容器区域

TYPE="Container"

拖拽源容器即所有可供用户拖拽到画布上的容器布局,所有的组件应当被放置到容器内进行布局上的管理,如果组件能实现良好的布局管理其实也可以不需要此容器。

2. 拖拽源组件区域

TYPE="Widget"

即实际业务上需要的展示组件,这部分是支持二次开发的,且用了Form-Render 支持以配置项的方式生成组件配置表单,组件只需要关注业务逻辑,配置项会自动注入进来。

3. 画布容器区域

TYPE="PaintContainer" ACCEPT=["Container", "PaintContainer"]

当把拖拽源拖入画布后,即生成一个画布容器区域,也可以不用一个新的TYPE,这样做主要是便于快速区分是从拖拽源过来的或是画布上模块的移动,如果想让一个DOM同时支持Drag & Drop,可以这样做:

const ref = useRef();
const [,drop] = useDrop({});
const [,drag] = useDrag({});
drop(drag(ref));

return 
Both Can Drag & Drop

4. 画布组件区域

TYPE="PaintWidget" ACCEPT=["Widget", "PaintWidget"]

这里也可以用两个不同的TYPE来区分,区分从拖拽源进来的还是从画布上别的地方拖进来的,一个是把数据填充进去,一个是交换两个位置的下标。

最后说一下数据结构

画布区域使用一个JS数组来维护,数组元素大致结构如下:

{
        uuid: string;                          // 唯一标识区块的id
        width,height...                  // 定位与尺寸属性
        children: {                                        // 里面的子展示组件
                uuid: string;                        // 唯一标识展示组件的id
                span: number;                        // 展示组件占宽度
                widgetId: string;        // 具体是哪一个展示组件,渲染时会取组件列表中获取并渲染
                config: object;                // 个性化配置项值
        }[]                                                                        
}

这里不得不赞美一下React的 Render(data) => View 模式做这种画布实在太合适了,每次只要修改了数据结构,React就会自动根据数据结构渲染出画布里具体的内容,少操了很多心。

五、其他问题

1. 如何做日期数据补0?

这普遍发生在做折线图的时候,DB的数据并不是每天都有,特别是在画多条折线图的时候:

[
        { data: '2020-09-01', type: 'A', count: 5 },
        { data: '2020-09-03', type: 'A', count: 15 },
        { data: '2020-09-03', type: 'B', count: 10 },
        { data: '2020-09-06', type: 'C', count: 20 },
]

上面的数据,缺少了9月2日和9月4日,9月5日的数据,如果不把空缺的时间填上去,那横轴间隔就会很奇怪。

并且因为是多条折线,每个日期都需要每种type对应的数据,不然会出现折线断掉的情况。

补0的方法无非三种思路:

  1. 数据库每天定时更新,插入冗余数据,这得看业务场景和表的作用来定
  2. 创建日期表,每次查询的时候做LEFT JOIN,虽然用起来简单了,但是性能可能会略差
  3. 后端或者前端补0,这里因为用go写太麻烦了考虑到减小后端计算压力和网络传输压力,就放到前端来了

设查询的时间范围长度为N,返回的记录为responseData数组,总种类数为M,分享一个O(NM)时间复杂度的方法(因为最终数组长度就是N*M,所以应该还是蛮高效的)

第一步:用dayjs工具生成从查询起始时间到终止时间的时间序列数组dateList,元素为日期string

第二步:生成空的结果数组resultList,参考Echarts规范,这个数组的格式为{ type: value[] },type就是状态值,value的下标是日期的下标,值是count数据

第三步:下标指针i指向dateList0个元素,下标指针j指向responseData0个元素

第四步:先不比较,遍历M所有状态,给resultList[Enum(M)][i]赋值resultList[Enum(M)][i] || 0

第五步:比较dateList[i]responseData[j]对应的日期是否一样,如果一样,则跳转到第六步,否则到第七步

第六步:赋值resultList[type][i]responseData[j].count,这里的type是responseData[j].type,然后j++,因为还要在结果中找寻同一个日期下其他数据,接着返回第四步

第七步:说明结果中不存在此日期下数据,因为第四步中已经做了默认值赋值,所以直接i++,然后返回第四步

第八步:当i超过dateList的长度后,终止循环即可

六、还缺点啥

这毕竟是两个星期做出来的东西,还有很多实现并不完善的地方:

1. 拖拽交互

拖拽交互如果想要增加动效,预览等等效果,需要增加很多细节上的判断

2.布局

目前强制行优先布局,强制四平八整,可能需要支持列方向上的布局

3.组件库建设

CMS系统中核心的就是模板+组件库。目前组件没有版本的概念,硬编码到代码中,需要拆分出来异步引用,另外也需要做好对所在容器宽高做自适应。

The End

如果你觉得这篇文章对你有帮助,有启发,我想请你帮我2个小忙:
1、点个「」,让更多的人也能看到这篇文章内容;
2、关注公众号「豆皮范儿」,公众号后台回复「加群」 加入我们一起学习;

关注公众号的福利持续更新,公众号后台送学习资料:
1、公众号后台回复「vis」,还可以获取更多可视化免费学习资料。
2、公众号后台回复「webgl」,还可以获取webgl免费学习资料。
3、公众号后台回复「算法」,还可以获取算法的学习资料。
4、公众号后台回复「招聘」,获取各种内推。
大盘开发从入门到所见即所得_第1张图片

你可能感兴趣的