3、CSSTransition实战:用CSSTransition实现常见入场/离场动画

好,咱们今天来点真格的。

上一章我们聊了React Transition Group的基本概念,说白了就是搭建了一个动画框架。但光有架子不行,得往里填东西。这一章,我们就用CSSTransition这个核心组件,把淡入淡出、滑动、缩放这些最常见的入场/离场动画一个个实现出来。

我个人习惯把CSSTransition叫做「CSS状态机」——它本质上就是在不同阶段往DOM元素上贴不同的class名,然后你通过CSS来控制这些class对应的样式变化。就这么简单,但威力不小。

3.1 先搭个基础架子

动手之前,先看看CSSTransition的基本用法。我直接上代码,你感受一下:

import { CSSTransition } from 'react-transition-group';
import { useState } from 'react';

function FadeDemo() {
  const [show, setShow] = useState(true);

  return (
    <div>
      <button onClick={() => setShow(!show)}>
        {show ? '隐藏' : '显示'}
      </button>

      <CSSTransition
        in={show}
        timeout={300}
        classNames="fade"
        unmountOnExit
      >
        <div className="box">我是会淡入淡出的盒子</div>
      </CSSTransition>
    </div>
  );
}

这里有几个关键属性,我挨个说一下:

  • in:布尔值,控制显示还是隐藏。true就入场,false就离场。
  • timeout:动画持续时间,单位毫秒。这个值要和CSS里的transition时长保持一致,不然会出现动画没播完就消失的尴尬情况。
  • classNames:CSS类名的前缀。比如我写了"fade",那组件就会自动生成fade-enterfade-enter-activefade-exitfade-exit-active这些class。
  • unmountOnExit:离场动画结束后,直接从DOM里移除元素。这个我强烈建议加上,不然隐藏的元素还占着位置,容易出问题。
小技巧:timeout可以传对象,比如timeout={{ enter: 300, exit: 200 }},这样入场和离场动画时长可以不一样。我在做弹窗组件时经常这么用——入场快一点,离场慢一点,用户体验更好。

3.2 淡入淡出动画

好,架子搭完了,咱们开始写CSS。淡入淡出是最基础的动画,说白了就是控制透明度从0到1,或者从1到0。

/* 入场前状态 */
.fade-enter {
  opacity: 0;
}

/* 入场动画执行中 */
.fade-enter-active {
  opacity: 1;
  transition: opacity 300ms ease-in;
}

/* 离场前状态 */
.fade-exit {
  opacity: 1;
}

/* 离场动画执行中 */
.fade-exit-active {
  opacity: 0;
  transition: opacity 300ms ease-out;
}

嗯,这里要注意一个细节。fade-enter是动画开始前的状态,fade-enter-active是动画执行中的状态。组件挂载时,会先加上fade-enter,然后在下一帧立刻换成fade-enter-active。这个「下一帧」的机制很关键,没有它,CSS transition就触发不了。

我曾经踩过一个坑:把opacity: 0opacity: 1都写在fade-enter-active里,结果动画完全不生效。为什么?因为浏览器没检测到状态变化,自然不会触发过渡。记住,初始状态和结束状态必须分在两个class里。

3.3 滑动动画

淡入淡出太单调了?那咱们加点方向感。滑动动画,说白了就是让元素从某个方向「滑」进来。

我个人最常用的是从上往下滑,做通知栏或者下拉菜单时特别好使:

/* 从上方滑入 */
.slide-enter {
  transform: translateY(-100%);
  opacity: 0;
}

.slide-enter-active {
  transform: translateY(0);
  opacity: 1;
  transition: all 300ms ease-out;
}

.slide-exit {
  transform: translateY(0);
  opacity: 1;
}

.slide-exit-active {
  transform: translateY(-100%);
  opacity: 0;
  transition: all 300ms ease-in;
}

你想想看,这里我同时控制了transformopacity,效果就是元素一边滑下来一边变清晰。比单纯的滑动要有质感得多。

如果想从右边滑入,把translateY改成translateX(100%)就行。从左边就是translateX(-100%)。这个套路很固定,你记住一个方向,其他方向就是改个参数的事。

性能提醒:做滑动动画时,尽量用transform而不是top/left。因为transform只触发合成层,不触发重排和重绘,性能好得多。我在移动端项目里测试过,用transform的帧率能稳定在60fps,用top直接掉到30fps以下。

3.4 缩放动画

缩放动画适合做弹窗、图片预览、或者那种「从一个小点炸开」的效果。实现起来也不复杂:

.scale-enter {
  transform: scale(0);
  opacity: 0;
}

.scale-enter-active {
  transform: scale(1);
  opacity: 1;
  transition: all 300ms cubic-bezier(0.34, 1.56, 0.64, 1);
}

.scale-exit {
  transform: scale(1);
  opacity: 1;
}

.scale-exit-active {
  transform: scale(0);
  opacity: 0;
  transition: all 200ms ease-in;
}

注意看,我入场动画用了cubic-bezier(0.34, 1.56, 0.64, 1)。这个贝塞尔曲线有个特点——它会先超过终点再弹回来,产生一种「弹性」的感觉。说白了就是让动画更有活力,不那么死板。

离场动画我用了200ms,比入场短。为什么?因为用户关闭弹窗时通常希望它快点消失,别磨叽。这个设计细节是我从实际项目中总结出来的。

避坑指南:我曾经在缩放动画里忘了加transform-origin,结果元素从左上角缩放,看起来特别奇怪。如果你想让元素从中心缩放,记得加上transform-origin: center center。弹窗类组件建议用这个,下拉菜单类组件可能从顶部缩放更自然。

3.5 组合动画:淡入 + 滑动 + 缩放

单个动画玩熟了,咱们可以组合起来用。比如做一个卡片入场效果,让它一边滑上来一边放大一边变清晰:

.card-enter {
  transform: translateY(50px) scale(0.9);
  opacity: 0;
}

.card-enter-active {
  transform: translateY(0) scale(1);
  opacity: 1;
  transition: all 400ms cubic-bezier(0.34, 1.56, 0.64, 1);
}

.card-exit {
  transform: translateY(0) scale(1);
  opacity: 1;
}

.card-exit-active {
  transform: translateY(20px) scale(0.95);
  opacity: 0;
  transition: all 250ms ease-in;
}

这里我把translateYscale写在了同一个transform里。注意顺序——先位移再缩放,效果更自然。反过来先缩放再位移,元素移动的路径会变,看起来怪怪的。

组合动画的关键是「统一节奏」。所有属性用同一个transition时长,或者至少时长相近。不然会出现透明度已经变完了,位置还没到位的情况,那画面就割裂了。

3.6 实际项目中的封装思路

每次写动画都重复这些class很烦,对吧?我一般会封装成一个Animate组件:

function Animate({ children, type = 'fade', timeout = 300, show }) {
  const classMap = {
    fade: 'fade',
    slide: 'slide',
    scale: 'scale',
    card: 'card'
  };

  return (
    <CSSTransition
      in={show}
      timeout={timeout}
      classNames={classMap[type]}
      unmountOnExit
    >
      {children}
    </CSSTransition>
  );
}

// 使用
<Animate type="slide" show={visible}>
  <div>滑进来的内容</div>
</Animate>

这样项目里所有动画都统一管理,想改动画时长或者效果,改一个地方就行。我在一个中型后台项目里就是这么干的,维护起来特别省心。

3.7 常见问题与调试技巧

问题 原因 解决方案
动画不执行 timeout和CSS transition时长不一致 确保两者数值相同
元素闪一下才消失 忘记加unmountOnExit 加上unmountOnExit属性
入场动画不触发 初始状态和结束状态写在了同一个class里 把初始状态写在-enter里,结束状态写在-enter-active里
动画卡顿 使用了会触发重排的属性(如top/left) 改用transform和opacity

调试动画时,我建议你在浏览器开发者工具里打开「动画」面板(Chrome DevTools -> More Tools -> Animations)。它能录制和回放动画,还能看到每一帧的变化。我曾经靠这个面板找到了一个因为z-index导致动画被遮挡的bug,省了我半天时间。

调试小技巧:如果动画不生效,先在CSS里把transition时长改成5秒。这样动画变得很慢,你能清楚地看到每个阶段的状态变化。排查完再改回正常时长。

好了,这一章的内容就到这里。淡入淡出、滑动、缩放,再加上组合动画,你已经掌握了最常见的入场/离场动画写法。下一章我们会聊更高级的用法——列表动画和动态切换动画,到时候你会看到TransitionGroupCSSTransition配合起来有多强大。