Angular 复杂动画序列实战:query、stagger、group 与 sequence 的协调编排
【免费下载链接】angularDeliver web apps with confidence 🚀项目地址: https://gitcode.com/GitHub_Trending/an/angular
本篇技术指南基于 Angular 官方文档中的复杂动画序列章节,系统讲解如何用query()、stagger()、group()、sequence()四个核心函数编排列表/网格元素的协调动画,并结合 hero-list-page.ts、hero-list-groups.ts 等官方示例与 packages/animations/src/animation_metadata.ts 的源码定义展开实现原理。读完后,你将能够独立实现“逐项延迟入场”“并行多属性动画”“实时过滤列表动画”以及重排序列表的动画跟踪等实战场景。
需要首先明确的重要前提:@angular/animations包已在 v20.2 起被标记为废弃(deprecated)。Angular 团队推荐所有新代码使用原生 CSS 配合animate.enter与animate.leave实现动画。源码中 animation_metadata.ts 里trigger、group、sequence、query、stagger等每一个函数的文档注释都带有@deprecated 20.2 Use animate.enter or animate.leave instead. Intent to remove in v23的标记,印证了这一状态。既有代码的迁移方法可参考 迁移指南。本文聚焦该动画包的复杂序列能力本身,理解它对维护存量应用仍然必要。
四个编排函数总览
此前简单的动画只针对单个 HTML 元素,而 Angular 允许你编排协调的动画序列——例如让一个完整的列表或网格在进出页面时逐项动画,可以选择多个动画并行运行,也可以让离散的动画按顺序一个接一个执行。控制复杂动画序列的函数如下表:
| 函数 | 作用 |
|---|---|
query() | 查找一个或多个内部 HTML 元素 |
stagger() | 为多个元素的动画施加级联延迟(瀑布式) |
group() | 并行运行多个动画步骤 |
sequence() | 按顺序依次运行动画步骤 |
这四个函数在 packages/animations/src/animation_metadata.ts 中都有对应定义,并通过 packages/animations/src/animations.ts 统一导出为公开 API。从源码结构看,每个函数返回的只是一个带type字段的元数据对象(AnimationMetadataType枚举区分了Group、Sequence、Query、Stagger等 13 种元数据类型),真正的执行由动画引擎在运行时按元数据树递归解释。
query() 函数:查找内部元素的入口
大多数复杂动画都依赖query()来查找子元素并施加动画。基本用法有两类:
| 用法 | 说明 |
|---|---|
query()后接animate() | 查询简单的 HTML 元素,直接对其施加动画 |
query()后接animateChild() | 查询那些自身带有动画元数据的子元素,并触发其动画(否则这些动画会被当前/父元素的动画阻塞) |
关于父级阻塞机制,源码中animateChild的文档注释给出了明确解释:“每次 Angular 触发一个动画时,父动画具有优先级,任何子动画都会被阻塞。为了让子动画能够运行,父动画必须用query()查询包含子动画的每一个元素,并用animateChild()运行它们。” 这也说明animateChild是专为query()设计的,且只处理 Angular 动画库分配的动画,不处理 CSS keyframes/transitions。
query()的第一个参数是 CSS 选择器字符串,其中还可以包含以下 Angular 专用 token:
| Token | 含义 |
|---|---|
:enter/:leave | 进入/离开 DOM 的元素 |
:animating | 当前正在动画的元素 |
@*/@triggerName | 带有任意(或指定)动画触发的元素 |
:self | 正在动画的元素本身 |
这些 token 可以组合进一个选择器字符串,例如query(':self, .record:enter, .record:leave, @subTrigger', [...])。
query()的第三个参数是AnimationQueryOptions选项对象,从 源码定义 可见它包含两个关键开关:
optional?: boolean— 默认false。必填的 query 在执行时若查不到任何元素会抛出错误;设为true则忽略该错误。limit?: number— 限制返回结果的最大数量;若为负数,则从结果列表末尾向开头方向截取。从源码结构看,query()内部基于element.querySelectorAll收集元素。
关于“进入/离开”的常见误区:并非所有子元素都会被算作正在进入/离开,这一点有时反直觉。根据源码中query()的 API 文档,能通过:enter/:leave查询到的元素,只有那些 Angular 认为“基于自身逻辑”进出 DOM 的元素——即通过ViewContainerRef动态插入的元素,以及带有结构性指令(结构化模板指令,内部是前者的子集)的元素。如果一个元素的插入/移除只是其父元素插拔的“连带结果”,就应当在父元素的:enter/:leave过渡里用其它方式查询它。还有一个例外:带有动画触发的元素,即使父元素正在离开,也总可以被:leave查询到。
用 query() + stagger() 为多个元素编排级联动画
通过query()查询到子元素后,stagger()函数用来定义每个元素之间的时间间隔,让元素带着依次递进式的延迟执行动画。
下面的官方示例演示了如何用query()与stagger()为英雄列表(heroes)实现自顶向下的逐个入场,每个元素之间带有轻微延迟。完整代码见 hero-list-page.ts 的page-animations区域:
// hero-list-page.ts animations: [ trigger('pageAnimations', [ transition(':enter', [ query('.hero', [ style({opacity: 0, transform: 'translateY(-100px)'}), stagger(30, [ animate('500ms cubic-bezier(0.35, 0, 0.25, 1)', style({opacity: 1, transform: 'none'})), ]), ]), ]), ]), ]逐步拆解这个动画的定义过程:
- 用
query()查找满足条件、正在进入页面的元素(此处是.hero列表项); - 对每个查到的元素,先用
style()设置统一的初始样式:设为透明(opacity: 0),并用transform将其移出原位(translateY(-100px)),以便随后滑入; - 用
stagger(30, ...)让每个元素的动画彼此延迟 30 毫秒; - 对每个元素执行 0.5 秒的动画,使用自定义缓动曲线
cubic-bezier(0.35, 0, 0.25, 1),同时完成淡入(opacity: 1)与取消位移(transform: 'none')。
该触发器通过组件上的@HostBinding('@pageAnimations')绑定激活(animatePage = true)。animate()的时间字符串遵循"duration [delay] [easing]"语法,如animate("100ms 0.5s")表示时长 100ms、延迟 500ms,这一点在 animation_metadata.ts 中animate()的 JSDoc 里有完整列举(animate(500)、animate("1s")、animate("5s 10ms cubic-bezier(.17,.67,.88,.1)")等)。
stagger()的源码定义签名为stagger(timings: string | number, animation: AnimationMetadata | AnimationMetadata[]),其中timings是“每个被查到的元素动画启动之后”追加的间隔时间,animation是包裹在间隔之内的动画步骤。
group() 函数:并行动画
在级联延迟之外,你可能还想配置同时发生的并行动画。例如,对同一元素的两个 CSS 属性分别使用不同的easing函数。这时用group()函数。
注意一个关键区别:group()分组的是动画步骤(steps),而不是动画元素。
group()的行为规则(源自源码 JSDoc):
- 当步骤由
style()或animate()调用定义时,组内每次调用都立即执行(同时开始); - 若要指定更晚时间应用的样式,可以用
keyframes()定义带offset的步骤,或使用带 delay 值的animate()调用; - 当
group()位于sequence()或transition()内部时,组内所有动画步骤完成后才继续下一条指令——也就是说,整个过渡的时长取决于组内最长的那个步骤。
官方示例 hero-list-groups.ts 在:enter与:leave两处都使用了group(),为同一个元素同时应用两组独立时序的动画:
// hero-list-groups.ts (excerpt) trigger('flyInOut', [ state( 'in', style({ width: '*', transform: 'translateX(0)', opacity: 1, }), ), transition(':enter', [ style({width: 10, transform: 'translateX(50px)', opacity: 0}), group([ animate( '0.3s 0.1s ease', style({ transform: 'translateX(0)', width: '*', }), ), animate( '0.3s ease', style({ opacity: 1, }), ), ]), ]), transition(':leave', [ group([ animate( '0.3s ease', style({ transform: 'translateX(50px)', width: 10, }), ), animate( '0.3s 0.2s ease', style({ opacity: 0, }), ), ]), ]), ]),解读:enter分支:元素从“窄且右移、透明”的初始风格开始,进入一个并行组——位移/宽度动画时长 0.3s 并额外延迟 0.1s,透明度动画 0.3s 无延迟;两条动画并行推进但起点不同,实现“位移稍晚于淡入”的效果。:leave分支同理,透明度淡出延迟 0.2s 启动。width: '*'是 AUTO_STYLE 自动样式标记(源码中export const AUTO_STYLE = '*'),表示动画引擎从元素的当前实际布局取值。
sequence() 与 group() 的对比:顺序 vs 并行
复杂动画中可能同时发生许多事情。如果你想让若干动画一个接一个地依次发生,就用sequence():
style()步骤:立即应用所提供的样式数据;animate()步骤:在给定的时间区间内应用样式数据。
从源码结构看,两者的语义差异是:向transition()传入一个步骤数组时,默认就是按顺序(sequentially)执行的,而group()才是显式的并行。sequence()与group()可以嵌套——当sequence()位于group()或transition()内时,只有当内部每个步骤都完成后,执行才会继续到下一条指令。
group与sequence的源码实现都极为简洁,仅构造对应类型的元数据对象:
// packages/animations/src/animation_metadata.ts export function group( steps: AnimationMetadata[], options: AnimationOptions | null = null, ): AnimationGroupMetadata { return {type: AnimationMetadataType.Group, steps, options}; } export function sequence( steps: AnimationMetadata[], options: AnimationOptions | null = null, ): AnimationSequenceMetadata { return {type: AnimationMetadataType.Sequence, steps, options}; }二者的第三个维度是共同的AnimationOptions:delay(动画启动延迟,默认 0)与params(开发者自定义参数)。
综合实战:实时过滤列表动画(Filter Animation)
这是官方示例页 Filter/Stagger 标签页的核心场景:在Search Heroes文本框中输入Magnet或tornado之类的文本,过滤实时生效——每输入一个新字母、过滤变严格,就有元素离开页面;每删除一个字母,英雄列表又逐渐重新进入页面。
模板中,一个名为filterAnimation的触发器绑定在heroesTotal上(见 hero-list-page.html):
<!-- hero-list-page.html --> <label for="search">Search heroes: </label> <input type="text" id="search" #criteria (input)="updateCriteria(criteria.value)" placeholder="Search heroes" /> <ul class="heroes" [@filterAnimation]="heroesTotal"> @for (hero of heroes; track hero) { <li class="hero"> <div class="inner"> <span class="badge">{{ hero.id }}</span> <span class="name">{{ hero.name }}</span> </div> </li> } </ul>组件装饰器中的filterAnimation触发器包含三个过渡(完整代码见 hero-list-page.ts 的filter-animations区域):
// hero-list-page.ts trigger('filterAnimation', [ transition(':enter, * => 0, * => -1', []), transition(':increment', [ query( ':enter', [ style({opacity: 0, width: 0}), stagger(50, [animate('300ms ease-out', style({opacity: 1, width: '*'}))]), ], {optional: true}, ), ]), transition(':decrement', [ query(':leave', [stagger(50, [animate('300ms ease-out', style({opacity: 0, width: 0}))])]), ]), ]),这个例子完成了以下任务:
- 跳过初次进入时的动画:
transition(':enter, * => 0, * => -1', [])对空步骤数组匹配页面首次打开/导航时的状态。因为过滤动画是对“已经存在”的元素收窄范围,所以初始挂载不需要动画。 - 根据搜索输入过滤英雄:
updateCriteria()按name过滤HEROES,并把过滤后的数量同步到heroesTotal,驱动触发器的状态值变化。 - 对每次数量变化(
:increment):查询进入 DOM 的元素(:enter),先设置为透明且宽度为 0,再用stagger(50, ...)让每个元素自顶向下延迟 50ms,各自用 300msease-out动画恢复到默认宽度(width: '*')与不透明状态。注意第三个参数{optional: true}——没有元素进入时(比如首次渲染)不抛错。 - 对每次数量减少(
:decrement):查询离开 DOM 的元素(:leave),同样 50ms 级联延迟,每项 300ms 内把opacity与width动画到 0。
这里还隐含一个重要设计:触发器绑定的是一个数字(heroesTotal),利用:increment/:decrement这两个特殊过渡表达式——当绑定到触发器的数值表达式增加或减少时分别匹配。触发器绑定值会先统一转成字符串再做状态匹配,布尔值可写作1/true或0/false。
重排序列表的动画:TrackByFunction 的必要性
Angular 开箱即可正确地动画*ngFor(或@for)列表项,但如果列表项的排序会发生变化,动画就会失效——因为 Angular 会丢失“哪个元素是哪个”的跟踪,导致动画错乱。让 Angular 始终知道每个元素身份的唯一办法,是给列表指令指定一个TrackByFunction。
重要:如果你需要动画一个*ngFor列表,且其项的顺序在运行期间可能变化,务必使用TrackByFunction。否则元素被重新识别为“移除 + 新增”而非“移动”,进出场动画会被错误地应用,动画序列也随之崩坏。这也是上面示例中track hero(按实体而非索引跟踪)写法的原因。
动画与组件样式封装(View Encapsulation)的关系
Angular 动画基于组件的 DOM 结构实现,并不直接考虑样式封装(View Encapsulation)。这意味着:
- 使用
ViewEncapsulation.Emulated的组件,动画表现与ViewEncapsulation.None完全一致。例如,如果把query()应用在一棵使用模拟封装的组件树顶端,该 query 能识别(并因此能动画)树上任意深度的 DOM 元素; - 而
ViewEncapsulation.ShadowDom与ViewEncapsulation.ExperimentalIsolatedShadowDom会改变组件的 DOM 结构——将 DOM 元素“隐藏”在ShadowRoot元素内部。由于动画实现对简单的 DOM 结构有依赖、并不感知ShadowRoot,这类操作会使部分动画实现无法正常工作。因此官方建议:避免对包含 ShadowDom 封装组件的视图施加动画。
复杂动画序列小结
Angular 动画多元素的函数组合遵循清晰的模式:
- 用
query()查找内部元素(例如收集某个<div>内的全部图片); stagger()施加级联延迟,形成逐项瀑布式动画;group()让多个步骤并行推进,整体时长由最长步骤决定;sequence()让步骤严格顺序执行(也是过渡中步骤数组的默认行为)。
配套要点:{optional: true}避免空查询报错,limit控制查询数量上限,:enter/:leave只能命中“自主进出 DOM”的元素,重排序列表必须配TrackByFunction,ShadowDom 封装视图应避开动画。
延伸阅读
- 动画迁移指南:从
@angular/animations迁移到animate.enter/animate.leave原生 CSS 动画的完整流程; - 动画包 API 定义源码:packages/animations/src/animation_metadata.ts、统一导出入口 packages/animations/src/animations.ts;
- 官方示例代码:hero-list-page.ts、hero-list-groups.ts、hero-list-page.html。
【免费下载链接】angularDeliver web apps with confidence 🚀项目地址: https://gitcode.com/GitHub_Trending/an/angular
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考