vue3分析Pragmatic drag and drop实例源码,并实现简单列表拖拽排序
想做一个无限嵌套的列表拖拽排序组件,但因为各种原因不想用组件式的实现方式(即vuedraggable的 和 element-plus的 tree组件)并且自身的内容数据涉及多个watch,所以也不便随意修改数据的结构。本来vue的拖拽插件就少,这下更没得选了。
前言:想做一个无限嵌套的列表拖拽排序组件,但因为各种原因不想用组件式的实现方式(即vuedraggable的<draggable> 和 element-plus的 tree组件)并且自身的内容数据涉及多个watch,所以也不便随意修改数据的结构。本来vue的拖拽插件就少,这下更没得选了。
好在找到了即将介绍的pramatic dnd,可以说是完美满足需求的同时性能还高,美美pnpm i 发现文档全英+外国人特有的示例驱动文档+国内社区全是水文+Tsx看不懂啦。对着唯一的Vue示例艰难啃完发现唉我去这玩意真好用吧!自由度超高的同时基于原生的性能也超高,自带的数据管理看上去复杂但是实用度拉满!这么好用的东西我一定要推荐给别人(安利之魂全开)
前言2:本人水平有限,以下内容不可避免地会有一些错误或遗漏,一切以官方文档为准,如果错误欢迎指正
目录
getInitialData() {return getTaskData(task);},
onGenerateDragPreview({ nativeSetDragImage }) {}
getDropEffect(){return "none" | "copy" | "link" | "move"}
事件:onDropTargetChange({ self,source,location }) {}
事件:onDragEnter({self,source,location}){}
事件:onDragLeave({self,source,location}){}
事件:onDrag({self,source,location}){}
事件:onDrop({self,source,location}){}
canMonitor({initial, source}){bool}
简介
以下内容将以官方提供的vue示例作为基础,逐个介绍重要的函数和大致的实现原理,争取做到以实用性为主,看完多的不说能用就行。注意:以下将简称pragmatic drag and drop为 pdnd
结构
示例的结构很简单:一个list遍历生成多个task,每个task都可以拖动改变顺序,这里它用了一些样式来阻止在task的内容部分拖动,但更正确的做法是使用`dragHandler`(后面会介绍)
task组件较为复杂,包含如下内容:
1. 操作的task组件本身,这里通过ref暴露出HTML结构,用来绑定到pdnd上
<div ref="elRef" :data-task-id="task.id" :class="[
'flex text-sm bg-white flex-row items-center border border-solid rounded p-2 pl-0 hover:bg-slate-100 hover:cursor-grab',
stateStyles[elState.type] ?? '']">
省略掉一些样式
<div ref="elRef" :data-task-id="task.id"
:class="[stateStyles[elState.type] ?? '']">
即根据 elState的type,在stateStyles表中切换task组件的class从而改变样式
2. task组件左侧的竖着的三个点点,这里本应作为dragHandler暴露出去
<div class="w-6 flex justify-center">
<GripVertical />
</div>
需要注意这里的`GripVertical`为`lucide-vue-next`包中的样式组件
3.俩结构样式组件,不影响功能
<span class="truncate flex-grow flex-shrink">{{ task.content }}</span>
<Status :status="task.status" />
4.在拖动时,用于显示拖动/插入位置的线条
<DropIndicator v-if="elState.type === 'is-dragging-over'
&& elState.closestEdge"
:edge="elState.closestEdge"
gap="8px" />
本身只是一个根据 `edge`的值改变显示位置的线条,注意其只会在 `elState`为`is-dragging-over`并且`elState.closestEdge`时显示,相关内容会在下面详细解释
5.拖动时的幻影元素
<Teleport v-if="elState.type === 'preview'" :to="elState.container">
<div class="border-solid rounded p-2 bg-white">
{{ task.content }}
</div>
</Teleport>
注意这里的`:to="elState.container"`该组件内容最终会传送到`elState`中定义的container中,下面会提到
数据结构
1.elState:在上文中多次出现,是一个对当前组件的状态进行管理的结构,万恶的ts导致它看起来很复杂,但实际上就是一个type来标识此时的状态,另外有多个不同的键存放该状态下会使用到的数据
elState的type
type TaskState =
| {
type: "idle"; //初始化状态
}
| {
type: "preview"; //当前组件的预览
container: HTMLElement; //预览的容器,用来teleport
自定义的预览内容
}
| {
type: "is-dragging"; //当前组件正在被拖拽
}
| {
type: "is-dragging-over"; //当前组件正在被
另一个拖拽组件经过
closestEdge: Edge | null; //被经过时,
对应的拖拽组件最接近的本组件的边缘,
这里的Edge是pdnd提供的type,
其实就是上下左右四个字符串啦,
在本示例中限制为上下两个边缘
(限制方法见下方)
};
一个elState的type映射的class表,用来控制不同状态样式
const stateStyles: { [Key in TaskState["type"]]?: string } = {
"is-dragging": "opacity-40",
};
2. task-data.ts文件中的内容
type TTask:task对象的type,换成你的列表项的结构就行
type TTaskData:通过Symbol(taskDataKey)来唯一标识各个task的数据结构,这个数据会在pdnd内部传递,并用以唯一识别不同的task,非常重要,请参照该结构做一个类似的数据结构和函数
const taskDataKey = Symbol("task");
type TTaskData = { [taskDataKey]: true; taskId: TTask["id"] };
export function getTaskData(……){……}
export function isTaskData(……){……}
3.回到Task.vue文件
const idle: TaskState = { type: "idle" }; //初始化的状态
const elRef = ref<HTMLDivElement | null>(null); //该Task组件的ref
const elState = ref<TaskState>(idle); //该Task组件的状态,
这里它用ref是为了符合TS筛查,
你不介意TS想用reactive的话也没问题,
就是要自己注意一下状态改变时是否有传入需要的数据结构
函数
搞了大半天终于可以开始介绍函数了(抹泪),不过上述结构尤其是数据结构都颇为重要,会在下方多次涉及,请务必保证你理解了上面的结构再往下看
注意:以下会将“正在被拖拽的组件”称为拖组件,“可以被放置的组件”称为放组件
1.cleanup和combine
pdnd提供的三大结构:draggable(生成拖组件),dropTargetForElements(生成放组件),monitorForElements(生成拖放监听器)都会返回一个 cleanup函数,用来清理上述结构,对于我们vue来说,按标准而言,就该在挂载后使用调用三大结构,并获取cleanup,并在卸载时通过cleanup将其清理,请不要偷懒哦
combine则是用来连接多个结构生成的cleanup,即如下所示:
cleanup = combine(
draggable({
……
}),
dropTargetForElements({
……
}),
monitorForElements({
……
})
)
2.draggable({})
该函数用来将元素生成为拖组件,其中涉及的设置项包括
element: elRef.value
需要被变成拖组件的HTML元素,这里的elRef即为task组件的ref,我们vue该用ref来给它传对象
dragHandle:handlerRef.value
需要被变成上述拖组件的拖动手柄的元素,这是我自己加的,同样要传一个HTML元素,不要求该元素在element内部
getInitialData() {return getTaskData(task);},
重点!
生成这个拖组件的初始数据,返回值即为拖组件的在pdnd内部传递数据时使用的,对应该拖组件的数据,请保证其中包含对应组件的唯一标识,该数据可以在接下来的各个方法中被获取。在这里是通过getTaskData来获取的对应task的数据
onGenerateDragPreview({ nativeSetDragImage }) {}
重点!
当这个拖组件被拖动,并在视图上产生一个“预览图”的时候,我们可以通过该方法来覆盖原生的预览图(原生预览图就是元素的一比一图片)
onGenerateDragPreview({ nativeSetDragImage }) {
setCustomNativeDragPreview({
nativeSetDragImage,
getOffset: pointerOutsideOfPreview({
x: "16px",
y: "8px",
}),
render({ container }) {
elState.value = { type: "preview", container };
},
});
},
我们来拆分一下这玩意
1.首先是官方提供的,覆盖原生预览图的函数
setCustomNativeDragPreview({
nativeSetDragImage, //需要被覆盖的原生预览图,在上面的函数的参数里面
getOffset: //见下方
//渲染container的回调函数,
通过这个函数获得container本身,并将其存储在elState中
render({ container }) {
elState.value = { type: "preview", container };
},
});
该函数的作用,按照官方文档中的说法,会在body中创建一个悬浮的container用于显示预览内容,并在预览内容销毁的同时删除
setCustomNativeDragPreviewadds thecontainerElementto thedocument.bodyand will remove thecontainerElementafter yourcleanupfunction is called.
还记得结构→预览内容里面那个teleport吗?
<Teleport v-if="elState.type === 'preview'" :to="elState.container">
<div class="border-solid rounded p-2 bg-white">
{{ task.content }}
</div>
</Teleport>
其会在成功渲染出container后(即render回调函数调用时),因elState被设置为`{ type: "preview", container }`而被渲染到container中,这也是我们vue定制预览内容的方法啦!
2.然后是getOffset项,其接收一个{x:number, y:number}值,用于设定新的预览内容的偏移值。不过你应该注意到了,这里接收的是纯数字,没有带单位,那咋办好呢?
于是上面使用到了官方提供的用于获取指针位置并进行相应调整的函数
pointerOutsideOfPreview({ x: "16px",y: "8px"}),
传入的值为在指针位置的基础上,再进行偏移的值,正数为向→向↓,上面这段代码的含义就是按指针位置向→16px,向↓8px。在这里就可以使用单位了。该函数返回的值可以直接用于设定`getOffset`,因为是基于ts的,所以用这种方法最安全!
不过你可能还需要基于container本身来调整位置,比如希望鼠标刚好握在container的中间,不必担心,getOffset还可以为一个返回{x:number,y:number}的回调函数
getOffset: () => {
const rect = previewRef.value.getBoundingClientRect();
return { x: rect.width / 2, y: 16 };
},
这里的previewRef就是上文提到的,渲染在container内部的预览元素,官方推荐使用rect来设定getOffset哦!
此外,官方还提供了几个helper函数来辅助设置getOffset
centerUnderPointer: centers the custom native drag preview under the users cursor
container 的正中间在指针的下方,直接用就行
getOffset:centerUnderPointer,
preserveOffsetOnSource: applies the initial cursor offset to the custom native drag preview for a seamless experience
container的位置会和原生的预览图的光标偏移位置一致,说是可以做到无缝,但我个人没啥感觉
onGenerateDragPreview: ({ nativeSetDragImage, location, source }) => {
setCustomNativeDragPreview({
getOffset: preserveOffsetOnSource({
element: source.element,
input: location.current.input,
}),
render({ container }) {
/* ... */
},
nativeSetDragImage,
});
},
需要用到source.element:即该拖组件的html元素,后面会涉及到
location.current.input:location是一个pdnd内通用的拖拽历史记录,其中current为当前的拖拽行为,input则是该拖拽行为时的用户输入,包含这样一些内容,更多的就不深入了哈,我也没看那么深
{
"altKey": false, //有没有按住alt键
"button": 0,
"buttons": 1,
"ctrlKey": false, //同上ctrl键
"metaKey": false, //Windows 键 或 Super 键,IOS好像不一样,没用过
"shiftKey": false, //shift键
"clientX": 244, //记录的位置
"clientY": 94,
"pageX": 244,
"pageY": 94
}
事件:onDragStart(){}
拖拽开始时回调函数
onDragStart() {
elState.value = { type: "is-dragging" };
},
这里只是修改了一下当前状态,用来也可以做些别的,其可获取的参数为source和location
onDragStart({source,location}) {
……
},
这里就展开讲一下source,在这里的source对应的就是该拖组件本身
{
"element": ……, //该拖组件的HTML元素
"dragHandle": ……, //该拖组件的拖动手柄的HTML元素
"data": {} //该拖组件在初始化时传入的数据
}
显而易见,你可以通过source来获取拖组件的html元素,从而对其进行修改
但我们可是vue啊,为什么不用我们万能而优雅(笑)的响应式呢?
没错,我们完全可以在该拖组件数据初始化 getInitialData() (如果你已经忘记了这是啥,请翻看上面)传入一个响应式对象,并在这里通过data获取和修改
例如:
1.创建响应式对象task=reactive({content:"123"}),并在div上显示
2.通过getInitialData传入pdnd : return {task}
3.通过source的data获取这个task:const theTask = source.data.task
4.修改响应式对象的值 source.data.task.content = "456"
5.div上的值发生变化
聪明的你此时一定已经注意到了:我完全可以在组件内定义这个响应式对象,然后直接在onDragStart内访问到啊?
确实是这样的!但pdnd提供的这套数据流是可以在任何位置通过对应的data访问到的,这部分的内容会在下面的监视器部分体现出来!我之后还会写一篇嵌套的拖拽管理列表系统,里面也会涉及到这个技巧!
事件:onDrop(){}
放置时回调函数,这里是重置了组件的状态,如果你想在改变组件拖动时的样式,在拖拽开始和结束(也就是放置)时改变状态并响应到组件上即可
onDrop({source,location}) {
elState.value = idle;
},
source参数的内容同上,不过这里可以进一步讲一下location,其结构如下
{
//当前
"current": {
"dropTargets": [],
"input": ……
},
//历史记录
"previous": {
"dropTargets": []
},
//初始化该拖组件时
"initial": {
"input": ……,
"dropTargets": []
}
}
input之前讲过了,这里简单讲一下dropTargets,其是一个列表,包含该拖组件放置在的(drop)的放组件(之后讲)的初始化数据。由于本案例不涉及到放置在某个组件内部的情况,因此这里的放组件实际存储的是“这个拖组件经过了哪个放组件,放在哪个放组件边上”
感觉还是有点不好讲,你可以大致理解为可以通过该数据访问到放置目标的初始化数据
事件:onDrag(){}
没什么好讲的,拖拽过程中不断调用的事件,主要不要放一些过于复杂的逻辑进去以免影响效率。尽可能地在开始和结束时控制。
3.dropTargetForElements({})
终于该讲放组件了。放组件顾名思义,就是允许拖组件放置的组件,在这个示例中,每个<Task>是拖组件的同时也是放组件,当一个拖组件放置在某一个放组件上(或者旁边)时,会根据其相对位置判断其所处的边Edge,从而使其放在该放组件的上边或是下边
element: elRef.value
表示该HTML元素为一个放组件
canDrop({ source }) {}
返回一个布尔值,表示该source是否可以放置在该放组件上面。
如果返回false,那么放组件的所有事件也都不会触发。
source 还是和上面的一样,表示的即将放置在该放组件上面的拖组件的数据
canDrop({ source }) {
//不允许放置在自己身上
这一点对于要做嵌套的情况非常重要,否则会递归放置而溢出
if (source.element === elRef.value) {
return false;
}
// 对放置目标的data(也可以用别的啦)进行判断
要求其为一个task
return isTaskData(source.data);
},
你也可以增添更多需求,但需要注意官方对ts要求很严格,请务必返回一个布尔值
getData() {return ……}
重点!!!
获取该放组件相关的数据
你可以将其大致类比为 getInitialData(){} ,这个函数的返回值即为这个放组件的数据,可以在上面提到的location.current.dropTarget中获取
不过!注意!该函数并不会“设定”该拖组件的数据,而是在“需要获得这个拖组件数据时执行并返回”,在许多情况下(尤其是本案例中)可能会反复多次大量地调用(例如,当拖组件移动到放组件上面时),请不要在其中执行过于复杂的逻辑!
在本示例中,我们获取当前<Task>对应的taskData,但这还不够,我们还需要为其包装一层Edge信息
getData({ input }) {
const data = getTaskData(task);
return attachClosestEdge(data, {
element: elRef.value!,
input,
allowedEdges: ["top", "bottom"],
});
},
attachClosestEdge(data,{})是官方提供的包装函数,其会保持data不变的基础上,为其增添一些与Edge边缘有关的数据
其使用element(这里传入放组件本身)和 input(这里是getData的参数)来计算 input 对应的拖组件当前在 element 的哪一边,allowedEdges用来控制输出值(在这里将其控制为仅输出上边or下边)
这个包装后的数据中包含着计算结果的Edge值,但需要使用配套的extractClosestEdge()函数来解包获得(下面会讲到)
注意:这两个函数都是拓展包:hitbox里面的!需要额外安装这个包哦!
npm i @atlaskit/pragmatic-drag-and-drop-hitbox
getDropEffect(){return "none" | "copy" | "link" | "move"}
与↑类似,在访问该放组件的dropEffect时调用的函数,返回值即为该放组件的dropEffect,默认为move
我目前没有发现这些值的应用场景,毕竟drop事件是你自己定义的,改成copy也不会发生什么,必须要你自己修改drop事件的逻辑,或许可以当成一种标识符在某些官方提供的api中使用?如果你知道其应用场景,请和我分享!
getIsSticky() {}
这个函数返回布尔值,true表示该放组件具备“粘滞性”
Drop targets are generally calculated based on where the user's pointer is currently located. In some scenarios you might want to hold on to a previous drop target (make it "sticky"), even when the drop target is no longer being directly dragged over. This is useful if you want to maintain a selection while you are in gaps between drop targets.
getIsSticky()is calledrepeatedlywhile a drop target is no longer being dragged over to determine whether a drop target should be sticky.
官方的讲解略有抽象,我的理解是放组件的一系列影响会在拖组件上短暂地持续一段时间。我看了几个案例,基本上都是返回true的。
事件:onDropTargetChange({ self,source,location }) {}
当拖组件所处的放组件改变时执行的事件,比较少用,但性能更优秀
self即为该改变后的放组件,可以通过location的previews来获取改变之前的放组件数据
我们一般使用这个事件的拓展事件↓
事件:onDragEnter({self,source,location}){}
当拖组件进入该放组件内时执行的事件,这里的进入是指指针进入放组件的边缘,对于同一个放组件,如果在其边缘摩擦,而不进入另一个放组件的话,不会重复执行
onDragEnter({ self }) {
const closestEdge = extractClosestEdge(self.data);
elState.value = { type: "is-dragging-over", closestEdge };
},
这里通过self参数获取到了该放组件的数据,其结构与source类似
{
"data": ……
"element": 放组件的HTML,
"dropEffect": "move"
}
data即为通过getData获取的,该放组件的数据
dropEffect是通过getDropEffect获取的值,默认为"move"
示例中的 getData 会返回一个被`attachClosestEdge`加工后的数据,这里使用`extractClosestEdge`来读取其中的Edge信息,并用于设置该放组件的状态,值得一提的是,其他data中的数据仍然可以正常访问
还记得之前出现在结构→4.显示拖动位置的线条吗?这里的状态设置满足了其显示条件,并会将closestEdge传递给这个组件,从而使其显示的这个放组件的上边或着下边
也就是说,当这个放组件被一个拖组件经过时,其会在这个拖组件靠近的边显示一个线条,从而让用户更清晰地了解其在该位置放置时会发生的情况!
事件:onDragLeave({self,source,location}){}
当拖组件离开这个放组件时的事件,没啥好说的。示例中在此对放组件的状态初始化
事件:onDrag({self,source,location}){}
当一个拖组件在这个放组件内拖动时发生的事件,示例中在这里对放组件的状态进行检查,确保状态正确设置。同时也实现了当拖组件穿过放组件的过程中,边缘线条从一方移到另一方的过程
事件:onDrop({self,source,location}){}
当一个拖组件放置在该放组件上发生的事件,示例中仅在此对放组件的状态进行初始化,这是因为本示例并不涉及到放置时对放组件的操作,相关的逻辑被转移到了list.vue中的监视器中,从而使用list的响应式结构对列表属性进行操作。
如果你希望把拖组件放置在放组件上,在这里执行相关的逻辑也不会有什么问题,但如果你有多种放置操作,官方推荐的是在容器组件中使用监听器来分别处理不同放置操作的逻辑。
放组件终于讲完了!写到这里竟然都有1w字了()
4.monitorForElements({})
最后浅浅讲讲监视器!监视器是以每一个拖动过程作为单位进行监视的!
本示例中的监视器定义在list.vue中
canMonitor({initial, source}){bool}
对每一个拖动过程,如果返回true则表示该监视器允许监听该拖动过程,否则该监视器会忽略该拖动过程
其中的initial是平时的location中的initial,也就是该拖动过程创建时的初始相关数据
在本示例中,监听的对象(source)被限制为Task类型
canMonitor({ source }) {
return isTaskData(source.data);
},
可以监听的事件
onGenerateDragPreview - 某个拖拽即将开始
onDragStart - 某个拖拽以开始
onDropTargetChange - 某个拖拽的放置目标改变
onDragEnter - 某个拖组件进入了某个允许的放组件
onDragLeave - 某个拖组件离开了某个允许的放组件
onDrag - 慎用!大量调用的逻辑!某个拖拽发生的过程
onDrop - 某个拖组件放置在了某个位置,不一定会放置在放组件上,参见下方的示例
注意:这些事件的响应顺序为:拖组件中的事件→放组件中的事件→监视器中的事件
示例解析
没什么好讲的,就分析一下示例的代码吧
//监听放置事件
onDrop({ location, source }) {
//获取这个放组件当前(current)的放置目标(dropTargets)中最近的([0])的一个放组件
//这个方法也被用于获取多层嵌套放置时的最接近的放组件,等之后的多层嵌套文章吧
const target = location.current.dropTargets[0];
//要求其必须放置在某个放组件上
if (!target) {
return;
}
//获取其data,并要求此时监听到的拖组件和放组件都是TaskData
const sourceData = source.data;
const targetData = target.data;
if (!isTaskData(sourceData) || !isTaskData(targetData)) {
return;
}
//从tasks,也就是总的列表中获取两个组件的index,其中的taskId
是初始化时通过函数存放的哦,可以回去看一下
const indexOfSource = tasks.value.findIndex(
(task) => task.id === sourceData.taskId
);
const indexOfTarget = tasks.value.findIndex(
(task) => task.id === targetData.taskId
);
//要求两个组件必须在列表中
if (indexOfTarget < 0 || indexOfSource < 0) {
return;
}
//再次解析放组件(target)的数据中的Edge数据
const closestEdgeOfTarget = extractClosestEdge(targetData);
//这里使用到了官方提供的函数来为列表重新排序,可以稍微详细说一下,见下方
tasks.value = reorderWithEdge({
list: tasks.value,
startIndex: indexOfSource,
indexOfTarget,
closestEdgeOfTarget,
axis: "vertical",
});
//这里使用的官方提供的一个动画效果:triggerPostMoveFlash,不影响逻辑
const element = document.querySelector(
`[data-task-id="${sourceData.taskId}"]`
);
if (element instanceof HTMLElement) {
triggerPostMoveFlash(element);
}
},
reorderWithEdge
讲一下官方提供的列表重排序函数,顾名思义其是一个通过Edge信息来进行重排序的函数,其需要的参数包括
list: [] //需要重排序的列表,注意该函数并不会直接修改该列表,而是返回修改后的列表
startIndex: number //需要重排序的起始元素的index,也就是source的index,因为我们拖动的对象就是source嘛!那自然就要改变source的位置啦
indexOfTarget: number //需要重排序到的目标位置的index,我们把source移动到了target的前面or后面,此时自然也就需要把source的位置重排序到target的前面or后面
closestEdgeOfTarget: Edge | null //source离target最近的边缘,会根据这个边缘和下面的axis来决定source的新位置是在target的前面还是后面。这里我们是通过解析edge数据获得Edge,但其实你手动传字符串的上下左右也行,之后的嵌套排序就要用这个方法
axis: 'vertical' | 'horizontal' //重排序的顺序或者说方法,只有两种方式,垂直方式会在edge为top或bottom时,将source排序到target的前面or后面,水平方式则会在edge为left或right时将source排序到target的前or后
需要注意这个方法也是hitbox这个拓展库里面的,如果你漏看了上面的下载方式,可以回到onDragEnter事件看
结语
讲完了!有什么查漏补缺请留言哈,有疑问也可以问但我只能回答我懂的部分!官方文档虽然东一块西一块还全是TSX,但该讲的内容都是有的,就是啃生肉真累啊(真累啊)
之后会写一篇关于多层嵌套的文章,也没啥内容,就是在上述基础上增添了嵌套的一些注意项!就这样哈,辛苦了!
火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。
更多推荐
所有评论(0)