列表增删不再跳帧:Motion 中进退场与位置重排的分层实现
有一类列表动画,正常速度看起来似乎没有问题,一旦录屏后逐帧回放,破绽就会全部出现。
这次遇到的场景是文章筛选面板里的标签列表。选择一个标签后,可用标签会减少;取消选择后,可用标签又会增加。理想状态很清楚:
- 被删除的标签停在原地,缩小并淡出
- 幸存标签从旧位置平滑移动到最终位置
- 新增标签已经位于最终位置,再缩放淡入
- 同一次状态变化只发生一次重排,不出现二次消失或文字拉伸
但第一版实现逐帧看起来是另一回事:删除项在第一帧就彻底消失,新增项在第一帧就完全显示,只有幸存项还在慢慢移动。DOM 中虽然短暂存在过 opacity: 0 或 opacity: 1,浏览器却没有真正把那一帧画出来。
这篇文章就从这个问题出发,整理一套更完整的 Motion 列表增删与重排模型。它不只回答“代码应该怎么写”,也会划清一个很重要的边界:普通列表什么时候用 AnimatePresence mode="popLayout" 已经足够,什么时候才值得升级为旧坐标退出快照。
在线演示: 打开“列表增删与位置重排”交互实验室。可以切换分类、增删元素、执行纯重排,并用
0.35×慢放观察进入、退出和幸存项的状态。
一、先建立正确的状态模型
一次列表更新后,元素并不只有“存在”和“不存在”两种状态。对动画而言,它们会被分成三组:
这三组元素的运动目标不同:
最容易犯的错,是让同一个 DOM 节点同时承担所有职责:既做 layout projection,又做 scale,还要负责退出占位。这样不仅状态难以推理,多个动画还会争用同一个 transform。
更稳定的心智模型是三层:
- 位置层只属于仍在新列表中的元素
- 视觉层负责新增项的透明度和缩放
- 退出快照层保存删除项最后一次可见的几何信息
这是后面所有实现选择的基础。
二、先用最轻方案:AnimatePresence + popLayout
并不是所有列表都需要自己保存旧坐标。
对于普通纵向列表、尺寸稳定的卡片组,以及没有复杂裁剪和祖先尺寸动画的场景,Motion 已经提供了很合适的组合:
mode="popLayout" 的核心作用是:退出项立即从布局流中弹出,不再阻碍幸存项到达目标位置,但它仍然可以完成自己的 exit 动画。
这套方案应当是默认起点。它有几个明显优势:
- 代码短,状态由 Motion 管理
- 退出项不会继续占位
- 幸存项可以立即进行 layout projection
- 进入、退出和重排可以同时发生
如果视觉验收已经通过,就应该停在这里。
自定义直接子组件要转发 ref
popLayout 需要拿到退出元素对应的 DOM 节点。如果 AnimatePresence 的直接子元素是自定义组件,要把 ref 转发到需要弹出布局流的真实节点:
如果 ref 停在 React 组件上,没有落到真实 DOM,popLayout 就无法可靠地计算和固定退出位置。
三、什么时候 popLayout 还不够
真实业务中的筛选标签比普通纵向列表复杂一些:
- 使用
flex-wrap或响应式 Grid,删除一项可能让后面几十项换行 - 父级同时在观察内容高度,并动画筛选面板尺寸
- 标签文字必须保持自然宽度,不能在投影期间被压缩换行
- 批量筛选会同时增加或删除很多元素
- 快速连续点击时,上一次退出尚未结束,下一批状态已经到来
- 逐帧验收要求退出项的初始可见态真的被浏览器绘制过
这时只声明 initial / animate / exit,可能出现一种很隐蔽的问题:React 提交、useLayoutEffect、Motion 控制器启动都发生在下一次浏览器绘制之前。初态在 JavaScript 和 DOM 层面存在过,却没有成为用户真正看到的一帧。
逐帧结果就会变成:
如果产品要求的是“原位退出”和“终点进入”,就需要更明确地管理 presence 生命周期。
四、升级方案:最终布局 + 旧坐标退出快照
升级方案并不是让删除项继续留在原列表里。恰恰相反,真实列表要立即提交最终结构,删除项则以视觉快照的形式存在于独立覆盖层。
这个结构保证了两件事:
- 删除项不会再影响新布局
- 幸存项只从旧位置移动到最终位置一次
记录稳定的旧 rect
每次列表完成布局后,记录每个稳定 key 对应的几何信息:
这里使用 offsetLeft / offsetTop 的前提是:item 的 offsetParent 就是 relative 列表容器。若组件结构不能保证这一点,可以改用 getBoundingClientRect(),再减去容器 rect,换算为容器内部坐标。
比较前后 key,生成退出快照
列表变化后,用稳定 id 比较前后集合:
snapshotId 不能只使用业务 id。快速连续切换时,同一个业务项可能退出、重新进入、再退出。增加批次号后,每次退出生命周期都有独立身份,不会误删另一批快照。
五、把 layout 与 scale 放到不同节点
Motion 的 layout projection 本质上会使用 transform 做视觉补偿,而 scale 同样会写入 transform。
如果把它们全部放在同一个元素上:
实现简单,但在复杂重排、文字自然宽度或快速状态切换时,容易看到:
- scale 覆盖或污染位置投影
- 文字在宽度补间中换行
- 边框、圆角和阴影被一起拉伸
- 中间态看起来像一张被压扁的截图
更稳定的结构是外层负责位置,内层负责视觉:
这也解释了为什么这里通常不需要给每个列表项添加 layoutId。
稳定 key 已经告诉 React 这是同一个元素,layout="position" 会比较它的前后位置。layoutId 更适合两个不同 DOM 位置之间的共享元素迁移,例如卡片标题移动到详情页 Header,而不是普通列表重排的必要条件。
六、DOM 中存在初态,不代表用户看见过初态
这是这次排查里最值得记住的一点。
下面的逻辑看起来已经先设置初态,再启动动画:
但两次调用可能发生在浏览器下一次 paint 之前。浏览器会直接合并结果,用户看到的第一帧已经接近目标态。
如果逐帧验收明确要求初态至少被绘制一次,可以把动画启动推迟到下一次真实绘制之后:
为什么是两次 requestAnimationFrame?
- 第一次回调发生在一次 paint 之前
- 在第一次回调里预约第二次回调
- 两次回调之间,浏览器有机会绘制已经提交的初态
- 第二次回调再启动目标动画
进入视觉层可以这样写:
双 rAF 不是所有入场动画都必须使用的仪式。只有当实际录帧证明 Motion 或同步状态更新吞掉了首个可见帧,并且这个首帧对视觉结果确实重要时,才需要增加这层时序保证。
七、退出快照不要用固定定时器清理
一种常见写法是:退出时创建副本,再用 setTimeout(180) 删除。
这很脆弱。只要动画时长、减少动态效果、页面后台降频或快速连续操作发生变化,定时器就可能早于动画结束,导致元素在中间某一帧突然消失。
更可靠的做法是等待 Motion 控制器返回的 Promise:
退出项只是视觉副本,因此需要:
aria-hidden="true"pointer-events-none- 不包含可聚焦控件
- 不重复业务事件和状态订阅
它不是第二份真实交互元素。
八、容器高度是另一类问题
列表项重排解决的是“元素怎样移动”,不自动等于“外层容器怎样变高或变矮”。
如果列表增删会改变浮层卡片高度,而且外框也要平滑过渡,应当继续分层:
不要为了让外框跟随变化,就给整个内容区到处添加 layout。祖先的布局投影会影响后代坐标系,还可能让输入框、文字、边框和渐变遮罩都参与不必要的变形。
更清晰的边界是:
- 列表项外层:
layout="position" - 新增与退出视觉:内层
opacity + scale - 容器尺寸:
ResizeObserver + animate({ height }) - 内容本身:保持正常排版,不做宽高投影
这部分的完整实现可以继续参考 《内容高度变化不再跳动:Motion 中 auto 到 auto 的平滑过渡》。
九、快速连续操作必须单独验收
慢慢点一次能够成功,不代表列表 presence 生命周期是正确的。
至少要验证这些序列:
实现上需要注意:
- 新增集合只保留当前仍然可见的 id
- 已经重新进入真实列表的退出快照要及时移除
- 每次退出使用批次 id,避免旧动画回调清理新快照
- cleanup 中取消尚未执行的 rAF
- 减少动态效果下不创建无意义的退出副本
- 空状态应等最后一个退出快照完成后再显示
最后一点很容易漏掉。如果 items.length === 0 时直接用空状态替换整个列表容器,最后一个元素的退出副本也会被一起卸载。正确做法是让快照层始终挂载,等 items.length === 0 && exitingSnapshots.length === 0 时再显示空状态。
十、逐帧验收应该看什么
只检查动画结束后的 DOM 数量远远不够。列表动效的验收对象应该是“浏览器真正绘制出来的帧”。
标签减少
- 第一个可见中间帧里,所有删除项仍在旧坐标
- 删除项从
opacity: 1 / scale: 1开始退场 - 幸存项只向最终位置移动一次
- 退出过程中没有第二批元素延迟消失
- 动画完成后退出快照数量为 0
标签增加
- 新增项第一次出现时已经在最终位置
- 新增项从
opacity: 0 / scale: 0.6开始入场 - 原有元素只执行位置投影,不重复淡入
- 动画完成后所有新增状态被清理
响应式与边界
- 窄屏换列时文字不在动画中换行
- 横向或纵向滚动后,退出快照坐标仍正确
- 删除最后一项时,空状态不会抢先出现
prefers-reduced-motion下不执行明显缩放与位移- 快速连续操作后没有残留的绝对定位副本
十一、方案选择清单
最后把决策压缩成一张表:
所谓最佳实践,不是永远使用最复杂的方案,而是先选择最轻的可靠实现,再根据真实失败证据增加控制层。
结语
列表增删动画真正难的地方,不是补间参数,而是生命周期与布局职责。
当我们把问题拆开后,它会清楚很多:
- React 负责立即提交最终列表
- Motion 的 layout projection 负责幸存项位置
- 独立视觉层负责新增项的透明度与缩放
- 退出快照负责删除项最后一段视觉生命
- ResizeObserver 只在外层尺寸确实需要补间时出现
- 逐帧录屏验证的是 paint,而不是某一刻的 DOM 声明
最值得保留的经验也许只有一句:不要让一个列表项节点同时承担布局、进入、退出和容器尺寸四种动画语义。
职责一旦分开,动效才会既自然,又经得起慢放。