Angular17 CDK拖拽cdkDropListDropped事件仅触发一次问题排查
Angular 17嵌套树拖拽异常:节点首次拖拽后无法触发后续事件
问题现象
基于Angular Material拖拽API开发的嵌套树组件,页面包含左右两棵树,需求是将右侧树节点拖拽至左侧树。当前存在以下异常:
- 首次拖拽右侧节点到左侧树时,
cdkDropListDropped事件正常触发 - 后续针对该节点的拖拽操作完全无事件触发,但拖拽到同层级其他节点仍正常工作
- 只要节点触发过任意拖拽相关事件,后续所有事件均失效,检查DOM发现节点的ID和类名无变化
组件代码
HTML模板
<mat-tree [dataSource]="categories" [treeControl]="treeControl" class="fb-tree"> <mat-tree-node *matTreeNodeDef="let category" matTreeNodeToggle matTreeNodePadding [ngClass]="{' border-t ': treeMode === 'INTERNAL'}"> @if(treeMode === " INTERNAL"){ <div class=" uppercase" cdkDropList [id]="getCategoryCode(category)" (cdkDropListDropped)="drop($event)" [cdkDropListData]="nodeCategoryList"> <div class=" grid grid-cols-2 gap-0"> <div class="grid grid-cols-8"> @if(hasEmojiAndRank(category)){ <div *ng-if="hasEmojiAndRank(category)" class=" justify-start items-start w-1/6 self-center" [class.ml-11]="isRoot(category)"> {{ category.emoji }} </div> <div class=" justify-start items-start w-1/6 self-center" [class.ml-2]="!isRoot(category)" [class.ml-12]="isRoot(category)"> {{ category.rank }} </div>} <div class=" justify-start items-start w-4/6 uppercase" [class.ml-10]="isRoot(category)" [class.ml-1.5]="!isRoot(category)"> {{ category.code }} </div> </div> <div class="group flex relative justify-end"> <button mat-icon-button [matMenuTriggerFor]="menu" aria-label=" icon-button with a menu"> <mat-icon>more_vert</mat-icon> </button> <mat-menu #menu="matMenu"> <button mat-menu-item (click)="addNewItem.emit(category)"> <mat-icon>add</mat-icon> <span>Add Category</span> </button> <button mat-menu-item (click)="editItem.emit(category)"> <mat-icon>edit</mat-icon> <span>Edit</span> </button> <button mat-menu-item (click)="removeItem.emit(category)"> <mat-icon>delete</mat-icon> <span>Delete</span> </button> </mat-menu> </div> </div> <div class="ml-10"> <mat-chip-set #chipGrid> @for (ex of category.partners; track ex.entries) { @for (value of ex[1]; track value) { <mat-chip class="uppercase" (removed)="chipRemoved(category, value, ex[0])"> [{{ ex[0] }}] {{ value }} <button matChipRemove [attr.aria-label]="'remove ' + value"> <mat-icon>cancel</mat-icon> </button> </mat-chip> }} </mat-chip-set> </div> </div> }@else { <div cdkDropList CdkDragStart [id]="buildPartnerId(category)" [cdkDropListConnectedTo]="nodeCategoryList" (cdkDropListDropped)="partnerDrop($event)" (cdkDragStarted)="startDrag(category)" [cdkDropListData]="nodeCategoryList"> <div cdkDrag class="w-max h-max">{{ category.code }}</div> </div> } </mat-tree-node> <mat-nested-tree-node *matTreeNodeDef="let category; when: hasChild" matTreeNodePadding> @if(treeMode === "INTERNAL"){ <div class="flex flex-col" cdkDropList id="{{ category.code }}" (cdkDropListDropped)="drop($event)" [cdkDropListData]="nodeCategoryList"> <div class="mat-tree-node grid grid-cols-2 gap-0 border-t"> <div class="grid grid-cols-8"> <div class="flex flex-col justify-start items-start w-1/6 mr-10"> <button mat-icon-button matTreeNodeToggle [attr.aria-label]="'Toggle ' + category.code"> <mat-icon class="mat-icon-rtl-mirror"> {{ treeControl.isExpanded(category) ? 'expand_more' : 'chevron_right' }} </mat-icon> </button> </div> <div class="flex flex-col justify-start items-start w-1/6 ml-2 self-center"> {{ category.emoji }} </div> <div class="flex flex-col justify-start items-start w-1/6 self-center ml-3"> {{ category.rank }} </div> <div class="flex flex-col justify-start items-start ml-1 w-5/6 self-center"> {{ category.code }} </div> </div> <div class="group flex relative justify-end"> <button mat-icon-button [matMenuTriggerFor]="menu" aria-label=" icon-button with a menu"> <mat-icon>more_vert</mat-icon> </button> <mat-menu #menu="matMenu"> <button mat-menu-item (click)="addNewItem.emit(category)"> <mat-icon>add</mat-icon> <span>Add Category</span> </button> <button mat-menu-item (click)="editItem.emit(category)"> <mat-icon>edit</mat-icon> <span>Edit</span> </button> <button mat-menu-item (click)="removeItem.emit(category)"> <mat-icon>delete</mat-icon> <span>Delete</span> </button> </mat-menu> </div> </div> <div class="ml-10"> <mat-chip-set #chipGrid> @for (ex of category.partners; track ex.entries) { @for (value of ex[1]; track value) { <mat-chip class="uppercase" (removed)="chipRemoved(category, value, ex[0])"> [{{ ex[0] }}] {{ value }} <button matChipRemove [attr.aria-label]="'remove ' + value"> <mat-icon>cancel</mat-icon> </button> </mat-chip> }} </mat-chip-set> </div> </div> }@else { <div class="mat-tree-node" cdkDropList id="{{ category.code }}"> <button mat-icon-button matTreeNodeToggle [attr.aria-label]="'Toggle ' + category.code"> <mat-icon class="mat-icon-rtl-mirror"> {{ treeControl.isExpanded(category) ? 'expand_more' : 'chevron_right' }} </mat-icon> </button> <div cdkDropList [id]="buildPartnerId(category)" [cdkDropListConnectedTo]="nodeCategoryList" (cdkDropListDropped)="drop($event)" [cdkDropListData]="nodeCategoryList"> <div cdkDrag class="w-max h-max">{{ category.code }}</div> </div> </div> } <div [class.fb-tree-invisible]="!treeControl.isExpanded(category)" role="group"> <ng-container matTreeNodeOutlet></ng-container> </div> </mat-nested-tree-node>
TypeScript代码
export class FbTreeComponent implements OnInit, OnDestroy { @Input() categories = new MatTreeNestedDataSource<Category>(); @Input() partnerCategories!: PartnerCategory[]; @Input() treeControl = new NestedTreeControl<Category>((node) => node.children); @Input() treeMode: string; @Input() nodeCategoryList!: string[]; @Output() dropCategory = new EventEmitter<CdkDragDrop<string[]>>(); @Output() partnerDropCategory = new EventEmitter<CdkDragDrop<string[]>>(); @Output() addNewItem = new EventEmitter<Category>(); @Output() editItem = new EventEmitter<Category>(); @Output() removeItem = new EventEmitter<Category>(); @Output() removeChip = new EventEmitter<any>(); constructor( private cd: ChangeDetectorRef, private localStorageService: LocalStorageService ) { } ngOnInit(): void { console.log(this.categories); } hasChild = (_: number, node: Category) => !!node.children && node.children.length > 0; isRoot(node: Category): boolean { return !node.children || node.children.length === 0; // or another condition that defines a root node } drop(event: CdkDragDrop<any>) { this.dropCategory.emit(event); } }
排查与解决方案
1. 确保cdkDropList全局ID唯一
拖拽系统依赖唯一ID识别拖拽列表,需保证左右两棵树的所有cdkDropListID不重复。检查buildPartnerId(category)和getCategoryCode(category)的实现,确保返回值全局唯一,比如给右侧树节点ID添加前缀(如partner-{{category.code}})。
2. 拖拽后强制触发变更检测
当节点被拖拽到左侧树并更新数据源后,调用变更检测方法确保组件视图和拖拽系统同步:
drop(event: CdkDragDrop<any>) { this.dropCategory.emit(event); // 触发变更检测 this.cd.detectChanges(); }
3. 移除嵌套的cdkDropList
右侧树模板中存在cdkDropList嵌套(外层div和内层div都标记了cdkDropList),这会导致拖拽事件被拦截。保留最内层或最外层的cdkDropList即可,比如移除外层的cdkDropList属性:
<!-- 修改前 --> <div class="mat-tree-node" cdkDropList id="{{ category.code }}"> <!-- 修改后 --> <div class="mat-tree-node">
4. 动态更新cdkDropListConnectedTo的目标列表
当左侧树新增节点时,需要将新节点的ID添加到nodeCategoryList中,确保右侧树的拖拽列表能关联到新的目标:
// 在父组件处理drop事件时更新nodeCategoryList onDropCategory(event: CdkDragDrop<any>) { // 处理节点添加逻辑... this.nodeCategoryList.push(newNodeId); }
5. 调试拖拽事件生命周期
添加cdkDragEnded事件监听,确认拖拽事件是否正常结束,帮助定位问题:
<div cdkDrag class="w-max h-max" (cdkDragEnded)="onDragEnded($event)">{{ category.code }}</div>
onDragEnded(event: CdkDragEnded) { console.log('拖拽结束事件:', event); }
内容的提问来源于stack exchange,提问作者Amine Choukri
相关产品推荐
相关产品推荐

