Svelte animate: 指令详解:keyed each 列表重排动画的 FLIP 原理与自定义动画函数
【免费下载链接】svelteweb development for the rest of us项目地址: https://gitcode.com/GitHub_Trending/sv/svelte
本文基于 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标志。注释里明确写道:“Sinceanimate: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。
【免费下载链接】svelteweb development for the rest of us项目地址: https://gitcode.com/GitHub_Trending/sv/svelte
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考