大概在2017年7月,我司计划开发一款可视化建站的项目。由于团队初建人手短缺,当时只有一年工作经验的我被“赶鸭子上架”,开始了为期一年半的折腾之旅。在众多复杂的交互中,有一项需求是“拖拽对齐吸附及显示参考线”,当时也希望在社区寻找解决方案。很可惜,除了一些简单的DEMO外,并没有可用于生产环境的实践。一年半过去了,项目接近尾声不那么忙。我逐步整理出自己在工作中的解决方案,于是就有了这个开源项目react-dragline。
示例
开门见山,首先上一个简单的例子。
import { DraggableContainer, DraggableChild } from 'react-dragline'
const children = [
{ id: 1, position: { x: 100, y: 10 } },
{ id: 2, position: { x: 400, y: 200 } },
]
const containerStyle = {
height: 600,
position: 'relative',
}
const childStyle = {
width: 100,
height: 100,
cursor: 'move',
background: '#8ce8df',
}
export default function Example() {
return (
{
children.map(({ id, position }) => (
))
}
)
}
然后你就可以动手拖一拖体验一下啦~ 在线DEMO戳我
关于调用方式,起初参考了react-sortable-hoc,计划使用HOC的写法,但感觉使用HOC会把代码从JSX中分离,复杂度不高的情况下有些过度设计,所以这选择了这更传统的写法。位置属性是通过绝对定位实现的,因此需要使用者自行为DraggableContainer
加上定位属性relative/absolute/fixed
,本意是检测到没有定位属性时自动加上relative
,但是这种方式在服务端渲染的场景下会有丑陋的“跳动”(因为只有在客户端才能检测DOM嘛),因此就把这项功能给去了。更多的options都写在README里了,出自我的“中式英语”大家阅读起来也没什么难度。
实现原理
关于原理,拖拽功能是基于react-draggable的Uncontrolled组件DraggableCore
,统一使用left和top作为x,y坐标的映射。DraggableContainer
和DraggableChild
之间的通信是通过React.cloneElement
实现的。剩下的,就是把精力集中于实现核心功能参考线和吸附。以下根据拖拽的事件周期onstart
,ondrag
,onstop
分别阐述。
onstart
在拖拽初始中获取每个DraggableChild
的坐标、宽高、索引等信息。可以将所有的DraggableChild
分为两类,target
为当前拖拽的元素,compares
为其余的元素,可以称为对照组(为了方便行为,下文中target
即代表拖拽目标元素,compare
即代表当前与target
比较的元素)。为什么不在componentDidMount
中就获取好呢?因为这些元素的信息可能会变得,比如说增删。相比起这细微的性能损失,维护信息变化的成本显然要高得多。
ondrag
核心代码主要在ondrag的过程中,我们需要不断的去比较target
和compares
之间的距离是否小于阈值threshold
(默认5px)。考虑过是否需要加上debounce
,但是似乎对灵敏度还是有些影响,不是一个太好的选择。
吸附功能的实现相对简单,坐标和对照组的某元素的距离小于阈值threshold
时,让其等于对照组的坐标即可:
// a 为对照组某元素的坐标
if (Math.abs(a - x) < threshold + 1) {
x = a
}
参考线的实现略微复杂一些,以Y轴方向为例,最初的实现是分别取target
元素和compare
元素上下位置(Element.getBoundingClientRect),组成一个包含四个值的数组[t, b, T, B]
(target
用小写字母表示,compare
用大写字母表示),最大差值(排序取首尾值相减)即为参考线的长度,取最小值作为参考线的起点。
这么做似乎也没有什么问题,实际上是有一些细微的误差的。使用DOM元素的位置信息计算具有一定的滞后性,DOM表示的是当前的位置,而计算的是拖拽下一帧的位置,这样“细微的误差”也就可以解释了。解决方式也很简单,将数组中的t
和b
替换为y
和y + height
即可。
结束了吗? 并没有。最初的设计是将计算x
和y
是否需要吸附和参考线在统一流程里,因为有吸附才有会出现参考线,避免了重复的计算。然而,当target
元素同时和X轴和Y轴两个compare
元素吸附时,Y轴的参考线是会受到Y轴吸附的影响(X轴同理)。见下图:
当水平方向上两个绿色的元素吸附时,Y轴的参考线也必须“突然地增加了一段”。因此后续又做了一次代码封装粒度更小的重构,以在计算完成x
,y
吸附之后再对参考线作出一次修正。
onstop
拖拽结束就比较简单了,将参考线的和一些其它状态清除就好了。
收获与总结
在整理这些项目的过程中,除了核心代码本身,还有一些我觉得更为宝贵的收获。
在构建方式上,我们日常的项目(Application)开发都是把源码和第三方依赖等打包成“可执行文件”,即可以直接扔到浏览器上跑JavaScript代码。但是在打造一款第三方项目(Library)时,这样做是显然不可行的。试想一下,如果一个项目有10个第三方依赖,而每个依赖都引入classnames
,如果这些第三方依赖包都把classnames
打包到源码中,那对于使用者来说,岂不是有10份重复的classnames
代码?实际上我们需要做的是“只编译,不打包”。可否记得你在使用npm install
的时候安装数量都是远远大于写在package.json
内依赖的数量?没错,所有依赖及依赖的依赖...都是由用户统一安装,这样就可以避免了上述“10份重复的代码”的问题。另外,一般考虑到浏览器用户,确实会提供一份把依赖也打包进源码的UMD文件。
关于前端测试,这也是我之前了解较少的领域。一般前端业务变化频繁,生命周期相对较短,不太具备持续迭代的可能,因此写测试倒说不上是一个性价比高的选择。但是对于需要持续迭代的底层UI(组件库),单元测试的必要性还是很高的。因此我也假模假样地基于jest和enzyme写了一些测试用例,以保证后续迭代的不会因为粗心大意而对之前功能有所影响。
细心的朋友可能会发现,我在DraggableChild
中使用的defaultPosition
而不是position
,这里就涉及到一些uncontrolled components的知识。一般来说,合格的React组件是需要提供position
和defaultPosition
两种使用方式的。但是考虑到吸附功能是需要对元素的位置具备完全地控制能力,因为初步决定只提供defaultPosition
的使用方式。
react-dragline算是我的第一个不那么玩具的开源项目了,欢迎大家交流拍砖~
原文首发于我的博客https://www.vq0599.com/p/44,转载请注明。