最简单的拖拽排序的实现思路SortableJS + ATable+NodeJS后端

826 阅读6分钟

前言

在许多面向个人用户的程序中,有列表的地方通常就有排序,例如服装类目、乐器类目之类的,管理人员通常希望自己能够手动设置一些类目的顺序。

要设置顺序,最简单的方式当然是新增一个order字段,然后让管理人员手动输入order的值,通常是一个整数,一般是数值越小排名越靠前。这样的方式用起来的时候比较低效,代码实现却很简单。

相比之下,拖拽排序就是一个非常自然的解决方案,用起来会稍微顺手一点,这也是本文的主要内容。

对比其他方案

写这篇文章之前,我在网上搜索了一下相关的文章,得到的无非是两类,一种是纯前端的,例如用el-table+SortableJS实现拖拽排序的,另一种的包含后端的设计的,但是实现的逻辑却很复杂,例如什么中间数啊、双向链表啊、数组存储顺序之类的,看起来就很复杂,实现起来更复杂,而且还容易出bug,于是我一拍脑门就写了这个东西。

效果图

demodrag.gif

原理

下面讲一下拖拽排序实现的原理,后续的代码实现基本都围绕着这个原理展开

  1. 增加一个整数的order字段来表示顺序
    假设我们现在有一组数据,每一条数据都有一个order字段,order是一个整数,数值越小越靠前,大概是下面这个样子。
[
{data: 'A', order: 1}, 
{data: 'B', order: 2}, 
{data: 'C', order: 3}, 
{data: 'D', order: 4}, 
{data: 'E', order: 5}
]
  1. 让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}
]
  1. 删除一条数据时直接删除
    当我们把初始数据中的C删掉时,只需要直接删掉,剩下的数据依然是保持原有的顺序的,不需要任何额外的操作,效果如下:
[
{data: 'A', order: 1}, 
{data: 'B', order: 2}, 
{data: 'D', order: 4}, 
{data: 'E', order: 5}
]
  1. 拖拽排序时批量设置数据的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的接口就好了。

  1. 添加order字段,以postgreSQL为例
alter table
  brands
add
  column "order" serial not null;

说明:serial是postgreSQL中的自增字段,not null代表为非空,这样设置之后,表中原有的数据会自动添加上order,新增数据会自动添加一个自增的order。

  1. 新增一个接口批量设置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,让用户可以继续拖拽
}

结束语

写完收工