静态页面再漂亮也是死的,好的动效能让界面"活"过来:卡片滑入、列表展开、元素被拖走……本文介绍页面动画的三板斧——CSS 过渡、Web Animations API 和 requestAnimationFrame,并逐步实现滚动显现、缓动动画、拖拽三个经典效果,让你从"会写动画"升级到"会写不卡的动画"。

1. 让页面动起来的三种手段

做动画先选工具,选错工具会平白增加大量代码。三种方式各有定位:

手段适合场景特点
CSS transition / animationhover、入场离场等简单效果声明式,浏览器优化,几乎零代码
Web Animations API需要 JS 控制的样式动画element.animate(),可暂停、回放
requestAnimationFrame逐帧计算:拖拽、进度条、物理模拟完全可控,每帧都能改值

原则很简单:能用 CSS 解决的别用 JS,能用 JS 控制样式的别自己造轮子。CSS 动画跑在合成器线程上,比 JS 逐帧改 style 更省性能。先看一个最基础的 CSS 过渡:

.box {
  width: 80px;
  height: 80px;
  background: #4f7cff;
  border-radius: 8px;
  transition: transform 0.4s ease, background 0.4s ease;
}

.box:hover {
  transform: scale(1.2);
  background: #ff6b6b;
}

transition 声明了"哪些属性变化时要做动画、持续多久、用什么缓动"。鼠标悬停触发样式变化,浏览器自动补出中间帧——这就是最省心的动画,一个 CSS 属性就完成了。

CSS 动画还有两个进阶属性值得知道:animation 系列(配合 @keyframes)可以定义多阶段动画,不依赖交互状态;而 transition 只在属性值变化时生效。另外,transition 里的缓动函数和 JS 里的缓动函数是同一套概念,CSS 的 ease-in-out 就对应后面要讲的 easeInOutQuad 曲线——理解了 JS 版,再看 CSS 也不会觉得神秘。

2. requestAnimationFrame 入门

需要逐帧控制的场景(比如拖拽跟随、数值滚动)就用 requestAnimationFrame(简称 rAF)。它告诉浏览器:"下一帧绘制前,先执行我的回调"。浏览器会在刷新前调用它,每秒约 60 次,并且页面切到后台时自动暂停,非常省电:

const box = document.getElementById('box');
let start = null;

function move(timestamp) {
  if (start === null) {
    start = timestamp;
  }
  const elapsed = timestamp - start;
  const progress = Math.min(elapsed / 2000, 1);

  box.style.transform = 'translateX(' + progress * 300 + 'px)';

  if (progress < 1) {
    requestAnimationFrame(move);
  }
}

requestAnimationFrame(move);

回调收到的 timestamp 是页面加载以来的毫秒数,用它算进度比 Date.now() 更准(后者可能受系统时间调整影响)。progress 被 Math.min 限制在 0-1 之间,表示"动画完成了百分之多少",小于 1 就继续请求下一帧,等于 1 就停——这是所有 rAF 动画的标准骨架。

有人会问:为什么不用 setInterval?两个原因:一是 rAF 的帧率由浏览器按屏幕刷新率决定(通常 60fps,高刷屏能到 120fps),setInterval 固定间隔容易掉帧或浪费;二是页面切到后台时 rAF 自动暂停,setInterval 还在后台傻跑,白白耗电。所以"逐帧动画"的标准答案永远是 rAF,除非你需要一个精确到毫秒的定时任务,那才轮到 setInterval。

3. 缓动函数:让动画有灵魂

上面的动画是匀速的,机械感很强。真实世界的运动有加速减速,这就是"缓动(easing)"。缓动函数接收 0-1 的进度 t,返回调整后的进度。比如 easeOutCubic 让动画"先快后慢",像物体撞上缓冲垫;而 easeInOutQuad 先慢后快再慢,适合"入场又退场"的场景:

function easeOutCubic(t) {
  return 1 - Math.pow(1 - t, 3);
}

function easeInOutQuad(t) {
  return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
}

function animate(duration, easing, onUpdate) {
  return new Promise(function (resolve) {
    const start = performance.now();

    function frame(now) {
      const t = Math.min((now - start) / duration, 1);
      onUpdate(easing(t));
      if (t < 1) {
        requestAnimationFrame(frame);
      } else {
        resolve();
      }
    }

    requestAnimationFrame(frame);
  });
}

const box = document.getElementById('box');
animate(1200, easeOutCubic, function (p) {
  box.style.opacity = p;
  box.style.transform = 'scale(' + (0.5 + p * 0.5) + ')';
}).then(function () {
  console.log('动画完成');
});

把"时间 → 进度"的计算和"进度 → 样式"的应用拆成两个函数,animate 就能复用到任何属性上:透明度、位移、缩放,换一个 onUpdate 回调就行。返回 Promise 也让"动画结束后再做某事"变得非常自然,不用再写回调地狱。

缓动函数远不止这两个:常用的还有 easeOutBack(带一点回弹,适合卡片弹入)、easeInOutCubic(更柔和的加速减速)。想找灵感可以直接在 DevTools 的 CSS 面板里选 easing,浏览器会实时预览曲线;把曲线对应的函数参数记下来,你就能在 JS 里复现同款手感。

4. 滚动进入视口动画

页面滚动时让元素"登场"是很常见的交互。传统做法是监听 scroll 事件算位置,性能差还容易卡。现代做法是用 IntersectionObserver:元素进入视口时自动回调,浏览器原生帮你算,完全不用碰滚动位置:

const observer = new IntersectionObserver(function (entries) {
  for (const entry of entries) {
    if (entry.isIntersecting) {
      entry.target.classList.add('visible');
      observer.unobserve(entry.target);
    }
  }
}, { threshold: 0.2 });

document.querySelectorAll('.reveal').forEach(function (el) {
  observer.observe(el);
});

配合 CSS:.reveal 默认透明加一点位移,加上 .visible 类后通过 transition 过渡到正常状态,入场动画就完成了。threshold: 0.2 表示元素露出 20% 时触发;注意 unobserve——只播一次的元素,观察完立刻解除,避免多余开销。对应的 CSS 只有几行:

.reveal {
  opacity: 0;
  transform: translateY(24px);
  transition: opacity 0.6s ease, transform 0.6s ease;
}

.reveal.visible {
  opacity: 1;
  transform: none;
}

初始状态向下偏移 24px 并完全透明,加上 visible 类后回到原位、恢复可见,浏览器自动补出平滑过渡。transform: none 比 translateY(0) 语义更清晰——"回到默认"比"再平移 0 像素"好懂,也少一次矩阵计算。

5. 拖拽:指针事件实战

拖拽是典型的"逐帧跟随指针"场景。用 pointerdown 开始、pointermove 移动、pointerup 结束,比 mouse 事件多一个好处:触摸屏和鼠标统一处理,一套代码两端通用:

const draggable = document.getElementById('draggable');

draggable.addEventListener('pointerdown', function (event) {
  const startX = event.clientX;
  const startY = event.clientY;
  const rect = draggable.getBoundingClientRect();

  function onMove(e) {
    draggable.style.left = rect.left + (e.clientX - startX) + 'px';
    draggable.style.top = rect.top + (e.clientY - startY) + 'px';
  }

  function onUp() {
    window.removeEventListener('pointermove', onMove);
    window.removeEventListener('pointerup', onUp);
  }

  window.addEventListener('pointermove', onMove);
  window.addEventListener('pointerup', onUp);
});

两个关键细节:监听 move/up 要挂在 window 上而不是元素上——鼠标移出元素也能继续拖,不会"脱手";开始拖时记录起始坐标差,而不是直接把 event.clientX 赋值给 left,否则元素会"跳"到鼠标位置,手感全无。

指针事件还有一个神器:setPointerCapture。在 pointerdown 里调用 event.target.setPointerCapture(event.pointerId),之后的所有指针事件都会被强制派发给这个元素,连 move/up 的监听都可以直接挂在元素上,不用再挂 window。它还能保证"手指按下后即使滑出元素也不丢事件",做触摸拖拽时特别省心。

拖拽还有一个容易踩的坑:记得给元素加 touch-action: none 样式,否则触摸屏上浏览器会先响应滚动,拖拽手势会被"抢走",表现就是手指动了但元素纹丝不动。这行 CSS 加上之后,桌面端的鼠标拖拽和移动端的触摸拖拽就都能正常工作,一套代码通吃两种设备。

6. 性能提醒:别让动画卡顿

动画卡顿通常不是机器差,而是触发了昂贵的"重排"。尽量只改 transform 和 opacity 这两个属性,它们由合成器处理,不触发布局计算。同理,能用 transform 做的位移,别去改 left/top——后者每次变化都要重新计算布局:

const box = document.getElementById('box');

const animation = box.animate(
  [
    { transform: 'translateX(0)' },
    { transform: 'translateX(200px)' }
  ],
  {
    duration: 800,
    easing: 'ease-in-out',
    iterations: Infinity,
    direction: 'alternate'
  }
);

animation.pause();
setTimeout(function () {
  animation.play();
}, 1000);

这是 Web Animations API 的完整例子:element.animate() 一行就能生成动画,还自带 pause()/play()/cancel() 控制方法。direction: 'alternate' 让动画来回摆动,iterations: Infinity 无限循环。想快速做"JS 控制的样式动画",它比手写 rAF 省一半代码。

最后别忘了尊重用户的偏好:很多系统里开着"减少动态效果(prefers-reduced-motion)"。用 matchMedia('(prefers-reduced-motion: reduce)') 检测到后,直接跳过动画、瞬间切换状态,既是对用户的体贴,也是专业团队的标配。记住动画是增强,不是必需——内容在任何情况下都要可用。

7. 总结与练习

三种手段按需选用:CSS transition 管简单过渡,WAAPI 管 JS 控制的样式动画,rAF 管逐帧计算;缓动函数决定动画的"手感";滚动入场用 IntersectionObserver;拖拽用指针事件;性能上优先 transform 和 opacity。

练习:

💡 用 DevTools 的 Performance 面板录制动画过程,能看到哪些帧超时。绝大多数卡顿都源于布局抖动(layout thrashing)——把读取和写入样式分开,就能避开它。