Svelte transition 指令完全指南:原理、内置过渡、自定义函数与源码实现解析
【免费下载链接】svelteweb development for the rest of us项目地址: https://gitcode.com/GitHub_Trending/sv/svelte
Transition(过渡)是 Svelte 中由"元素因状态变化而进入或离开 DOM"这一事件触发的动画机制。本文基于官方文档 14-transition.md,系统讲解transition:指令的用法、svelte/transition内置过渡的参数细节、自定义过渡函数的规范、过渡事件以及可访问性处理,并深入当前仓库的编译器与运行时源码,剖析"局部 vs 全局"过渡、可逆双向过渡、css与tick的底层执行路径,帮助你在掌握 API 的同时理解其实现原理。
基本概念:transition 何时触发
transition由元素进入或离开 DOM触发,而进出 DOM 通常由状态变化引起。两个关键特性:
- 当一个块(如
{#if ...}块)正在"离场"(transitioning out)时,块内所有元素——包括自身没有过渡的元素——都会保留在 DOM 中,直到块内所有过渡全部完成。 transition:指令表示一个双向(bidirectional)过渡,即过渡进行到一半时可以平滑反向。
最典型的应用示例:
<script> import { fade } from 'svelte/transition'; let visible = $state(false); </script> <button onclick={() => visible = !visible}>toggle</button> {#if visible} <div transition:fade>fades in and out</div> {/if}{#if visible}的求值结果变化时,<div>进出 DOM,transition:fade便同时覆盖了入场(intro)和离场(outro)两个方向。
编译器如何生成过渡代码
从源码结构看,编译器在处理过渡指令时会将其编译为运行时内部函数$.transition的调用。见 TransitionDirective.js:
- 通过位标志编码修饰符:
global修饰符置TRANSITION_GLOBAL位;in:置TRANSITION_IN位,out:置TRANSITION_OUT位,二者同时出现(即transition:)则方向为both; - 参数以 thunk(惰性求值的箭头函数)形式传入,因此过渡参数表达式可以在运行时反复求值;
- 调用语句被推入
context.state.after_update,源码注释说明这是为了确保它总是在bind:this之后执行,从而保证过渡函数拿到的是正确的节点引用; - 若参数表达式是异步的(例如含
await),编译器会用$.run_after_blockers包裹该语句,等阻塞表达式完成后再执行过渡初始化。
局部(local)与全局(global)过渡
过渡默认是局部的(local):只有当过渡所属的直接父块被创建或销毁时才播放,而不会因为更外层的父块创建/销毁而播放。加上|global修饰符后,任意祖先块的变化都会触发它。
{#if x} {#if y} <p transition:fade>fades in and out only when y changes</p> <p transition:fade|global>fades in and out when x or y change</p> {/if} {/if}运行时如何区分二者?在 transitions.js 中,transition()在创建过渡管理器后,若是 intro 且处于"应播放 intro"的场景(should_intro),会向上查找最近的块级 effect:
- 跳过透明块(snippet、
else if块等标记为EFFECT_TRANSPARENT的 effect); - 检查该块 effect 是否带有
REACTION_RAN标志——即状态变化是否发生在这个块自身,而不是它的祖先。局部过渡只有此时才真正创建 intro effect; - 全局过渡则跳过上述判断,直接播放。
这正是"局部过渡只在y变化时播放、|global过渡在x或y变化时都播放"的底层依据。
内置过渡:svelte/transition 模块
一系列内置过渡可从svelte/transition模块导入,实现位于 transition/index.js,类型定义见 public.d.ts。全部内置函数签名如下(参数均可选,括号内为源码中的默认值):
| 过渡函数 | 动画内容 | 特有参数 |
|---|---|---|
fade(node, params) | 透明度从 0 到当前计算值 | delay=0, duration=400, easing=linear |
fly(node, params) | x/y 位移 + 透明度 | x=0, y=0, opacity=0, easing=cubic_out |
slide(node, params) | 沿主轴"展开/收起",同时缩放高度/宽度、padding、margin、border | axis='y'('x' \| 'y') |
scale(node, params) | 缩放 + 透明度 | start=0, opacity=0, easing=cubic_out |
blur(node, params) | 模糊滤镜 + 透明度 | amount=5(支持带单位的字符串),opacity=0 |
draw(node, params) | SVG 路径描边动画,要求节点有getTotalLength() | speed,或duration=800/duration(len)函数 |
crossfade(params) | 生成send/receive一对跨位置过渡,见下文 | fallback(node, params, intro) |
所有内置过渡都遵循统一的TransitionConfig返回结构:
export interface TransitionConfig { delay?: number; duration?: number; easing?: EasingFunction; // (t: number) => number css?: (t: number, u: number) => string; tick?: (t: number, u: number) => void; }几个实现细节值得注意:
fade会先读取getComputedStyle(node).opacity作为目标值,因此元素本身有半透明样式时,过渡是在"当前透明度的 0 到 1"之间进行,而不是硬编码 0→1;fly中位移量使用(1 - t) * value计算,即in过渡(t 从 0 到 1)时从偏移量回到原点,且会保留元素原有的transform;slide对每一帧返回overflow: hidden;前缀,并在运行时若检测到关键帧包含overflow: hidden,会以行内样式补上——源码注释指出这是为了规避 Safari 18 之前的 bug(见 transitions.js 的needs_overflow_hidden逻辑);draw通过stroke-dasharray与stroke-dashoffset实现"画线"效果,speed与duration二选一:给speed时duration = len / speed,给函数形式的duration时以路径总长len求值;crossfade返回[send, receive]一对过渡函数,内部用两个Map按key配对发送方与接收方元素,并基于getBoundingClientRect()计算位移、缩放与透明度插值;若某节点没有对应的"另一半"(列表项消失),则回退到可选的fallback过渡。
过渡参数
过渡可以携带参数。文档特别说明:双花括号{{...}}不是特殊语法,就是表达式标签里的一个对象字面量。
{#if visible} <div transition:fade={{ duration: 2000 }}>fades in and out over two seconds</div> {/if}参数对象在运行时通过get_params?.()惰性求值后传给过渡函数(见$.transition的get_options())。由于是 thunk,参数可以引用响应式状态——例如下一节示例中prefersReducedMotion.current变化时参数会随之更新。
可访问性:prefers-reduced-motion
过渡由Web Animations API驱动,而不是 CSS 过渡/动画,因此一条将transition-duration和animation-duration置零的全局@media (prefers-reduced-motion: reduce)规则对 Svelte 过渡没有效果。
正确做法是使用svelte/motion提供的prefersReducedMotion(自 5.7.0 引入)来调整或完全禁用过渡。其实现见 motion/index.js——它本质上是一个订阅'(prefers-reduced-motion: reduce)'媒体查询的MediaQuery响应式对象,.current属性会在系统偏好变化时更新,从而驱动响应式依赖:
<script> import { prefersReducedMotion } from 'svelte/motion'; import { fly } from 'svelte/transition'; let visible = $state(false); </script> {#if visible} <p transition:fly={{ y: prefersReducedMotion.current ? 0 : 200 }}> flies in, unless the user prefers reduced motion </p> {/if}由于过渡参数是响应式表达式,用户开关"减少动态效果"后,参数会重新求值,后续过渡立即按新参数播放。
自定义过渡函数
自定义过渡函数的完整签名:
transition = (node: HTMLElement, params: any, options: { direction: 'in' | 'out' | 'both' }) => { delay?: number, duration?: number, easing?: (t: number) => number, css?: (t: number, u: number) => string, tick?: (t: number, u: number) => void }其中options是第三个参数,目前包含direction,取值为in、out或both。在运行时源码中可以确认:transition()根据TRANSITION_IN/TRANSITION_OUT标志计算direction(in:out:同时存在时为'both'),并作为第三个参数传给过渡函数(见 TransitionDirective.js 生成代码与 transitions.js 的get_options())。
关键语义:
- 若返回对象带
css函数,Svelte 会为其生成 Web Animations 的关键帧; css收到的t是经过easing处理后的 0~1 值。in过渡t从 0 走到 1,out过渡t从 1 走到 0——1永远是元素的"自然状态"(相当于没施加任何过渡),u恒等于1 - t;css/tick会在过渡开始前被反复调用(不同的t、u),用于预计算关键帧序列。
使用 css 的示例(缩放 + 弹性缓动)
<!--- file: App.svelte ---> <script> import { elasticOut } from 'svelte/easing'; /** @type {boolean} */ export let visible; /** * @param {HTMLElement} node * @param {{ delay?: number, duration?: number, easing?: (t: number) => number }} params */ function whoosh(node, params) { const existingTransform = getComputedStyle(node).transform.replace('none', ''); return { delay: params.delay || 0, duration: params.duration || 400, easing: params.easing || elasticOut, css: (t, u) => `transform: ${existingTransform} scale(${t})` }; } </script> {#if visible} <div in:whoosh>whooshes in</div> {/if}NOTE:能用
css就用css,不要用tick——Web Animations 可以脱离主线程运行,避免在低性能设备上产生卡顿。
使用 tick 的示例(打字机效果)
自定义过渡函数还可以返回tick函数,它在过渡进行中每帧被调用,参数同样是t与u:
<!--- file: App.svelte ---> <script> export let visible = false; /** * @param {HTMLElement} node * @param {{ speed?: number }} params */ function typewriter(node, { speed = 1 }) { const valid = node.childNodes.length === 1 && node.childNodes[0].nodeType === Node.TEXT_NODE; if (!valid) { throw new Error(`This transition only works on elements with a single text node child`); } const text = node.textContent; const duration = text.length / (speed * 0.01); return { duration, tick: (t) => { const i = ~~(text.length * t); node.textContent = text.slice(0, i); } }; } </script> {#if visible} <p in:typewriter={{ speed: 1 }}>The quick brown fox jumps over the lazy dog</p> {/if}延迟函数返回值:多个过渡如何协同
如果过渡函数返回的是函数而不是过渡配置对象,该函数会在下一个 microtask 中被调用。这允许多个过渡相互协调,从而实现 crossfade 这类"延迟过渡"——两个列表的元素需要等 DOM 更新完毕后互相"看见"对方,才能计算位移。
运行时源码印证了这一点:animate()中检测到options是函数时,会queue_micro_task延迟执行,并返回一个同步的 facade(abort/deactivate/reset/t),让调用方无需async/await也能正确中止或查询进度(见 transitions.js)。crossfade正是利用了这一机制。
可逆双向过渡的实现
transition:的"中途平滑反转"能力在源码中由counterpart(对手方动画)实现:intro 与 outro 互相持有引用。当反向过渡开始时,运行时会读取counterpart?.t()获取对手方当前进度t1,以t1为起点、按剩余比例Math.abs(delta)缩放时长重新生成关键帧,于是动画从当前画面无缝反方向进行,而不是从头重放(见 transitions.js 的animation.onfinish回调)。同样地,过渡进行中被重新求值的参数会沿用旧 options(current_options ??= ...),防止因duration等变化导致画面跳变。
过渡事件
带过渡的元素除了标准 DOM 事件外,还会派发四个自定义事件:
introstart/introend:入场开始 / 结束outrostart/outroend:离场开始 / 结束
源码中由dispatch_event()通过element.dispatchEvent(new CustomEvent(type))派发,且包在without_reactive_context中,避免事件回调内的状态写入被意外追踪(见 transitions.js)。
{#if visible} <p transition:fly={{ y: 200, duration: 2000 }} onintrostart={() => (status = 'intro started')} onoutrostart={() => (status = 'outro started')} onintroend={() => (status = 'intro ended')} onoutroend={() => (status = 'outro ended')} > Flies in and out </p> {/if}运行时执行路径小结
将上文源码证据串起来,一次过渡的完整执行路径是:
- 编译期:
TransitionDirectivevisitor 将transition:fade|global={{...}}编译为带标志位的$.transition(flags, element, fn, paramsThunk)调用,并插入到after_update阶段; - 初始化:
transition()解析direction,创建{ is_global, in, out, stop }管理器挂到当前 effect 的nodes.t上;对 intro,按局部/全局规则决定是否创建播放 effect; - 播放:
animate()先排队一个 microtask——源码注释解释这是为了让同一批次的嵌套过渡都先测量 DOM 再施加初始样式,且仍在下一帧绘制之前完成;随后创建一个fill: 'forwards'的占位动画覆盖delay期,防止元素在无样式的状态下被绘制; - 关键帧:delay 结束后,按
duration / (1000/60)向上取整的帧数逐帧调用css(t, u)生成关键帧数组,再调用element.animate(keyframes, { duration, fill: 'forwards' }); - 每帧 tick:若配置含
tick,通过loop()在动画running期间按当前currentTime映射出t并回调; - 事件与收尾:
onfinish触发introend/outroend,清理占位动画与临时样式;abort()则取消动画并置空effect/onfinish,源码注释指明这是为防止 Chromium 内存泄漏及cancel()后误触发onfinish。
相关资源
- 内置过渡实现:transition/index.js,类型定义:transition/public.d.ts
- 客户端运行时核心:transitions.js
- 编译器指令处理:TransitionDirective.js
prefersReducedMotion实现:motion/index.js- 官方文档原文:14-transition.md
- 测试用例(runes 运行时):tests/runtime-runes/samples,其中包含
dynamic-transition、async-transition-blockers、transition-component等与过渡相关的样本目录,可用于验证不同使用场景下的行为
【免费下载链接】svelteweb development for the rest of us项目地址: https://gitcode.com/GitHub_Trending/sv/svelte
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考