Svelte animate: 指令详解:keyed each 列表重排动画的 FLIP 原理与自定义动画函数
本文基于 Svelte 官方模板语法文档 documentation/docs/03-template-syntax/16-animate.md 展开,系统讲解 animate: 指令的触发条件、内置 flip 动画的参数与默认值、自定义动画函数(custom animation functions)的完整签名与 css/tick 回调语义,并结合 Svelte 编译器与运行时源码,剖析指令从模板编译到 Web Animations API 播放的完整调用链,帮助读者写出既正确又高性能的列表重排动画。
动画的触发条件:只在 keyed each 重排时运行
animate: 指令的行为边界与普通 transition 完全不同。文档给出的核心规则有三条:
- 动画只在 keyed each block 的内容被重新排序(re-ordered)时触发;
- 元素被添加或删除时不会运行动画,只有当某个已存在的数据项在 each block 中的索引发生变化时才会触发;
animate:指令必须写在 keyed each block 的直接子元素(immediate child)上。
最小可用示例:
<!-- When `list` is reordered the animation will run -->
{#each list as item, index (item)}
<li animate:flip>{item}</li>
{/each}
注意 (item) 是 key 表达式——没有 key 的 each block 是“非 keyed”的,animate: 对其无效。
从源码结构可以印证这三条规则。在 EachBlock 客户端转换 中,编译器在编译期就能确定一个 each block 是否带动画:只有当 node.key 存在、且 body 中的某个直接子节点是 RegularElement 或 SvelteElement 并带有 AnimateDirective 属性时,才会打上 EACH_IS_ANIMATED 标志。注释里明确写道:“Since animate: can only appear on elements that are the sole child of a keyed each block, we can determine at compile time whether the each block is animated or not (in which case it should measure animated elements before and after reconciliation)”。也就是说,编译器会把“重排前测量 / 重排后测量”织入每个带动画的 keyed each block 的调和(reconciliation)流程中。
在 分析阶段 还有一个容易忽略的约束:animate: 的参数表达式不允许包含 await,否则会触发 illegal_await_expression 编译错误。
内置动画函数 flip 及其参数
Svelte 的动画可以用内置动画函数或自定义函数。svelte/animate 模块当前只提供 flip 这一个内置函数,实现位于 animate/index.js。
flip 这个名字来自经典的 [First, Last, Invert, Play] 动画技术:先记录元素“第一(First)”位置,再让布局到达“最后(Last)”位置,随后用“反向(Invert)”的 transform 把元素视觉上拉回起点,最后“播放(Play)”动画。
它的参数类型定义在 animate/public.d.ts:
export interface FlipParams {
delay?: number;
duration?: number | ((len: number) => number);
easing?: (t: number) => number;
}
结合 flip 实现 中的默认值解构:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
delay |
number |
0 |
动画开始前的延迟(毫秒) |
duration |
number | (len) => number |
(d) => Math.sqrt(d) * 120 |
可以是固定毫秒数,也可以是一个接收元素位移距离 d 的函数,按距离平方根动态计算时长 |
easing |
(t) => number |
cubicOut |
缓动函数,通常从 svelte/easing 导入 |
flip 的实现细节值得展开看。它并不只是简单地做 translate:
var { delay = 0, duration = (d) => Math.sqrt(d) * 120, easing = cubicOut } = params;
var style = getComputedStyle(node);
// find the transform origin, expressed as a pair of values between 0 and 1
var [ox, oy] = style.transformOrigin.split(' ').map(parseFloat);
ox /= node.clientWidth;
oy /= node.clientHeight;
// calculate effect of parent transforms and zoom
var zoom = get_zoom(node);
var sx = node.clientWidth / to.width / zoom;
var sy = node.clientHeight / to.height / zoom;
可以看到它做了三件容易被忽视的事:
- 考虑
transform-origin:分别求出起点与终点处 transform origin 的绝对坐标fx/fy、tx/ty,再换算出初始位移dx/dy,这样即使元素设置了非中心的变换原点,动画也不会错位; - 考虑父级 CSS zoom:
get_zoom()会沿着父元素链累乘zoom计算值(或读取currentCSSZoom),保证在缩放容器内位移换算正确; - 处理尺寸变化:除位移外还计算相对缩放
dsx/dsy(from.width / to.width),最终的css回调同时输出translate和scale:
css: (t, u) => {
var x = u * dx;
var y = u * dy;
var sx = t + u * dsx;
var sy = t + u * dsy;
return `transform: ${transform} translate(${x}px, ${y}px) scale(${sx}, ${sy});`;
}
如果元素原本带有 transform(非 none),它会保留在输出字符串的开头,避免动画期间覆盖已有变换。
动画参数:{{...}} 是对象字面量而非特殊语法
与 actions 和 transitions 一样,动画可以携带参数。文档特别提醒:双花括号 {{curlies}} 并不是 Svelte 的特殊语法,它只是表达式标签内部的一个对象字面量:
{#each list as item, index (item)}
<li animate:flip={{ delay: 500 }}>{item}</li>
{/each}
编译层面,客户端 AnimateDirective 转换 会把指令展开为一个 $.animation 调用:
$.animation(node, () => flip, () => ({ delay: 500 }));
两个细节:
- 指令名和参数表达式都被包成 thunk(
() => ...),延迟到运行时求值; - 该语句被推入
after_update,源码注释解释了原因:“in after_update to ensure it always happens afterbind:this”,即保证bind:this先拿到元素引用,动画管理器再注册到该元素上; - 如果参数表达式是异步的(依赖 store 等),还会额外包一层
$.run_after_blockers等待阻塞项完成。
自定义动画函数
函数签名
自定义动画函数接收三个参数:目标元素 node、一个包含 from 与 to 的几何状态对象、以及你在模板里传入的任意 params。文档给出的签名(结合 AnimationConfig 的 TypeScript 定义):
/**
* @param {HTMLElement} node
* @param {{ from: DOMRect; to: DOMRect }} states
* @param {any} params
*/
function whizz(node, { from, to }, params) { /* ... */ }
其中 animation 对象的两个关键属性:
from:元素在起始位置的DOMRect(重排前测量);to:列表重排并更新 DOM 之后,元素最终位置的DOMRect。
运行时如何填充这两个值,可以在 transitions.js 中的 animation() 函数 中看到:每个带 animate: 的元素会在其 effect 的 nodes.a 上挂一个“动画管理器”,measure() 在重排前记录 from = element.getBoundingClientRect(),apply() 在重排后记录 to,并只有当左右上下四条边任一发生变化时才真正调用你的动画函数:
if (
from.left !== to.left ||
from.right !== to.right ||
from.top !== to.top ||
from.bottom !== to.bottom
) {
const options = get_fn()(this.element, { from, to }, get_params?.());
animation = animate(this.element, options, undefined, 1, () => {}, () => { ... });
}
返回对象与 css 回调
动画函数应返回一个配置对象,字段与 AnimationConfig 一致:
export interface AnimationConfig {
delay?: number;
duration?: number;
easing?: (t: number) => number;
css?: (t: number, u: number) => string;
tick?: (t: number, u: number) => void;
}
如果返回对象带有 css 方法,Svelte 会为元素创建一个Web Animation(即 Element.animate())来播放动画。css 回调的语义:
t是从0走到1的值,已经应用过easing函数;u恒等于1 - t;- 该函数会在动画开始前被反复调用,用不同的
t/u取样,生成关键帧。运行时通过 css_to_keyframe 把返回的 CSS 字符串(如transform: translate(12px, 0px);)解析为Element.animate()接受的关键帧对象,并按规范做驼峰化(cssFloat、cssOffset,--自定义属性保持原名)。
文档给出的完整 css 示例:
<!--- file: App.svelte --->
<script>
import { cubicOut } from 'svelte/easing';
/**
* @param {HTMLElement} node
* @param {{ from: DOMRect; to: DOMRect }} states
* @param {any} params
*/
function whizz(node, { from, to }, params) {
const dx = from.left - to.left;
const dy = from.top - to.top;
const d = Math.sqrt(dx * dx + dy * dy);
return {
delay: 0,
duration: Math.sqrt(d) * 120,
easing: cubicOut,
css: (t, u) => `transform: translate(${u * dx}px, ${u * dy}px) rotate(${t * 360}deg);`
};
}
</script>
{#each list as item, index (item)}
<div animate:whizz>{item}</div>
{/each}
这个例子里的 duration: Math.sqrt(d) * 120 正是内置 flip 的默认公式,rotate(${t * 360}deg) 则展示了用 t 驱动旋转角度的典型写法。
tick 回调与性能取舍
自定义动画函数也可以返回 tick 函数,它在动画播放过程中被逐帧调用,参数同样是 t 和 u。文档中的 tick 示例:
<!--- file: App.svelte --->
<script>
import { cubicOut } from 'svelte/easing';
/**
* @param {HTMLElement} node
* @param {{ from: DOMRect; to: DOMRect }} states
* @param {any} params
*/
function whizz(node, { from, to }, params) {
const dx = from.left - to.left;
const dy = from.top - to.top;
const d = Math.sqrt(dx * dx + dy * dy);
return {
delay: 0,
duration: Math.sqrt(d) * 120,
easing: cubicOut,
tick: (t, u) => Object.assign(node.style, { color: t > 0.5 ? 'Pink' : 'Blue' })
};
}
</script>
{#each list as item, index (item)}
<div animate:whizz>{item}</div>
{/each}
文档在这里附有一条重要性能提示:只要可能用 css 就不要用 tick——Web Animation 可以脱离主线程运行,避免在低性能设备上产生卡顿。tick 每一帧都要在主线程执行你的 JS 并直接改样式,仅用于 css 无法表达的场景(比如切换非 CSS 状态)。
运行时如何“冻结”元素完成 FLIP:measure / apply / fix / unfix
理解 animate: 为什么能跑通,还要看运行时管理器的 fix/unfix 阶段(transitions.js):
measure:重排前用getBoundingClientRect()记录from;fix:把元素临时改为position: absolute并固定width/height(保存原值到original_styles以便还原),如果这导致位置偏移,再叠加一个translate(...)把元素“钉”在视觉原点上,从而让列表其它元素先行移动而不带动它;apply:重排后记录to,调用你的动画函数并创建 Web Animation;unfix:动画结束后恢复position、width、height、transform原值。
fix 中还有一个防御性细节:如果 element.getAnimations().length 非零(元素正被其它动画,例如 crossfade 占着 transform),则跳过定位,注释解释了原因是“正在运行的动画施加的样式优先级更高,会导致元素跳到左上角”。另外,针对 <svelte:element> 标签动态变化的场景,animation() 会复用已存在的管理器、只替换 nodes.a.element,而不是新建一个。
缓动函数方面,flip 默认使用的 cubicOut 以及 linear、sineIn/Out/InOut、quad*、cubic*、expo*、circular、elastic*、back*、bounce* 等全套缓动函数都来自 easing/index.js,通过 svelte/easing 子模块导出,可在自定义动画的 easing 字段中任意选用。
小结:使用 animate: 的要点清单
animate:只配合 keyed{#each}使用,且必须写在 each 的直接子元素上;- 只在重排时触发,增删元素不触发——若需要进出场动画请改用 transitions;
- 参数用对象字面量
animate:flip={{ delay: 500 }}传递,flip支持delay、duration(可为(len) => number)、easing三个参数; - 自定义函数接收
(node, { from, to }, params),返回{ delay, duration, easing, css, tick };from/to是重排前后的DOMRect; - 优先用
css(Web Animation,可离主线程),必要时才用tick; - 参数表达式不能包含
await,否则编译报错。
参考仓库文件:模板语法文档 16-animate.md、keyed each 文档 03-each.md、svelte/animate 参考页 21-svelte-animate.md、内置实现 animate/index.js、类型定义 animate/public.d.ts、编译器 AnimateDirective.js 与 EachBlock.js、运行时 transitions.js。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00