ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Cocos Creator拖拽排序列表:三层结构、索引算法与数据回写

Cocos Creator拖拽排序列表:三层结构、索引算法与数据回写

简介:一套面向 Cocos Creator 开发者的拖拽排序列表演示工程,适合已了解场景图、节点层级与组件使用,希望实现列表项自由拖动并实时重排的初中级开发者。工程围绕触摸或鼠标事件监听、拖拽状态记录与排序结果持久化展开,可用于商品列表、背包、任务栏等需要自定义顺序的界面原型。压缩包共16个文件,约243KB,主要由 json 配置、meta 资源描述、TypeScript 脚本以及 fire 场景文件构成,体积轻便,便于导入编辑器后按模块查阅。已有115人学习,说明该案例对同类交互需求具有一定参考价值。通过场景与脚本的对应关系,读者可以梳理拖拽开始、移动、结束三个阶段的处理逻辑,观察数组顺序如何驱动节点位置刷新,并据此提炼视图更新与性能优化的排错思路,快速复用到自己的排序列表功能中。

1. 三十个背包格子里的一次拖拽:这套 Cocos Creator 拖拽排序列表到底管什么

背包里塞了三十件装备,玩家按住其中一件往上拖,中间那几格要顺势让位,松手之后顺序还得存进存档,下次进来不能变。这就是一份 Cocos Creator 拖拽排序列表源码包最典型的落地场景:它要解决的问题很具体,包含监听触摸、把手指坐标换算成列表局部坐标、让其它格子主动让位,最后把视觉顺序和内存里的数据数组对齐。这套东西不是某一个 API,而是一套把事件、布局、占位节点、数据回写串起来的小系统。

它适合两类人:一类是做背包、技能栏、卡组编排、任务队列这种“顺序即语义”的界面,cocos-creator里现成的 ScrollView 加 Layout 组合直接拿来拖一定会翻车;另一类是把这套逻辑抽成通用组件、准备在多个面板复用的团队。包里给的不是拖拽特效,是拖拽之后“顺序怎么算、数据怎么存、边界怎么守”这三件事。

2. 拆骨架:DragLayer + 占位节点 + Layout 的三层结构为什么比直接改 siblingIndex 稳

先把最容易被低估的一点讲清楚:拖拽排序的难点从来不在“跟着手指走”,而在于“其它节点怎么让”。让位这件事,Cocos Creator 已经用 Layout 组件做了一大半,你要做的是别跟它抢活。

2.1 在 Layout 容器里直接拖 item,为什么会抖

Layout 组件的职责非常明确:它按 siblingIndex 顺序排列子节点,一旦layout.updateLayout()被调用,或者布局参数变化触发刷新,它就会把所有子节点的position重新写一遍。这意味着如果被拖拽的 item 仍然是 Layout 容器的子节点,就同时有两个“作者”在改它的坐标:你的 TOUCH_MOVE 想让它的位置跟手移动,Layout 想把它放回格子里。结果就是肉眼可见的抖动、拖到一半弹回去,或者拖得动但松手瞬间位置错乱。

更隐蔽的一层是:你在拖动过程中为了提高落点精度去调setSiblingIndex(),这个动作本身就会打乱 Layout 的子节点顺序,进而触发重排,于是所有 item 一起闪一下。有人在 TOUCH_MOVE 里每帧调updateLayout(),帧率直接被拉到三十以下,列表一长就更明显。

比较稳妥的结构是把“被拖的节点”从 Layout 容器里摘出去,容器里留一个同尺寸的占位节点参与排布。占位节点不动手,只负责“占坑”,Layout 只排它;被拖节点在一个不受 Layout 管束的层里自由移动。这样一来,两个作者各管各的,边界清晰。

2.2 三层结构的职责划分

推荐的节点树是这样,text只是示意层级,实际工程里节点名按项目习惯改就行:

BagPanel ├── ScrollView (ScrollView + view/Mask) │ └── view │ └── content (Layout: GRID / VERTICAL,挂 DragSortList) │ ├── item_0 (ItemView,预制体实例) │ ├── item_1 │ ├── ... │ └── placeholder (Sprite 半透明底,默认 active = false) └── DragLayer (空节点,与 ScrollView 同级,节点顺序在其之上)

三层各自管什么,最好一开始就定死,后面所有代码都围绕这个分工作业:

节点挂载组件职责容易出问题的地方
contentLayout + DragSortList排序容器,负责最终顺序拖着 item 不放,和 Layout 抢位置
placeholderSprite + UITransform记录“当前落点”,让其它 item 让位尺寸没同步,导致让位后错位
DragLayer无(仅 UITransform)承载被拖节点,隔离 Layout层级盖不住 ScrollView 的遮罩
itemItemView + UITransform展示单条数据尺寸随内容变化时占位没跟上

DragLayer 必须是 ScrollView 的兄弟节点而不是子节点。放在 content 里会被 Mask 裁剪,拖到边缘时 item 直接消失一半;放在 view 里则会被 ScrollView 的滚动偏移带动,出现“item 跟着列表一起滚”的诡异效果。

2.3 原点对齐这件事,能省掉一半坐标 bug

上面那套结构里,唯一需要提前确认的是坐标系。content 的锚点通常是(0.5, 1)(顶部对齐的垂直列表),DragLayer 的锚点通常是(0.5, 0.5),两者原点根本不在一个位置。这时候如果你图省事,直接拿dragging.position.y去和item.position.y比大小,得到的顺序必然是错的。

我的做法是统一走一次“世界坐标 → content 局部坐标”的换算,任何比较之前先把被拖节点的位置折算到 content 的坐标系里:

// 把任意节点的世界位置,换算成 content 的局部坐标 private toContentSpace(node: Node): Vec3 { const contentUT = this.node.getComponent(UITransform)!; return contentUT.convertToNodeSpaceAR(node.worldPosition); }

convertToNodeSpaceAR的“AR”是 anchor relative,即以目标节点的锚点为原点。换算出来的y值在垂直列表里就是“离 content 原点多远”,可以直接和子节点的position.y比。这套换算多做一次的代价是一次矩阵乘法,比你在每个分支里手动加减锚点偏移靠谱得多。

顺带提醒一个版本差异:3.x 里尺寸和换算都在UITransform上,2.x 里是node.width/height和node.convertToNodeSpaceAR(),两个大版本的 API 名字接近但宿主不同,迁移的时候这类调用要全量搜一遍。

3. 把触摸事件接上:从 TOUCH_START 到落点换算的完整 TypeScript 实现

结构定好之后,剩下的就是三段时间轴:按下时记录状态,移动时算落点,抬手时换数据。下面这套代码是 3.x 写法,可以直接抄进组件里跑。

3.1 触摸开始:三个必须记下来的值

按下这一瞬间要记的东西只有三样,但每一样漏了都会出问题:起点下标、手指相对 item 中心的偏移、以及 item 换算到 DragLayer 之后的世界坐标。偏移不记,item 会“啪”一下跳到手指正下方;世界坐标不换算,setParent 之后 item 会瞬移到屏幕角落。

import { _decorator, Component, Node, UITransform, Vec3, EventTouch, Layout, ScrollView, tween, v3 } from 'cc'; const { ccclass, property } = _decorator; @ccclass('DragSortList') export class DragSortList extends Component { @property(Node) dragLayer: Node = null!; @property(Node) placeholder: Node = null!; @property(ScrollView) scrollView: ScrollView = null!; @property(Layout) layout: Layout = null!; @property({ tooltip: '抬手回位的补间时长,秒' }) snapTime = 0.12; private dragging: Node | null = null; // 正在被拖的 item private dragIndex = -1; // 起点在 dataList 里的下标 private targetIndex = -1; // 当前落点在“抽掉被拖项后”的数组里的下标 private touchOffset = new Vec3(); // 手指与 item 中心的偏移(content 局部) /** 由 ItemView 在 TOUCH_START 时回调,把自身和下标传进来 */ public beginDrag(item: Node, index: number, e: EventTouch) { if (this.dragging) return; // 状态锁:一次只允许拖一个 this.dragging = item; this.dragIndex = index; this.targetIndex = index; const contentUT = this.node.getComponent(UITransform)!; const ui = e.getUILocation(); const local = contentUT.convertToNodeSpaceAR(v3(ui.x, ui.y, 0)); // 记录手指相对 item 中心的偏移,避免 item 一上手就跳到手指中心 this.touchOffset.set(local.x - item.position.x, local.y - item.position.y, 0); // 占位节点就位:尺寸同步、插到当前下标、显形 const size = item.getComponent(UITransform)!.contentSize; this.placeholder.getComponent(UITransform)!.setContentSize(size); this.placeholder.setSiblingIndex(item.getSiblingIndex()); this.placeholder.active = true; // 摘下 item,保持它在屏幕上的位置不变 const world = item.getComponent(UITransform)!.convertToWorldSpaceAR(Vec3.ZERO); item.setParent(this.dragLayer); item.setWorldPosition(world); item.setSiblingIndex(this.dragLayer.children.length - 1); // 关掉 ScrollView 的触摸监听,避免一边拖一边滚 this.scrollView.enabled = false; } }

几个参数值得单独说。setSiblingIndex(this.dragLayer.children.length - 1)是把被拖节点顶到 DragLayer 的最上层,防止它被同层其它节点遮住;this.scrollView.enabled = false依赖 ScrollView 在onEnable里注册触摸监听、在onDisable里注销这套生命周期,不同小版本可能有出入,升级引擎后建议拿一条长列表实测一遍。snapTime默认给 0.12 秒,是因为低于 0.08 秒人眼几乎看不出回位过程,会觉得 item 是“闪”回去的;高于 0.2 秒又会觉得拖拽有延迟。

3.2 移动阶段:落点计算与“只在变化时重排”

移动阶段有一句非常关键的判断:只有落点下标变了,才调layout.updateLayout()。这一句直接决定了列表在低端机上是流畅还是卡成幻灯片。

public moveDrag(e: EventTouch) { if (!this.dragging) return; const contentUT = this.node.getComponent(UITransform)!; const ui = e.getUILocation(); const local = contentUT.convertToNodeSpaceAR(v3(ui.x, ui.y, 0)); // 手指位置减去按下时的偏移,就是 item 中心应该在的位置 const center = v3(local.x - this.touchOffset.x, local.y - this.touchOffset.y, 0); const world = contentUT.convertToWorldSpaceAR(center); this.dragging.setWorldPosition(world); const next = this.calcTargetIndex(); if (next !== this.targetIndex) { this.targetIndex = next; this.placeholder.setSiblingIndex(next); this.layout.updateLayout(); // 整段拖拽里,这里最多被调用 itemCount 次 } this.tryAutoScroll(local.y); } /** 参与排布的子节点(排除占位节点),顺序即“抽掉被拖项之后的数据顺序” */ private getItems(): Node[] { return this.node.children.filter(n => n !== this.placeholder && n.activeInHierarchy); }

getItems()这个过滤器是整个索引换算的基础。被拖节点此刻已经不在 content 里了,所以取出来的数组天然等于“dataList 去掉dragIndex那一项”。后面算出来的落点下标,正好可以直接用在“先 splice 删除、再 splice 插入”的逻辑里,不需要额外补偿。很多人在这里手动-1、+1地凑,越凑越乱,根因就是没想清楚数组到底代表哪一段。

calcTargetIndex()的具体算法按列表形态分三种,下一章展开。tryAutoScroll负责拖到边缘时自动滚,放到 4.3 讲。

3.3 抬手与中断:补间、回挂与数据换位

抬手这一段最容易漏的是 TOUCH_CANCEL。手指划出屏幕边缘、系统弹窗抢焦点、来电打断,都会触发 CANCEL 而不是 END,只接 END 的结果就是 item 永远悬在 DragLayer 上,玩家再点一次界面直接崩。

public endDrag(e: EventTouch, cancelled = false) { if (!this.dragging) return; const item = this.dragging; const from = this.dragIndex; const to = cancelled ? from : this.targetIndex; // 1) 数据层先换位,保证逻辑顺序和视觉顺序同源 if (to !== from) { const [data] = this.dataList.splice(from, 1); this.dataList.splice(to, 0, data); } // 2) 视觉层:补间回占位位置,再挂回 content const target = this.placeholder.worldPosition.clone(); tween(item) .to(this.snapTime, { worldPosition: target }, { easing: 'quadOut' }) .call(() => { item.setParent(this.node); item.setSiblingIndex(this.placeholder.getSiblingIndex()); this.placeholder.active = false; this.layout.updateLayout(); this.scrollView.enabled = true; this.dragging = null; this.dragIndex = -1; this.targetIndex = -1; this.onOrderChanged(this.dataList.map(d => d.id)); }) .start(); }

cancelled这个分支值得单独强调:中断时to = from,也就是数据不动,item 原路返回。有些实现图省事,中断时直接销毁重建整个列表,功能上是通的,但玩家正在看的那一屏会整个刷新一次,体验断层很明显。

tween(item).to(..., { worldPosition: target })这行的写法依赖 Node 的worldPosition有公开 setter,3.x 是支持的。如果你担心不同小版本对 Vec3 属性的插值行为不一致,退一步改成补间一个临时 Vec3、在onUpdate里setWorldPosition也行,多写五行的代价换确定性。

最后onOrderChanged()里传出去的是 id 数组,不是 node 数组。node 顺序只是渲染层的产物,下一帧某个 item 被回收复用,顺序就变了,这个区别在第五章还会再提一次。

4. 列表形态适配:垂直列表、九宫格与不等高条目的三套索引算法

同一个拖拽组件,换个Layout.Type就要换一套落点算法。包里给了三套,选哪套只看你的列表长什么样,不看别的。

4.1 垂直列表:按中心线计数法

等高的垂直列表是最简单的情况,判断标准可以简化成一句话:有多少个 item 的中心在拖拽中心之上,落点就是几。

private calcTargetIndex(): number { const items = this.getItems(); const dragLocal = this.toContentSpace(this.dragging!); let idx = 0; for (let i = 0; i < items.length; i++) { // 垂直向下增长的列表,y 越大越靠上;Layout 默认从顶往下排 if (items[i].position.y > dragLocal.y) idx++; } return idx; }

这个方法的时间复杂度是 O(n),n 是可见 item 数量,四十个以内完全无感。它的隐含假设是“所有 item 等高且等间距”,一旦某个格子因为文案换行撑高了一点,中心线比较就会出现一两格的偏差。这时候要换成上一章那套解法,用“最近的邻居”来判断,代价是多一次遍历。

items[i].position.y > dragLocal.y里的大于号方向跟你的 Layout 起始方向绑定。Layout.Type.VERTICAL默认是从上往下排,第一个子节点在最上面,所以 y 递减;如果你在 content 上加了Widget或者手动翻转过,符号要跟着翻。这是最容易复制粘贴出错的一行。

4.2 九宫格:最近中心点加边界钳制

Layout.Type.GRID下,纵坐标比较就失效了,得改算平面距离。思路是遍历所有格子中心,取距离最小的那个作为落点:

private calcGridIndex(): number { const items = this.getItems(); const dragLocal = this.toContentSpace(this.dragging!); let best = 0; let bestDis = Number.MAX_VALUE; for (let i = 0; i < items.length; i++) { const d = Vec3.squaredDistance(items[i].position, dragLocal); if (d < bestDis) { bestDis = d; best = i; } } // 边界钳制:拖出列表末尾时,最近点仍可能落在倒数第二格 const last = items.length - 1; const cellW = items.length > 1 ? Math.abs(items[1].position.x - items[0].position.x) : 0; if (cellW > 0 && dragLocal.x > items[last].position.x + cellW / 2) { best = last; } return best; }

用squaredDistance而不是distance,是为了省掉每次比较里的开方;这个优化在两三个格子上无所谓,但拖拽时每帧调一次,四十格就是每帧四十次开方,能省则省。

边界钳制这段是踩过坑才加的。九宫格最后一行的格子数往往不满,拖到最后一行右侧的空白区域时,距离最近的是倒数第二格,玩家松手会发现东西插到了错误的位置。用“超过最后一格中心线半个格子宽就算越界”这条规则能把这种情况兜住。cellW从第 0、1 两格的 x 差值取,前提是前三格在同一行;如果你的列数小于 3,这条就失效了,得改成从 Layout 的paddingLeft和cellSize直接算。

4.3 不等高条目与长列表:什么时候该放弃 Layout

两种情况下我会建议直接放弃 Layout,自己算位置:

第一种是条目高度由内容决定(聊天记录、评论区、任务描述)。Layout 虽然有resizeMode = CONTAINER能自适应,但每次内容变化都触发全量重排,拖动过程中的落点计算还得挨个读UITransform.height累加,代码量和运行时开销都不划算。这时候用“前缀和数组加二分查找”定位落点更直接:维护一个offsets[i]表示第 i 项顶部的 y 坐标,拖动时对dragLocal.y做一次二分,落在哪个区间就是哪个下标。

第二种是可见条目超过六十个。哪怕有 Mask 裁剪,Layout 依然会排布所有子节点,updateLayout()的耗时会随着总数线性增长。常见的做法是做虚拟列表:只实例化可视区加缓冲区的节点,靠content的高度撑出滚动条,滚动时复用节点并刷新数据。虚拟列表配拖拽排序,麻烦点在于被拖的 item 可能在滚动过程中被回收,所以拖动期间要给它打一个“禁止回收”的标记。

至于拖到边缘自动滚,我的实现是这样的:

private tryAutoScroll(localY: number) { const view = this.scrollView.content!.parent!; const halfH = view.getComponent(UITransform)!.height / 2; const edge = Math.min(80, halfH * 0.25); // 触发区最多 80px,且不超过视口四分之一 const speed = 12; if (localY > halfH - edge) { const off = this.scrollView.getScrollOffset(); this.scrollView.scrollToOffset(new Vec2(off.x, off.y + speed), 0); } else if (localY < -halfH + edge) { const off = this.scrollView.getScrollOffset(); this.scrollView.scrollToOffset(new Vec2(off.x, off.y - speed), 0); } }

edge取halfH * 0.25是为了在矮屏上不让触发区吃满整个视口,否则手指一进列表就开始滚,根本没法选中。speed是个经验值,每帧 12 像素在 60 帧下差不多 720px/s,长列表翻一屏大概一秒出头。方向对不对跟 ScrollView 的vertical设置和 content 锚点有关,第一次接的时候一定实机试一遍符号,我在这上面浪费过半小时。

5. 避坑与排查:拖拽排序最容易翻的五个车

这五条是按“复现频率”排的,每一条都在真机上出现过,不是理论推演。

5.1 手指划出列表再松开,item 悬在半空不回来

现象:拖到屏幕边缘外面松手,item 停在 DragLayer 上不动,占位节点还亮着,再点别的格子直接叠罗汉。

原因:只监听了TOUCH_END,没监听TOUCH_CANCEL。触摸被系统或父级节点拦截时,引擎派发的是 CANCEL 而不是 END,endDrag根本没被调用。还有一种是setParent到 DragLayer 之后,item 的触摸回调注册在旧父节点的冒泡链上,事件收不到了。

解决:在 item 上同时注册TOUCH_END和TOUCH_CANCEL,两个都走endDrag,CANCEL 传true。更稳的做法是把触摸监听挂在 content 上,用e.target判断是哪个 item,这样 setParent 不影响事件链。另外在onDisable里加一道兜底:如果this.dragging还在,直接复位,防止界面被关闭时残留状态。

5.2 轻轻一划整个列表跟着滚,item 纹丝不动

现象:想拖某个格子,结果触发的是 ScrollView 的滑动,列表滚了,格子没动。

原因:ScrollView 的触摸监听注册在自己的节点上,item 的 TOUCH_MOVE 冒泡上去之后,ScrollView 认为这是一次滚动。引擎默认不做“移动阈值”判断,手指一动它就接管。

解决:两条路,选一条。轻量做法是beginDrag里this.scrollView.enabled = false,抬手时恢复,前面代码里用的就是这个;它的副作用是拖动期间不能用 ScrollView 的原生惯性,但自动滚动可以照常用scrollToOffset。另一条路是给 item 加移动阈值:TOUCH_START 时只记录坐标,TOUCH_MOVE 里位移超过 6 像素才真正进入拖拽状态,没超过就把事件放行给 ScrollView。后者的体验更接近原生,代价是代码多一个状态位。

5.3 当前页面顺序是对的,退出再进顺序又复原了

现象:拖完看着没问题,切个页签回来、或者重新打开背包,顺序回到初始状态。

原因:只改了节点的siblingIndex,没改数据数组。节点顺序是渲染结果,一旦列表重建、对象池复用、或者走了一遍排序逻辑,视觉顺序就会被数据顺序覆盖回去。

解决:把dataList当成唯一事实来源,先改数组,再让节点跟着数组走。代码里的顺序是“先 splice 数据,再补间回位,最后按占位下标设 siblingIndex”,这个顺序不能倒。补一个自检函数在拖拽结束时跑一遍,比较dataList[i].id和node.children[i]上挂的 id,对不上就在控制台报红字,问题当场暴露。

5.4 手快连点两个 item,两个都飞起来了

现象:快速在 A 上按下、还没抬起又点到 B,DragLayer 上叠了两个 item,占位节点也出现两个。

原因:缺状态锁。beginDrag里没有if (this.dragging) return,或者补间还没结束就把dragging置空了,第二次按下时状态判断失效。

解决:开头加锁,末尾解锁,中间补间结束前不解锁。注意解锁要放在tween的call里,不能放在tween().start()后面——那行是同步执行的,等于没锁。如果产品要求“拖着 A 的时候点 B 要能切换”,那就得先走一遍endDrag再beginDrag,别指望两个同时存在。

5.5 拖到末尾,所有格子集体错位一格

现象:往下拖得越远,错位越明显,最后一格怎么都拖不到。

原因:把siblingIndex直接当数据下标用了。content 里除了 item 还可能有别的东西:顶部留白节点、空态提示、加载更多的按钮、占位节点本身。这些也在children里占着 index,一旦拿它去索引dataList,从第一个非 item 节点往后全部偏移。

解决:所有下标运算都走getItems()过滤之后的数组,永远不要直接读node.getSiblingIndex()当数据下标。占位节点设置 siblingIndex 的时候倒是可以用真实下标,因为它确实要插在 children 的对应位置上。这两个坐标系混用是这类组件最隐蔽的 bug,写的时候在注释里标清楚“这里是 children 下标”还是“这里是数据下标”,省的是自己的时间。

6. 排序落地与验证:把 node 顺序和 dataList 顺序绑死的一个土办法

拖拽做完只是半成品,真正决定这套东西能不能上线的,是“顺序有没有被存下来”和“下次加载能不能还原”。我一般的收尾方式很简单:拖拽结束之后立刻做一次断言,把不一致当场喊出来,别等策划来提 bug。

/** 拖拽结束后立即自检:node 顺序、dataList 顺序、存档 id 三者必须一致 */ private assertOrder(): boolean { const nodes = this.getItems(); let ok = nodes.length === this.dataList.length; for (let i = 0; ok && i < nodes.length; i++) { const view = nodes[i].getComponent(ItemView)!; if (view.dataId !== this.dataList[i].id) { console.error(`[DragSortList] 第 ${i} 项错位:node=${view.dataId} data=${this.dataList[i].id}`); ok = false; } } // 存档侧:只存 id,不存 node 名,重建列表时按下标回填 const snapshot = JSON.stringify(this.dataList.map(d => d.id)); if (ok && snapshot !== this.lastSnapshot) { this.lastSnapshot = snapshot; console.log('[DragSortList] 顺序已变更', snapshot); // 这里对接你的存档 / 协议层 } return ok; }

这段代码的价值不在断言本身,而在于它把三套顺序强制对齐:渲染层的 children、内存里的dataList、以及要落盘或发协议的 id 数组。三者任意一个出现偏差,console.error会直接指出是第几项、node 侧是什么、data 侧是什么,排查时间从半小时压缩到十秒。

还有一个容易被忽略的点:lastSnapshot做的是去重。玩家拖完又拖回原位,顺序没变,这时候不该再发一次协议或者写一次存档。很多项目里背包顺序异常,最后查出来是频繁写入导致的读写竞态,不是排序算错了。

顺便给个还原的入口,从存档读回来的时候不要重建节点顺序,而是按 id 给已有节点排序:

/** 按存档里的 id 顺序重排节点,不重建节点 */ public applyOrder(ids: string[]) { const map = new Map<string, Node>(); this.getItems().forEach(n => map.set(n.getComponent(ItemView)!.dataId, n)); ids.forEach((id, i) => { const n = map.get(id); if (n) n.setSiblingIndex(i); // 按 id 顺序重排 siblingIndex }); this.layout.updateLayout(); }

这套写法依赖 id 唯一,重复 id 会静默丢掉后面的项,所以数据层要做唯一性约束,别指望组件兜。还有一点,setSiblingIndex在循环里调用的时间复杂度不是 O(1),项数上百时会有明显卡顿,那种规模建议直接按 id 顺序实例化节点,别在原有节点上折腾。

从那以后我每次接拖拽排序,都会先把assertOrder和applyOrder这两个函数写出来再写拖拽逻辑——先把“怎么验证”想明白,再动手写“怎么拖”,返工率能降一大截。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进