Blazor集成SortableJS首次拖拽正常第二次排序异常如何解决?
问题描述
我在Blazor应用中集成了sortablejs实现拖拽排序功能,首次拖拽可以正常生效,但第二次触发dragStart时会出现排序异常问题,具体现象如下:
Blazor组件代码
<ul class="ulManufacturers"> @foreach (var m in manufacturers) { var mGrayedOut = m.Visible ? "" : "grayout"; var toggleButtonImg = showManufacturerBikes[m] ? "up" : "down"; var toggleButtonTitle = showManufacturerBikes[m] ? $"Sakrij {m.Nickname} motore" : $"Prikaži {m.Nickname} motore"; <li data-id="@m.Id"> <div class="divManufacturerHeader"> <div class="buttons"> <img class="zoom-on-hover cursor-grab fit-image grayout drag-handle" src="/lib/bootstrap-icons-1.5.0/list.svg" data-toggle="tooltip" data-placement="top" title="Uhvati i premesti"/> <img class="zoom-on-hover cursor-pointer fit-image grayout" src="/lib/bootstrap-icons-1.5.0/plus-circle.svg" data-toggle="tooltip" data-placement="top" title="Dodaj novi @m.Nickname motor" @onclick="()=>AddBike(m)" /> <img class="zoom-on-hover cursor-pointer fit-image grayout" src="/lib/bootstrap-icons-1.5.0/arrow-@(toggleButtonImg)-circle.svg" data-toggle="tooltip" data-placement="top" title="@toggleButtonTitle" @onclick="()=>ToggleBikes(m)" /> </div> <div class="divLogoOrName"> @if (string.IsNullOrEmpty(m.LogoUrl)) { <h1 class="zoom-on-hover cursor-pointer @mGrayedOut" @onclick="()=>SelectManufacturer(m)"> @m.Nickname </h1> } else { <img class="zoom-on-hover cursor-pointer fit-image imgManufacturerLogo @mGrayedOut" src="@m.LogoUrl" @onclick="()=>SelectManufacturer(m)" /> } </div> </div> <div class="divBikeThumbnailsContainer sortable-list @(showManufacturerBikes[m] ? "" : "hidden")"> @foreach (var b in m.Bikes) { var bGrayedOut = b.Visible ? string.Empty : "grayout"; <div class="sortable-list-grid-item drag-handle @bGrayedOut" @onclick="() => SelectBike(b)"> <img class="zoom-on-hover cursor-grab fit-image grayout" src="/lib/bootstrap-icons-1.5.0/list.svg" data-toggle="tooltip" data-placement="top" title="Uhvati i premesti" /> <BikeThumbnailPage Bike="b" /> </div> } </div> </li> } @if (showButtonAddManufacturer) { <li> <div class="divManufacturerHeader"> <img class="zoom-on-hover cursor-pointer fit-image grayout" src="/lib/bootstrap-icons-1.5.0/plus-circle.svg" data-toggle="tooltip" data-placement="top" title="Dodaj novog proizvodjača" @onclick="AddManufacturer" /> </div> </li> } </ul> @code { protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { var objRef = DotNetObjectReference.Create(this); await jsRuntime.InvokeVoidAsync("EnableManufacturerSorting", objRef); } await base.OnAfterRenderAsync(firstRender); } [JSInvokable] public void OnManufacturerDragStart() { // 隐藏所有厂商的摩托列表 foreach (var m in manufacturers) showManufacturerBikes[m] = false; showButtonAddManufacturer = false; StateHasChanged(); } [JSInvokable] public async void OnManufacturerDragEnd(int[] ids) { showButtonAddManufacturer = true; // 为厂商设置排序编号 for (int orderNo = 0; orderNo < ids.Length; orderNo++) manufacturers .First(m => m.Id == ids[orderNo]) .OrderNumber = orderNo; _manufacturerService.UpdateAll(manufacturers); //ReloadManufacturers(); //StateHasChanged(); //var objRef = DotNetObjectReference.Create(this); //await jsRuntime.InvokeVoidAsync("EnableManufacturerSorting", objRef); } }
对应JS初始化代码
function EnableManufacturerSorting(dotNetHelper) { $('.ulManufacturers').each(function() { new Sortable(this, { group: 'manufacturers', handle: '.drag-handle', direction: 'vertical', animation: 150, onStart: function () { console.log("onStart"); dotNetHelper.invokeMethodAsync('OnManufacturerDragStart'); }, onEnd: function () { console.log("onEnd"); var orderList = []; $(this.el).children().each(function (index, element) { orderList.push($(element).data('id')); }); var params = JSON.stringify({ 'orderList': orderList }); dotNetHelper.invokeMethodAsync('OnManufacturerDragEnd', orderList); //dotNetHelper.dispose(); //this.destroy(); }, }); }); }
我尝试了开启/关闭JS函数和Blazor OnManufacturerDragEnd方法中所有注释的代码,所有组合都无法解决该问题。
更新1
我排查发现如果从public async void OnManufacturerDragEnd(int[] ids)中移除如下保存排序的代码,功能可以正常运行,但无法保存新的排序结果:
// 为厂商设置排序编号 for (int orderNo = 0; orderNo < ids.Length; orderNo++) manufacturers .First(m => m.Id == ids[orderNo]) .OrderNumber = orderNo; _manufacturerService.UpdateAll(manufacturers);
如果保留上述代码但移除如下状态更新代码,功能也可以正常运行,但无法实现添加按钮的显隐控制:
showButtonAddManufacturer = true; StateHasChanged();
我怀疑是sortablejs和Blazor的渲染逻辑之间存在异步时序问题,可能和底部的添加新厂商按钮有关。
更新2
我已经找到了临时解决方案,现在想知道第二次拖拽时异常的具体底层原因。
更新3
我认为这个方向是对的,jQuery和Blazor是否可以兼容运行?
底层原因解释
1. Blazor和第三方DOM操作库的核心冲突
Blazor本身通过虚拟DOM管控所有真实DOM的变更,页面上的节点生成、顺序调整、删除新增都由Blazor内部的渲染逻辑控制。而SortableJS和jQuery都是直接操作真实DOM的库,你在首次渲染后初始化SortableJS,它会直接给ul节点绑定拖拽事件,并且在拖拽过程中直接修改li的顺序,这部分变更Blazor的虚拟DOM是感知不到的,两边的DOM状态就会出现不一致。
2. 异步调用的时序问题
SortableJS的onStart回调调用OnManufacturerDragStart是异步的,拖拽动作已经开始的时候,Blazor才收到通知修改showButtonAddManufacturer状态并触发StateHasChanged重渲染。这时候Blazor会删除末尾的添加按钮li,同时调整其他节点的渲染状态,但SortableJS内部已经记录了拖拽开始时的节点顺序、位置等信息,感知不到Blazor刚刚修改了DOM,后续的拖拽位置计算就会出错。
3. 排序后重渲染导致Sortable状态失效
你在OnManufacturerDragEnd里修改了所有厂商的OrderNumber,如果你的manufacturers列表是按OrderNumber排序后渲染的,Blazor触发重渲染的时候会按照新的排序重新生成li节点,或者调整已有节点的顺序。但你只在首次渲染时初始化了一次SortableJS,它内部存储的还是旧的节点映射关系,第二次拖拽时就会用错误的节点索引计算位置,最终表现为排序异常。
4. jQuery data方法的缓存坑
你用$(element).data('id')取节点的id属性,jQuery的data()方法会把读取到的属性值缓存起来,后续就算Blazor重渲染修改了节点的data-id属性,jQuery取到的还是第一次缓存的旧值,传给后端的排序id数组错误,也会导致排序结果异常。
jQuery和Blazor不是完全不能兼容,但必须遵守一个原则:Blazor管控的DOM区域不要让第三方库直接修改,要么把第三方库操作的部分用@key标记、每次重渲染后重新初始化第三方库实例,要么第三方库修改DOM后主动同步状态给Blazor,避免两边状态不一致。
内容的提问来源于stack exchange,提问作者Monset

