You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.09.30 12:15:04