别写拖拽行为,只声明"能拖到哪":UIST 最佳论文的拖拽方案我实测了

拖拽排序是那种"看起来一小时搞定,实际写一天"的活,而且写完之后你不敢动它。一个最普通的列表排序,命令式的写法大概是这样:pointerdown 记起点,pointermove 里算偏移、更新 transform、判断当前悬停在哪个元素上、决定要不要交换位置,pointerup 收尾。每一步单拎出来都不复杂,但坑全在细节里。我踩过印象最深的有这么几个:

HTML5 原生的 drag events,dragover 里不 preventDefault 整个拖拽直接失效,这个报错还不明显,页面就是"没反应",新手能查半天。浏览器自带的 ghost image(拖起来那个半透明截图)改不了样式,只能 dragstart 时自己造一个隐藏元素再 setDragImage,坐标偏移还得手调。列表长了还得处理自动滚动,滚轮容器和 overflow 容器的逻辑还不一样。心想这回总该齐了?拿到手机上一试,drag events 压根不触发,得换 pointer events 重写------而两套事件模型又不完全对应,等于核心逻辑再验一遍。

这些坑的单个解法网上都有,但把它们捏在一起,就是三四百行 if-else,视觉动画和状态变更搅在同一堆代码里。改一个吸边动画,可能碰坏交换逻辑------我今年年初就在一个看板项目里干过这事,改完回归测试跑了整整一下午。

然后这几天 HN 首页有个帖子挺热,截至我写稿时 400 多分:Declarative Dragging,华盛顿大学 Joshua Horowitz、Andrew Blinn、Jeffrey Heer 的工作,拿了 UIST 2026 的最佳论文奖。配套还开源了一个库,叫 Dragology。

论文的核心论点一句话就能说完:别去编程拖拽的视觉行为,只声明"拖动元素能到达哪些未来状态",剩下的交给引擎。

"可能世界"是什么意思

拿列表排序举例。命令式思维是:我在拖,指针移动了 dx dy,所以卡片应该平移 dx dy,然后判断插入位置,触发交换动画。 声明式思维是:我知道拖动这张卡片只有 n 种结局------插到第 0 个位置、第 1 个位置......第 n-1 个位置。这 n 个"未来"就是 n 个候选状态。我不写任何"拖拽中"的动画逻辑,只把这 n 个候选状态交给引擎。 引擎在拖拽的每一帧做什么?把所有候选状态全部渲染出来,提取每个状态下被拖元素会在的位置,得到 n 个点。指针在哪儿,就用 Delaunay 三角剖分加自然邻域插值,把这 n 个点对应的渲染结果按距离加权混合。说人话:离哪个候选位置近,画面就更像那个状态,两个候选之间就平滑过渡。 "拖拽中的视觉反馈"到这一步不再是一段手写的动画代码,而是"候选状态空间上的插值"。拖拽吸附、拖拽预览、组合行为(比如"既能横向排序也能扔进回收站")都变成对候选状态的组合运算。论文里管这叫 model-driven、composable dragging,我想把它理解成:把拖拽从一个事件处理问题,重构成了一个状态插值问题。 这套思路落到库上,API 是 React 组件式的。你写一个 Draggable 组件,渲染函数收到 state 和一个拖拽声明器 d,用 d.between(...) 列出候选状态:

javascript 复制代码
function CardDraggable({ state, setState, draggedId, ...rest }) {
  const candidates = state.order.map((_, i) => ({
    ...state,
    order: moveTo(state.order, draggedId, i),
  }));
  return (
    <DraggableRenderer {...rest} state={state}
      spec={(d) => d.between(...candidates)}>
      {/* 渲染每张卡片 */}
    </DraggableRenderer>
  );
}

(骨架示意,moveTo 是普通的数组搬移函数;完整可跑的 demo 在官方 demos 页面,从轨道滑块、拖拽复制到树形重排、二维列表,每个 demo 都附了源码链接。)

插值算法本身也给了选择:默认自然邻域插值(natural-neighbor),可以退到更便宜的 Delaunay 重心插值------官方树形重排的 demo 里这两个选项都开放着,能明显感觉到作者对这笔开销是有数的。 除了 between,还有 closest(多选一取最近)、vary(把状态里的某个数值作为自由度,用约束求解------约束不满足就拉回,比如"卡片不能越过边界""顺序必须保持"),以及一个从 SICP 里借来的 amb 算子做非确定性组合。vary 我觉得是全套方案里最实用的:你用 param 指定状态里某个数值路径作为自由度,再用 lessThan、inOrder 这些现成的帮助函数声明约束------"卡片不能越过边界""顺序必须保持"从 if-else 变成了一行声明,求解交给引擎。拖拽复制(按住 Alt 拖出一个副本)这种操作,在 demo 里也是用 vary 加状态切换拼出来的,没有一行拖拽事件代码。

每帧把所有可能渲染一遍,代价多大?

看到这你可能和我第一反应一样:每帧渲染 n 个完整状态,这不是拿性能换表达力吗?HN 评论区吵得最凶的也是这个,"Speculative rendering sounds awfully expensive"。 我把库的源码翻了一遍,确认了渲染路径:拖拽的每一帧,between 确实会对每个候选分支调一次渲染,拿到完整的 React 元素树,然后遍历树提取被拖元素的包围盒。顺手翻到一个细节:包围盒不是调 getBBox 算的,而是从 JSX props(rect 的 x/width、circle 的 cx/r 这些)直接推出来------纯数据运算,这也意味着整个渲染路径理论上不依赖浏览器 DOM。

那我就可以在 node 里模拟这条路径做实验。场景:n 张卡片的列表拖拽排序,候选状态 n 个(插到任意位置),每个状态一棵 n 卡片的 SVG 树。计量两种,一种贴近库内部真实路径------构造 React 元素树后深度遍历(对应库里的包围盒提取,都是从 props 推),一种用 renderToStaticMarkup 做上界参考。核心代码长这样:

scss 复制代码
const trees = states.map((s) => makeTree(n, s)); // n 个候选状态各一棵树
const t0 = performance.now();
for (const t of trees) countElements(t);          // 深度遍历,模拟库内位置提取
const t1 = performance.now();
for (const t of trees) renderToStaticMarkup(t);   // 上界:连字符串化一起算
const t2 = performance.now();

跑出来是这样:

scss 复制代码
n(卡片) | 每帧构造+遍历 | 每帧renderToString | 每帧创建的React元素
5       | 0.02ms        | 0.66ms             | 80
20      | 0.07ms        | 3.88ms             | 1220
80      | 3.31ms        | 55.7ms             | 19280
160     | 10.04ms       | 198.0ms            | 76960

(node v22.23.2,各跑 7 轮取中位数;第一列是等价路径模拟,仅供量级参考)

这组数字说明成本跟着"每帧元素总量"走:候选状态数乘以每棵树的元素数,n² 量级。n=20 的看板列表很常见,0.07 毫秒,随便造;n=80 时每帧近 2 万元素,3.3 毫秒,占掉 60fps 帧预算(16.7 毫秒)的五分之一,还没算 React 的 diff 和 Delaunay 三角剖分;n=160 直接奔 10 毫秒去,大半个预算没了。

所以 HN 上"这玩意能撑多少节点"的质疑,在源码和数据的双重确认下成立------但只在大场景成立。顺带说一句,作者显然知道这笔账:库内部对"候选状态全是静态的"这种情形做了缓存,候选树只渲染一次,之后每帧只做插值。列表排序恰好就落在缓存命中的范围里,因为排序的候选状态都是"静态未来"。真正每帧全量重渲染的,是那些候选状态本身依赖指针位置的复杂场景。

先泼盆冷水

想直接用到自己项目里的,先停一下。 最硬的一道坎:Dragology 现在只支持 SVG。包的描述就写着 Declarative dragging for SVG on the web。你在 HN 评论区能看到作者亲口回复,HTML 版本"有个基于 FLIP 的基本实现,用什么形式发布还不确定"------也就是说你手头那个 DOM 列表,现在用不上。SVG 里的拖拽和 DOM 里的拖拽本来就是两套苦活(坐标系统、事件目标都不一样),官方把范围圈死在 SVG,与其说是限制,不如说是他们现阶段只把最可控的一块做扎实了。

文档也是同样的处境:README 原话是"虽然我们还没准备文档,但有其他资源也许够用",然后推荐了 docs/AGENTS.md------一份由 LLM 写给 LLM 看的文档,作者还自嘲了这是 "slop"(AI 生成的垃圾内容)。我读了一遍,API 逻辑讲得算清楚,但你得自己适应这种阅读体验。再加上 peer dependency 锁死 React 18/19,Vue 或原生 JS 的项目就不用看了。

所以我的工程判断是:如果你的界面本来就是 SVG------白板编辑器、图表工具、流程图编辑器这一类------这个库现在就值得拿一个非核心交互试试水,vary 的约束式声明对这类场景是实打实的收益。但普通的 DOM 列表拖拽,现阶段老老实实用 dnd-kit 这类成熟方案------dnd-kit 把碰撞检测做成预计算的矩形集合,其实也在往"少做事"的方向靠,只是没走这么极端。论文的思路可以先记着,等 HTML 版落地再看。

就算不打算用这个库,"候选状态 + 插值"这个思路也可以偷:下次再写 pointermove 里那坨 if-else,先想想我的拖拽有几种"未来",能不能先列出来再让代码自己选。至少在我这儿,看板那个交换逻辑和吸边动画搅在一起的锅,下次不会再一锅端了。

相关推荐
再吃一根胡萝卜35 分钟前
DSH 插件的 Host 与 Client:为什么「只加载了 Host」,以及要不要自己写 Client 加载器
前端
parade岁月1 小时前
AI 写的前端代码为什么总像 5 年前的?Chrome 官方出手了
前端
tryCbest1 小时前
Vue系列——第 15 篇:内置组件全家桶——Transition、Teleport、Suspense、KeepAlive
前端·vue.js
易师傅2 小时前
技术面试已死?AI 时代,真正拉开程序员差距的是什么?
前端·后端·程序员
jay神2 小时前
基于深度学习的交通流量预测系统(Web 版)
前端·人工智能·深度学习·yolo·计算机毕业设计
AiMan2 小时前
别再把秘塔会话逐个点开:这可把秘塔多个会话一次导出?AI导出鸭
前端·后端·面试
AiMan2 小时前
你好,请问可以一键导出Dola多个对话框内容吗?一键导出一整年哦
前端
Moment3 小时前
写给迷茫的应届生,怎样在 AI 时代争取更多机会
前端·后端·面试
fengci.3 小时前
CTF2MISC中等部分
开发语言·前端·学习
涛涛ing3 小时前
那个PR太干净了,干净到我差点直接点了合并
前端