BLOG / POST
树形结构数据的拖动排序
实现树形结构数据的拖拽排序,处理节点间的层级与位置变更。
- algorithm
- frontend
实现如下结构的数据,用户可进行拖拽排序
- #folder-1
- #table-1-1
- #folder-1-2
- #table-1-2-1
- #folder-1-3
- #table-3(插入位置)
- #table-1-1
- #folder-2
- #table-3
需求描述:
- 实现上面结构的数据,目录节点可以嵌套子节点,表节点则只能是叶子节点(即不可嵌套子节点)
- 用户可自由拖拽节点到任意位置,例如:将
#table-3拖动到#folder-1-3下面。
表设计
我们从数据库设计开始,首先需要表示节点间层级关系,因此需要一个 parentId 来记录当前节点的父节点ID,parentId 为空时表示当前节点是最外层节点。
| 字段名 | 说明 |
|---|---|
| id | 主键 |
| parent_id | 父节点ID |
| node_name | 节点名称 |
| node_type | 节点类型(0:目录,1:表) |
节点层级关系表述完了,接下来需要思考如何排序。
通过 Sort 字段排序
首先想到的是添加 sort 字段进行排序。查询数据时按照 sort 的大小进行排列。
| 字段名 | 说明 |
|---|---|
| id | 主键 |
| parent_id | 父节点ID |
| sort | 排序值 |
| node_name | 节点名称 |
| node_type | 节点类型(0:目录,1:表) |
SELECT * FROM nodes ORDER BY sort ASC;
通过添加sort 字段,在SQL查询阶段就可以对数据排序了。但是这种做法有个问题:
每当拖拽一条记录,需要前端吧排序好后的ID数组给到后端,后端根据ID数组更新多条数据的排序值
如果数据量不大,我一般会对所有数据的sort值都更新一遍,简单粗暴。
当然也可以通过拖拽后和拖拽前的位置计算需要修改的数据范围,从而减少需要更新的数据数量。
这种做法本质上是通过「数组」的数据结构来更新数据,相当于把数组的一个位置的数据插入到另一个位置,从而使得插入位置后的数据都需要整体往后移动。
那么有没有办法减少每次拖动带来的多条数据更新呢?
通过链表排序
准确来说是通过单链表进行排序,在数据表中添加 prev_id 字段,这个字段用于记录**当前节点的前一个节点的id 。**通过下面的数据来具体说明:
| id | parent_id | prev_id | node_name | node_type |
|---|---|---|---|---|
| 1 | folder-1 | 0 | ||
| 2 | 1 | folder-2 | 0 | |
| 3 | 2 | table-3 | 1 | |
| 4 | 1 | table-1-1 | 1 | |
| 5 | 1 | 4 | folder-1-3 | 0 |
根据 parent_id 和 prev_id 的规则,得到如下结构的数据
如何排?
定义的这种结构的数据,不能在通过SQL查询直接将数据排序了,则需要先从数据库把数据都取出,通过程序代码进行排序。以 Golang 为例代码如下:
func (b *projectNodeBiz) GetProjectNodeList(ctx fiber.Ctx, projectId string) ([]*res.ProjectNodeItem, error) {
// 1. 从数据库读取数据
list, err := b.Queries.GetProjectNodeList(ctx.Context(), projectId)
if err != nil {
log.Errorf("[biz] select project node list error: %v", err)
return nil, errors.New("internal server error")
}
// 2.生成数据
resList := genProjectNodeTree(list, "")
return resList, nil
}
// 递归生成树
func genProjectNodeTree(list []repository.ProjectNode, parentId string) []*res.ProjectNodeItem {
resList := []*res.ProjectNodeItem{}
for _, node := range list {
if node.ParentID.String == parentId {
resList = append(resList, &res.ProjectNodeItem{
ID: node.ID,
ParentID: node.ParentID.String,
PrevID: node.PrevID.String,
ProjectID: node.ProjectID,
NodeType: node.NodeType,
Children: []*res.ProjectNodeItem{},
})
}
}
for _, node := range resList {
// 递归调用
node.Children = genProjectNodeTree(list, node.ID)
}
// 根据prevId进行排序
resList = sortChildren(resList)
return resList
}
/**
* 根据prevId排序 prevId为空则认为是当前层级内的根节点,prevId 表示当前节点的上一个节点
*/
func sortChildren(list []*res.ProjectNodeItem) []*res.ProjectNodeItem {
var current *res.ProjectNodeItem
sortMap := make(map[string]*res.ProjectNodeItem)
// 把数据根据 prevID 放到一个map上面。这里有个隐藏的约束就是,当前层级的prevId 不会重复。
for _, child := range list {
if child.PrevID == "" {
current = child
} else {
sortMap[child.PrevID] = child
}
}
var result []*res.ProjectNodeItem
for current != nil {
result = append(result, current)
current = sortMap[current.ID]
}
return result
}
简单解释下上面代码做了哪些
- 首先从数据库读取节点数据
- 通过递归将数据组合成树形结构的数组数据。
- 在递归的同时对children的数据进行排序,通过 prevId 排序。
通过链表方式进行排序的好处就是,他减少了需要更新数据的数量。其本质是对链表进行插入排序等操作。
结合上面的示例数据,我们将 #table-3 拖中到 #folder-1-3 下面。需要做如下步骤:
- 将**
#table-3**从链表folder-1 —> folder-2 —> table-3中取下来。 - 此时需要判断下**
#table-3** 后面是否还有节点,如果有则将其的下一个节点的prev_id 改成当前节点的 prev_id 的值。其实也就是从链表取下一个节点后,再把链表连接起来。 - 找到需要插入的节点位置,此时需要插入的节点位置是**
#folder-1-3** 的第一个节点位置,这意味着插入的位置没有上一个节点,因此可以将当前节点的prev_id 设置为空,parent_id 设置为**#folder-1-3** 的ID。其实就是将其设置为#folder-1-3children链表的头节点。 - 同样的,需要判断当前插入节点位置后面有没有下一个节点,如果有,则需要将其prev_id 设置成当前节点的ID。同样是为了将链表连接起来。
这种做法避免了更新多条数据的排序值。最多需要更改三条数据的值,即:
- 当前数据的prev_id 和 parent_id
- 当前移除位置的下一个节点的prev_id (如果有)
- 当前插入位置的下一个节点的prev_id (如果有)
以下是代码示例:
func (b *projectNodeBiz) MoveProjectNode(ctx fiber.Ctx, updatedBy string, req *req.MoveProjectNodeReq) error {
tx, err := b.Queries.NewDB().Begin(ctx.Context())
if err != nil {
log.Errorf("[biz] begin transaction error: %v", err)
return errors.New("internal server error")
}
defer tx.Rollback(ctx.Context())
qtx := b.Queries.WithTx(tx)
currentNode, err := qtx.GetProjectNode(ctx.Context(), req.NodeId)
if err != nil {
log.Errorf("[biz] select project node by id error: %v", err)
return errors.New("internal server error")
}
// 1.如果有下一个兄弟节点,则将下一个兄弟节点的prevId设置为当前节点的prevId
currentNodeNextSibling, err := qtx.GetProjectNodeByPrevId(ctx.Context(), pgtype.Text{String: req.NodeId, Valid: true})
if err == nil && currentNodeNextSibling.ID != "" {
if err := qtx.MoveProjectNode(ctx.Context(), repository.MoveProjectNodeParams{
ID: currentNodeNextSibling.ID,
PrevID: pgtype.Text{String: currentNode.PrevID.String, Valid: true},
ParentID: pgtype.Text{String: currentNodeNextSibling.ParentID.String, Valid: true},
UpdatedBy: pgtype.Text{String: updatedBy, Valid: true},
UpdatedAt: pgtype.Timestamp{Time: time.Now(), Valid: true},
}); err != nil {
log.Errorf("[biz] move next sibling node error: %v", err)
return errors.New("internal server error")
}
}
if req.PreNodeId == "" {
// 2.如果preNodeId为空,则将当前节点设置为parentId的第一个子节点
firstChild, err := qtx.GetProjectNodeByPrevIdAndParentId(ctx.Context(), repository.GetProjectNodeByPrevIdAndParentIdParams{
ParentID: pgtype.Text{String: req.ParentId, Valid: true},
PrevID: pgtype.Text{String: "", Valid: true},
})
if err == nil && firstChild.ID != "" {
if err := qtx.MoveProjectNode(ctx.Context(), repository.MoveProjectNodeParams{
ID: firstChild.ID,
PrevID: pgtype.Text{String: currentNode.ID, Valid: true},
ParentID: pgtype.Text{String: firstChild.ParentID.String, Valid: true},
UpdatedBy: pgtype.Text{String: updatedBy, Valid: true},
UpdatedAt: pgtype.Timestamp{Time: time.Now(), Valid: true},
}); err != nil {
log.Errorf("[biz] move current node error: %v", err)
return errors.New("internal server error")
}
}
if err := qtx.MoveProjectNode(ctx.Context(), repository.MoveProjectNodeParams{
ID: currentNode.ID,
PrevID: pgtype.Text{String: "", Valid: true},
ParentID: pgtype.Text{String: req.ParentId, Valid: true},
UpdatedBy: pgtype.Text{String: updatedBy, Valid: true},
UpdatedAt: pgtype.Timestamp{Time: time.Now(), Valid: true},
}); err != nil {
log.Errorf("[biz] move current node error: %v", err)
return errors.New("internal server error")
}
} else {
// 3.如果preNodeId不为空,则将当前节点设置为preNode的下一个兄弟节点
preNode, err := qtx.GetProjectNode(ctx.Context(), req.PreNodeId)
if err != nil {
log.Errorf("[biz] select project node by id error: %v", err)
return errors.New("internal server error")
}
preNodeNextSibling, err := qtx.GetProjectNodeByPrevId(ctx.Context(), pgtype.Text{String: preNode.ID, Valid: true})
if err == nil && preNodeNextSibling.ID != "" {
// 3.1 如果preNode有下一个兄弟节点,则将下一个兄弟节点的prevId设置为当前节点的Id
if err := qtx.MoveProjectNode(ctx.Context(), repository.MoveProjectNodeParams{
ID: preNodeNextSibling.ID,
PrevID: pgtype.Text{String: currentNode.ID, Valid: true},
ParentID: pgtype.Text{String: preNodeNextSibling.ParentID.String, Valid: true},
UpdatedBy: pgtype.Text{String: updatedBy, Valid: true},
UpdatedAt: pgtype.Timestamp{Time: time.Now(), Valid: true},
}); err != nil {
log.Errorf("[biz] move next sibling node error: %v", err)
return errors.New("internal server error")
}
}
if err := qtx.MoveProjectNode(ctx.Context(), repository.MoveProjectNodeParams{
ID: currentNode.ID,
PrevID: pgtype.Text{String: preNode.ID, Valid: true},
ParentID: pgtype.Text{String: preNode.ParentID.String, Valid: true},
UpdatedBy: pgtype.Text{String: updatedBy, Valid: true},
UpdatedAt: pgtype.Timestamp{Time: time.Now(), Valid: true},
}); err != nil {
log.Errorf("[biz] move current node error: %v", err)
return errors.New("internal server error")
}
}
return tx.Commit(ctx.Context())
}
其实从上面的代码可以看出,我们复杂化了查询列表和移动节点的代码,为了是减少排序时需要修改的数据量。除了这两个业务代码修改了以外,删除节点的操作同样需要修改,需要修改的地方是:删除当前节点后,需要将链表再连接起来。
总结
链表排序的优点如下:
- 移动节点操作效率高 - 只需要修改指针(PrevID)
- 适合频繁的插入和删除操作
- 维护顺序的成本低 - 不需要重新排序整个数组
- 空间复杂度低 - 只需要存储节点间的引用关系
缺点:
- 不支持随机访问 - 必须从头遍历
- 如果链表断裂(数据不完整),可能会丢失部分数据
- 遍历性能较差 - O(n)的时间复杂度
Sort排序方式的优点如下:
- 实现简单直观
- 支持多种排序条件
- 支持随机访问
- 不需要维护额外的节点关系
缺点:
- 每次修改都需要重新排序,时间复杂度 O(nlogn)
- 对于频繁的插入删除操作,性能较差
- 批量更新排序字段比较麻烦
所以具体使用那种排序方式,取决于业务的侧重点以及数据量。如果排序操作频繁可以考虑使用链表排序。如果数据不大,考虑到代码维护成本,那么sort排序是个不错的选择。
我比较侧重于链表排序,因为我写出来了🤭。同时考虑到递归的次数,我可能会限制目录文件夹可嵌套的层数来减少递归次数,如果用户需要更多层的嵌套,得加钱😂
完。