动画不是装饰,是状态迁移的视觉表达。Angular 从早期就内建了一套声明式动画体系(@angular/animations),能以数据驱动的方式描述”状态 A 到状态 B 如何过渡”。到 v22,官方的指导思路更务实:简单动画优先 CSS,状态编排复杂时用 Angular Animations,需要精细控制时用 WAAPI。本文先讲透 Angular 动画本身,再给出选型建议。
核心五要素:trigger / state / transition / style / animate
Angular 动画围绕”触发器(trigger)“组织,挂到模板上后根据绑定值在状态间过渡:
import { animate, state, style, transition, trigger } from '@angular/animations';
@Component({
selector: 'app-panel',
animations: [
trigger('openClose', [
state('open', style({ height: '*', opacity: 1 })),
state('closed', style({ height: '0px', opacity: 0 })),
transition('open <=> closed', [animate('250ms ease-in-out')]),
]),
],
template: `
<button type="button" (click)="isOpen = !isOpen">切换</button>
<div class="panel" [@openClose]="isOpen ? 'open' : 'closed'">面板内容</div>
`,
})
export class PanelComponent {
isOpen = true;
}
各要素的分工:
trigger(name, steps):命名并注册一组动画,模板用[@name]绑定state(name, style):命名一个终态样式;height: '*'表示”自适应当前内容高度”transition(stateExpr, steps):声明哪两个状态之间要播放动画,<=>是双向简写style(...):静态样式快照animate(duration, style):在时长内过渡到目标样式
使用前记得在应用根提供动画能力(懒加载场景推荐异步版本,没有动画触发时不会加载动画包):
bootstrapApplication(AppComponent, {
providers: [provideAnimationsAsync()],
});
enter 与 leave:元素的进出场
元素因 @if、@for 等控制流插入或移除时,可以用 :enter / :leave 伪状态做进出场动画——这是日常使用频率最高的两个:
trigger('fadeSlide', [
transition(':enter', [
style({ opacity: 0, transform: 'translateY(-8px)' }),
animate('250ms ease-out', style({ opacity: 1, transform: 'translateY(0)' })),
]),
transition(':leave', [
animate('200ms ease-in', style({ opacity: 0, transform: 'translateY(-8px)' })),
]),
]),
@if (showTip()) {
<p class="tip" @fadeSlide>已保存</p>
}
:enter/:leave 让”渲染”和”动画”天然同步——不存在”元素已删但动画还想播”的时序问题,框架会等 leave 动画结束才真正移除 DOM 节点。
:increment 与 :decrement:数值变化动画
当绑定值增加或减少时,可以用 :increment / :decrement 匹配。经典场景是计数器高亮:
trigger('countPulse', [
transition(':increment', [
animate('150ms', style({ color: '#4caf50', transform: 'scale(1.15)' })),
animate('150ms', style({ color: '*', transform: 'scale(1)' })),
]),
transition(':decrement', [
animate('150ms', style({ color: '#f44336', transform: 'scale(0.9)' })),
animate('150ms', style({ color: '*', transform: 'scale(1)' })),
]),
]),
<span class="count" [@countPulse]="likes()">{{ likes() }}</span>
信号作为动画绑定值完全可用——模板绑定表达式里的 likes() 变化即触发过渡判断。
列表编排:query 与 stagger
批量进出场用 query 选中子元素、stagger 错开时间,几行代码做出瀑布式入场:
import { query, stagger, style, animate, transition, trigger } from '@angular/animations';
trigger('listStagger', [
transition(':enter', [
query('.item', [
style({ opacity: 0, transform: 'translateX(-16px)' }),
stagger(80, animate('250ms ease-out', style({ opacity: 1, transform: 'translateX(0)' }))),
], { optional: true }),
]),
]),
<ul @listStagger>
@for (item of items(); track item.id) {
<li class="item">{{ item.title }}</li>
}
</ul>
{ optional: true } 让 query 在匹配不到元素时不抛错(首渲染时机问题常见的防御项)。此外 group() 让多段动画并行、sequence() 串行,配合 keyframes() 可以描述多步动画,这里不再展开。
动画与信号:驱动方式的变化
v22 的组件状态几乎都是信号,动画绑定值直接读信号即可:
export class DrawerComponent {
readonly open = signal(false);
}
<div class="drawer" [@drawer]="open() ? 'open' : 'closed'">...</div>
与 Zoneless 也完全兼容:动画触发器本身不依赖 Zone,绑定值(信号)变化会精确触发对应组件的更新。要避免的反模式是在 effect 里手动改样式类——那是把声明式动画倒退回命令式。
与 CSS 动画、WAAPI 的取舍
现代浏览器的能力让”纯 CSS”能覆盖过去必须靠 JS 的场景(@starting-style 支持进出场首帧、transition-behavior: allow-discrete 支持 display 过渡)。三套方案的定位:
| 维度 | CSS 动画/过渡 | Angular Animations | WAAPI(element.animate) |
|---|---|---|---|
| 适用场景 | hover、简单过渡、进出场 | 状态机驱动、多元素编排 | 精细逐帧控制、复用动画对象 |
| 状态编排 | 弱(靠类名切换) | 强(state/transition 声明) | 手工编排 |
| 多元素编排 | 无 | query/stagger 内建 | 手写循环 |
| 动态参数 | CSS 变量传递有限 | 任意绑定表达式 | 完全自由 |
| 包体积 | 零成本 | 引入 @angular/animations | 零成本 |
| 与 Angular 控制流集成 | 需自行处理移除时序 | :enter/:leave 自动处理 | 需自行处理 |
| 学习/维护成本 | 低 | 中 | 中高 |
选型建议按顺序自问:
- 能不能用 CSS? 简单过渡、hover 态、纯展示型进出场,CSS 是首选,零运行时成本
- 有没有多状态/多元素编排? 抽屉开合、手风琴、列表瀑布入场、依赖应用状态的复杂序列——Angular Animations 的声明式表达最省心
- 是不是游戏级/物理级效果? 拖拽跟随、惯性滚动、粒子效果,用 WAAPI 或 requestAnimationFrame 自己驱动
另外,页面级过渡可以关注 Router 的 View Transitions 集成(provideRouter(routes, withViewTransitions())),路由切换的整页动画交给浏览器原生能力,不占应用动画预算。
性能与可维护性提示
- 只动画
transform和opacity,它们走合成器线程;避免动画height/top/left引发重排(自适应高度展开是个合理例外,注意限制使用范围) - 离开视口的元素及时移除,
:leave动画时长别超过 300ms - 复杂 trigger 放到独立文件用
useAnimation复用,别让组件装饰器变成动画垃圾场 - 调试时给 trigger 临时加
animate('0ms')可以快速确认是”状态没切”还是”动画太慢”