列表生命周期:入场、出场、重排
一个会动的列表要同时处理三件事:新项入场、旧项出场、顺序变化时各项重排。三者的实现差别不在动画本身,而在动画与 DOM 变更的先后:
- 入场:先插入 DOM,再从初态动到终态
- 出场:先动到初态,动完再移出 DOM
- 重排:DOM 先就位(新顺序),再用 transform 反推后归零
1 · 三种过渡
2 · 出场为何不能直接删
删除一项时若立即 removeChild,元素当帧消失,动画无从播起。做法是延后卸载:先把待删项标记为「正在离开」,对它播一段出场动画,在 onComplete 里才执行真正的 removeChild。这段时间该项仍在 DOM 中,只是不再可交互——.leaving 类除了标记状态,还负责
pointer-events: none。
警示 ·「正在离开」的项必须从「当前项数」与后续的重排计算里排除,否则会出两类错:读数在出场动画期间偏大;FLIP 的 firsts 里混进一个即将消失的元素,幸存项的位移量就算错了。本页的 liveItems() 因此一律带 :not(.leaving) 过滤——这是 AnimatePresence
这类抽象里最容易漏的一处。
删除的动画分两段:被删项自己淡出,以及其余项上移填补空缺。两段有先后——必须等被删项真正 remove() 之后才能量出幸存项的新位置,所以上移的 FLIP 挂在出场动画的 onComplete 里。
3 · 重排走 FLIP
直接对位置做动画会逐帧重排。FLIP 分四步:First 改动前记录旧 rect,Last 改完 DOM 顺序后量新 rect,Invert 用 transform 把元素反推回旧位,Play 把这个 transform 动画归零。整个过程只动 transform,不触发重排。
import { Animation, invert, invertTransform } from '@vega/anim';
// 出场: 延后卸载 —— 先播动画, onComplete 里才 removeChild
function exit(li) {
li.classList.add('leaving'); // 期间仍在 DOM, 不再可交互
new Animation({
keyframes: [1, 0], duration: 300, ease: 'easeIn',
onUpdate: (p) => {
li.style.opacity = p;
li.style.transform = `translateX(${(1 - p) * 40}px)`;
},
onComplete: () => li.remove(), // 动画结束才真正卸载
});
}
// 重排: FLIP 四步
function shuffle(items) {
const firsts = items.map(measure); // First: 旧 rect
reorderDom(items); // Last: 改 DOM 顺序
for (const el of items) {
const d = invert(firsts[i], measure(el)); // Invert: 反推量
el.style.transform = invertTransform(d, 1);
new Animation({
keyframes: [1, 0], duration: 420, ease: 'easeOut',
onUpdate: (f) => el.style.transform = invertTransform(d, f), // Play
onComplete: () => el.style.transform = '',
});
}
}
FLIP 的几何反推与共享布局过渡见高级交互;入场与出场用到的多关键帧与逐段缓动见关键帧。同一套「先量再动」的思路用在跨页面元素上就是共享元素过渡。