前言
在许多面向个人用户的程序中,有列表的地方通常就有排序,例如服装类目、乐器类目之类的,管理人员通常希望自己能够手动设置一些类目的顺序。
要设置顺序,最简单的方式当然是新增一个order字段,然后让管理人员手动输入order的值,通常是一个整数,一般是数值越小排名越靠前。这样的方式用起来的时候比较低效,代码实现却很简单。
相比之下,拖拽排序就是一个非常自然的解决方案,用起来会稍微顺手一点,这也是本文的主要内容。
对比其他方案
写这篇文章之前,我在网上搜索了一下相关的文章,得到的无非是两类,一种是纯前端的,例如用el-table+SortableJS实现拖拽排序的,另一种的包含后端的设计的,但是实现的逻辑却很复杂,例如什么中间数啊、双向链表啊、数组存储顺序之类的,看起来就很复杂,实现起来更复杂,而且还容易出bug,于是我一拍脑门就写了这个东西。
效果图
原理
下面讲一下拖拽排序实现的原理,后续的代码实现基本都围绕着这个原理展开
- 增加一个整数的order字段来表示顺序
假设我们现在有一组数据,每一条数据都有一个order字段,order是一个整数,数值越小越靠前,大概是下面这个样子。
[
{data: 'A', order: 1},
{data: 'B', order: 2},
{data: 'C', order: 3},
{data: 'D', order: 4},
{data: 'E', order: 5}
]
- 让order字段自增
当我们新增一个数据的时候默认把它放到最后,即让新的order等于之前最大的order+1,例如新增一个数据F,则得到
[
{data: 'A', order: 1},
{data: 'B', order: 2},
{data: 'C', order: 3},
{data: 'D', order: 4},
{data: 'E', order: 5},
{data: 'F', order: 6}
]
- 删除一条数据时直接删除
当我们把初始数据中的C删掉时,只需要直接删掉,剩下的数据依然是保持原有的顺序的,不需要任何额外的操作,效果如下:
[
{data: 'A', order: 1},
{data: 'B', order: 2},
{data: 'D', order: 4},
{data: 'E', order: 5}
]
- 拖拽排序时批量设置数据的order字段
现在让我们基于第3步中的数组接着说,假设前端通过拖拽排序之后把D这一条数据放到了第一位,即数组的顺序变成了DABE,但是由于order的值还没有改变,所以数据看起来是这样子的。
[
{data: 'D', order: 4},
{data: 'A', order: 1},
{data: 'B', order: 2},
{data: 'E', order: 5}
]
虽然前端列表的数据已经改变了,但是因为order的值没有改变,下一次从后端查询数据时返回的仍然是原来的数据(ABDE)。所以前端在数据改变的时候应该发请求告诉后端修改order的值,从而让下次的请求能够返回排序之后的顺序(DABE)。那order的值应该怎么修改呢?这里还是直接看代码吧。
// 假设我们能得到拖拽排序前后的两个数组
const before = [
{data: 'A', order: 1},
{data: 'B', order: 2},
{data: 'D', order: 4},
{data: 'E', order: 5}
];
const after = [
{data: 'D', order: 4},
{data: 'A', order: 1},
{data: 'B', order: 2},
{data: 'E', order: 5}
];
/*
// 那么order应该这样修改,
// 'D'的order改为1,'A'的order改为2,'B'的order改为4,'E'的order改为5
const result = [
{data: 'D', order: 1},
{data: 'A', order: 2},
{data: 'B', order: 4},
{data: 'E', order: 5}
]
// 后端的order的值修改成这样之后下次查询得到的顺序就是DABE了。
*/
// 仔细观察的话很容易看出来result数组是这样生成的
const result = after.map((item, index) => ({
...item,
order: before[index].order
}));
后端实现
后端的实现可简单了,先在原来的表上新增一个自增的非空字段,然后在原来CRUD接口的基础上新增一个批量设置order的接口就好了。
- 添加order字段,以postgreSQL为例
alter table
brands
add
column "order" serial not null;
说明:serial是postgreSQL中的自增字段,not null代表为非空,这样设置之后,表中原有的数据会自动添加上order,新增数据会自动添加一个自增的order。
- 新增一个接口批量设置order
后端框架我用的是fastify,感觉算是nodeJS里面比较好用而且高效的WEB框架了,数据连接工具我用的是knex,这里就简单意思一下,写这种接口对于哪怕有一点经验的后端开发来说那不是有手就行🐴
fastify.post("/brands/order", async (req) => {
const list = req.body.data; // [{id: 1, order: 2}, ...];
await knex.transaction(async (trx) => {
for (const {id, order} of list) {
await trx.table("brands").update({order}).where({id});
}
});
});
说明:上面这个操作的要点是把更新多行数据的sql放在一个transaction当中,这样不仅sql执行的更快,而且一组数据要么都更新要么都不更新。
前端实现
如果你是用v-for遍历数组来展示前端数据的,那么我推荐你用这个Vue.Draggable。这是SortableJS的vue适配版,用法大概是这样的:
<!-- 要求被拖动的元素是Draggable组件的子元素,即<Draggable>下面直接就是v-for -->
<Draggable v-model="list">
<div v-for="item in list">{{item}}</div>
<Draggable>
<!-- 有一种特殊情况是v-for可以放在TransitionGroup当中,然后TransitionGroup直接放在Draggable下面 -->
<Draggable v-model="list">
<TransitionGroup>
<div v-for="item in list">{{item}}</div>
</TransitionGroup>
<Draggable>
但是如果你像我一样已经使用了Ant Design Vue之类的UI组件,那就只能用原生的SortableJS了,其他的什么Element UI啥的也是差不多的。这里我不想过多的解释了自己去看SortableJS的官方文档也可以自己去实现一下,我直接把我正在用的代码贴出来吧。 说明:代码使用了jsx和typescript,是基于vue3的setup语法的
import { HolderOutlined } from "@ant-design/icons-vue";
import { Table } from "ant-design-vue";
import type { ColumnsType } from "ant-design-vue/es/table";
import { tableProps } from "ant-design-vue/es/table/Table";
import Sortable from "sortablejs";
import {
onBeforeUnmount,
onMounted,
type ComponentPublicInstance,
ref,
defineComponent,
computed,
type PropType,
onUpdated,
} from "vue";
export interface SortEvent<T = any> {
sorted: T[];
dataIndex: number;
newIndex: number;
}
export interface SortCallback<T = any> {
(e: SortEvent<T>): void;
}
export const DraggableTable = defineComponent({
props: {
...tableProps(),
onSort: { type: Function as PropType<SortCallback> },
rowKey: {
type: [String, Function] as PropType<string | ((record: any) => string)>,
required: true,
},
},
emits: ["sort"],
setup(props, { emit, slots, expose }) {
const columns = computed(() => props.columns);
const table = ref<ComponentPublicInstance>();
const sortable = ref<Sortable>();
onMounted(() => {
const el = table.value?.$el as HTMLElement;
const tbody = el?.querySelector?.(".ant-table-tbody") as HTMLElement;
if (!tbody) {
throw new Error("Ant Table组件异常");
}
sortable.value = Sortable.create(tbody, {
handle: ".draggable-table-handle",
draggable: ".ant-table-row",
filter: ".ant-table-measure-row",
onEnd: ({ oldDraggableIndex: oldIndex, newDraggableIndex: newIndex }) => {
const arr = props.dataSource?.slice();
if (!arr || typeof oldIndex !== "number" || typeof newIndex !== "number") {
return;
}
const [element] = arr.splice(oldIndex, 1);
arr.splice(newIndex, 0, element);
emit("sort", {
sorted: arr,
dataIndex: oldIndex,
newIndex,
});
},
});
});
onBeforeUnmount(() => {
sortable.value?.destroy();
sortable.value = undefined;
});
// #start 这个地方是为了修复sortableJS和Ant Table一起使用时存在的一个bug,拖拽改变顺序之后当dataSource变少时某些DOM没有正常移除
onUpdated(() => {
const el = table.value?.$el as HTMLElement;
const tbody = el?.querySelector?.(".ant-table-tbody") as HTMLElement;
if (!tbody) {
throw new Error("Ant Table组件异常");
}
const arr = props.dataSource?.slice() || [];
const getKey = (item: any) => {
const rowKey = props.rowKey;
if (typeof rowKey === "string") {
return item[rowKey];
}
return rowKey(item);
};
const keys = arr.map((x) => getKey(x)?.toString());
const redundantItems = [...tbody.children].filter((x) => {
const rowKey = (x as HTMLElement).dataset.rowKey;
return rowKey && !keys.includes(rowKey);
});
redundantItems.forEach((e) => e.remove());
});
expose({ table, sortable });
const columnsWithDragHandle = computed(() => {
if (columns.value) {
const newColumns: ColumnsType = [
{
title: "",
dataIndex: "draggable_table_handle",
key: "draggable_table_handle",
fixed: true,
width: 24,
customRender() {
return (
<HolderOutlined class="draggable-table-handle text-base text-grey-400 cursor-pointer"></HolderOutlined>
);
},
},
...columns.value,
];
return newColumns;
}
return columns.value;
});
return () => (
<Table
ref={(v) => {
table.value = v as any;
}}
{...props}
columns={columnsWithDragHandle.value}
>
{{ ...slots }}
</Table>
);
},
});
说明,写出来的这个Draggable组件的用法和Ant Design Vue的Table组件的用法是一样的,区别是rowKey变成了必传,不传rowKey而使用SortableJS可能会导致显示错乱,另外多了一个@sort事件(e: {sorted: any[]; dataIndex: number; newIndex: number;}) => void,sorted是排序之后的数组,dataIndex是被拖动的元素原来的下标,newIndex是被拖动的元素在sorted中的下标。
所以很明显我们可以给@sorted绑定一个事件onDataSort来调用后端的接口从而批量修改order,例如
// 假设原来是数据是这个
const list = [
{...,order: 1},
{...,order: 2},
{...,order: 3},
{...,order: 3},
]
// 那么onDataSort就这样写
const onDataSort = async ({sorted}) => {
// 显示loading,在请求完成成避免用户再次拖拽
const data = sorted.map((item,index) => ({...item, order: list[index].order}));
// 调用后端的数据
await fetch("/brands/order", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data)
})
// 取消loading,让用户可以继续拖拽
}
结束语
写完收工