静态页面再漂亮也是死的,好的动效能让界面"活"过来:卡片滑入、列表展开、元素被拖走……本文介绍页面动画的三板斧——CSS 过渡、Web Animations API 和 requestAnimationFrame,并逐步实现滚动显现、缓动动画、拖拽三个经典效果,让你从"会写动画"升级到"会写不卡的动画"。
1. 让页面动起来的三种手段
做动画先选工具,选错工具会平白增加大量代码。三种方式各有定位:
| 手段 | 适合场景 | 特点 |
|---|---|---|
| CSS transition / animation | hover、入场离场等简单效果 | 声明式,浏览器优化,几乎零代码 |
| 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。
练习:
- 给列表项做"逐个入场"动画:每项延迟 100ms 依次显现,观察 stagger 错峰效果。
- 写一个回到顶部的按钮:点击后用 rAF + easeInOutQuad 平滑滚动,而不是瞬间跳转。
- 实现一个跟手的小球拖拽:拖起时加
scale(1.1),松手时弹回,体验"弹性"手感。
💡 用 DevTools 的 Performance 面板录制动画过程,能看到哪些帧超时。绝大多数卡顿都源于布局抖动(layout thrashing)——把读取和写入样式分开,就能避开它。