1. 滚动动画这件事,为什么老方案反而更省心
页面滚动到可视区域才触发动画,这个需求几乎每个做前端的人都遇到过。不管是企业官网的产品介绍模块、个人博客的文章卡片,还是后台系统里的数据看板,只要想让页面"活"一点,滚动显动画就是最直接的手段。我最早接触这类需求的时候,第一反应是手写IntersectionObserver,监听元素进入视口,然后加 class 触发 CSS transition。逻辑不复杂,但真写到项目里,问题就来了:每个模块的动画延迟不一样、方向不一样、有的要缩放有的要位移,CSS 写了一堆 keyframes,JS 里还要维护一套阈值配置,改一个效果要动三四个地方。
后来我换了个思路,用Animate.css + wow.js这套组合。Animate.css 负责提供现成的动画类,wow.js 负责监听滚动和触发时机,两者配合下来,大部分滚动动画需求只需要在 HTML 标签上写几个 data 属性就能搞定。这套方案不是新技术,甚至可以说有点"老",但它的稳定性和开发效率在实际项目里非常能打。尤其是项目需要支持 Vue 的时候,只要处理好生命周期和 DOM 渲染时机,一样跑得很顺。
这篇文章我会把这套方案的完整落地过程拆开讲:Animate.css 的动画类怎么选、wow.js 的配置项怎么调、Vue 项目里为什么直接引入会失效、以及我在实际项目里踩过的几个坑。适合已经会写基础 HTML/CSS/JS、想快速给页面加滚动动画的开发者,也适合在 Vue 项目里遇到动画不触发问题的朋友。读完你至少能做到两件事:一是独立完成一套滚动动画的接入,二是知道出问题时该往哪个方向排查。
2. Animate.css 的动画类到底该怎么挑
2.1 它本质上就是一堆预设好的 keyframes
Animate.css 的核心价值在于它把常用的 CSS 动画提前写好了。你引入一个 CSS 文件,然后给元素加上类似animate__animated animate__fadeInUp这样的类名,元素就会按照预设的 keyframes 动起来。它内部的结构很清晰:一个基础类animate__animated负责设置动画时长、填充模式、播放次数这些通用属性,另一个具体类animate__fadeInUp负责定义从什么状态动到什么状态。
我一般会把它的动画分成四类来记,这样选的时候不容易乱:
| 类别 | 典型类名 | 适用场景 |
|---|---|---|
| 淡入淡出 | fadeIn、fadeInUp、fadeInLeft | 通用性最强,几乎不挑内容 |
| 位移滑动 | slideInUp、slideInLeft | 强调方向感,适合列表、卡片 |
| 缩放 | zoomIn、zoomInUp | 突出焦点,适合图标、按钮 |
| 翻转旋转 | flipInX、rotateIn | 视觉冲击强,慎用,容易晕 |
选动画类的时候有个原则我一直在用:内容越重要,动画越克制。主标题可以用fadeInUp稍微带点位移,正文段落就用纯fadeIn,按钮和图标可以稍微活泼一点用zoomIn。如果整页都是flipInX和rotateIn,用户滚两屏就晕了,反而显得不专业。
2.2 版本差异是个容易被忽略的坑
Animate.css 从 3.x 升级到 4.x 之后,类名规则变了。3.x 时代你写animated fadeInUp就行,4.x 之后必须写成animate__animated animate__fadeInUp,多了一个animate__前缀。这个改动是为了避免和其他库的类名冲突,但如果你看的教程是几年前的,直接照抄就会失效。
我在一个老项目里就遇到过这个情况:同事从网上找了个 demo,类名写的是animated bounceIn,结果页面完全没反应。排查了半天以为是 JS 没加载,最后发现是引入的 Animate.css 是 4.x 版本,类名对不上。所以第一件事,先确认你引入的版本,然后按对应版本的类名规则来写。如果你用的是 CDN,建议锁定版本号,不要用latest,否则哪天库更新了类名规则,线上页面可能突然就不动了。
<!-- 4.x 版本引入方式 --> <link rel="stylesheet" href="https://cdn.example.com/animate.css@4.1.1/animate.min.css" /> <!-- 使用时的类名 --> <div class="animate__animated animate__fadeInUp">内容</div>提示:如果你不想记前缀,也可以在构建工具里用 PostCSS 插件做类名转换,但我不推荐这么做,多一层转换就多一个出问题的环节,直接按官方规则写最稳。
2.3 动画时长和延迟的微调逻辑
Animate.css 默认动画时长是 1 秒,这个速度在滚动场景里其实偏慢。用户滚动页面的时候,元素进入视口后如果等 1 秒才动完,会感觉页面"拖沓"。我一般会把时长压到 0.6 到 0.8 秒之间,具体看动画类型:位移类动画可以稍长一点,淡入类可以短一点。
调整方式有两种。一种是直接覆盖 CSS 变量,Animate.css 4.x 支持通过--animate-duration来改:
:root { --animate-duration: 0.7s; }另一种是给单个元素加内联样式或者自定义类。如果项目里不同模块需要不同时长,我建议用自定义类的方式,把时长配置集中管理:
.anim-fast { --animate-duration: 0.5s; } .anim-normal { --animate-duration: 0.7s; } .anim-slow { --animate-duration: 0.9s; }延迟这块要特别注意,wow.js 的>new WOW({ boxClass: 'wow', // 需要触发的元素类名,默认就是 wow animateClass: 'animate__animated', // 动画基础类,4.x 版本要改成这个 offset: 0, // 元素距离视口底部多少像素时触发 mobile: true, // 移动端是否启用 live: true, // 是否持续检测动态添加的元素 scrollContainer: null, // 滚动容器,默认是 window resetAnimation: true // 元素离开视口后是否重置动画 }).init();
offset这个参数我调得比较多。默认值 0 意味着元素刚进入视口底部就触发,但实际体验下来,稍微提前一点触发会更自然。我一般设成 50 到 100 之间,让元素在还没完全进入视口时就开始动,用户滚到的时候动画已经进行了一部分,感觉更流畅。但也不能设太大,否则元素还在屏幕外就动完了,滚过去看到的是静止状态,就失去意义了。
animateClass这个参数是 4.x 版本必须改的。wow.js 默认值是animated,对应 Animate.css 3.x。如果你用的是 4.x 的 Animate.css,这里不改的话,动画基础属性(时长、填充模式)就不会生效,元素会瞬间跳到最终状态,看起来像没动画一样。这个坑我见过不止一个人踩。
live参数在 Vue 项目里特别重要。Vue 是数据驱动渲染,列表数据变化后新生成的 DOM 元素,wow.js 默认是检测不到的。把live设为true,wow.js 会持续监听 DOM 变化,新元素也能正常触发动画。不过要注意,live开启后会增加一定的性能开销,如果页面元素特别多,可以考虑在数据更新后手动调用WOW.sync()来同步。
3.3 和滚动容器的配合问题
大部分情况下页面滚动是 window 级别的,wow.js 默认就能处理。但如果你的页面结构是某个固定高度的容器内部滚动,比如后台系统常见的"左侧菜单 + 右侧内容区"布局,右侧内容区是一个overflow: auto的 div,那 wow.js 默认监听 window 滚动就失效了。
这种情况需要把scrollContainer指向那个滚动容器:
new WOW({ scrollContainer: '.content-wrapper' }).init();这里有个细节:scrollContainer接受的是选择器字符串或者 DOM 元素。如果你在 Vue 的mounted里初始化,要确保这个容器已经渲染出来了,否则选择器找不到元素,wow.js 会回退到监听 window,动画依然不触发。我一般会在mounted的nextTick里做初始化,确保 DOM 就绪。
4. Vue 项目里接入时最容易翻车的几个环节
4.1 直接在 mounted 里初始化为什么有时不生效
Vue 项目里接入 wow.js,最常见的写法是在组件的mounted钩子里 new 一个 WOW 实例。但很多人会遇到"第一次进页面有动画,路由切换回来就没动画了"或者"列表数据是异步加载的,动画完全不触发"。
第一个问题的原因是:wow.js 实例在组件销毁时没有清理,路由切换后旧实例还挂着,新组件的 DOM 虽然渲染了,但 wow.js 扫描的是旧的状态。解决办法是在beforeDestroy或者unmounted里把实例置空,并在新组件挂载时重新初始化。
第二个问题的原因是:异步数据还没回来,DOM 里根本没有那些元素,wow.js 初始化时扫描不到。等数据回来渲染出元素后,wow.js 已经完成了初始化,不会再扫描。这时候要么开启live: true,要么在数据更新后手动调用WOW.sync()。
// Vue 2 写法 export default { data() { return { wowInstance: null }; }, mounted() { this.$nextTick(() => { this.wowInstance = new WOW({ animateClass: 'animate__animated', offset: 80, live: true }); this.wowInstance.init(); }); }, beforeDestroy() { if (this.wowInstance) { this.wowInstance = null; } } };4.2 列表渲染时 key 和动画的冲突
Vue 的v-for渲染列表时,如果每个列表项都带wow类,滚动到列表区域时所有项会同时触发动画,看起来像"整块一起冒出来",没有层次感。我一般会给每个项加一个递增的><div v-for="(item, index) in list" :key="item.id" class="wow animate__animated animate__fadeInUp" :data-wow-delay="(index * 0.1) + 's'" > {{ item.title }} </div>
这里有个坑:>onMounted(async () => { const { default: WOW } = await import('wow.js'); const wow = new WOW({ animateClass: 'animate__animated', offset: 80 }); wow.init(); });
服务端渲染还有一个问题:首屏内容在服务端就渲染好了,客户端接管后 wow.js 才初始化,如果首屏元素已经在可视区域内,wow.js 初始化时会立即触发它们的动画,用户会看到首屏内容"闪一下"。解决办法是给首屏元素不加wow类,或者给它们设置一个较短的延迟,让动画快速完成,减少闪烁感。
5. 实际项目里踩出来的经验与排查思路
5.1 动画不触发时的排查顺序
遇到动画不触发,我一般按这个顺序排查,基本能覆盖九成以上的情况:
- 确认 Animate.css 和 wow.js 都加载成功。打开浏览器控制台,看 Network 面板里两个文件是不是 200 状态,有没有 404。
- 确认类名版本匹配。Animate.css 是 4.x 的话,元素上必须有
animate__animated和animate__xxx两个类,wow.js 的animateClass也要设成animate__animated。 - 确认 wow.js 初始化代码执行了。在
new WOW().init()后面打一个console.log,看有没有输出。如果没有,说明代码没走到,可能是引入方式有问题或者被条件判断挡住了。 - 确认元素在初始化时已经存在于 DOM 中。如果是异步渲染的内容,检查初始化时机是不是太早了。
- 确认滚动容器配置正确。如果是容器内滚动,检查
scrollContainer有没有指向正确的元素。
这个顺序的逻辑是:先排除资源加载问题,再排除配置问题,最后排除时机问题。大部分问题在前两步就能定位。
5.2 性能上的取舍
wow.js 监听滚动事件,每次滚动都会计算元素位置。如果页面元素特别多,比如一个长列表有几百个带wow类的项,滚动时会有明显的卡顿。我做过一个粗略的测试,在中等配置的笔记本上,带wow类的元素超过 200 个之后,滚动帧率会从 60 降到 40 左右。
优化的思路有几个。一是减少wow类的使用范围,只给需要动画的模块加,不要全页铺开。二是对于长列表,只给首屏可见的十几项加动画,后面的项不加,或者用虚拟滚动配合。三是把offset调小,减少触发计算的频率。四是在移动端可以考虑关闭动画,mobile: false,因为移动端滚动本身就更敏感,动画多了反而影响操作。
注意:wow.js 内部用的是
scroll事件加节流,不是IntersectionObserver。如果你的项目对性能要求很高,或者元素数量确实很大,可以考虑用原生IntersectionObserver自己封装一个轻量方案,但开发成本会高一些。Animate.css + wow.js 的优势就在于快,适合大多数中小型项目。
5.3 和 CSS 动画冲突的处理
有时候元素本身已经有 CSS transition,比如 hover 时颜色变化,再加上 wow.js 的动画类,可能会出现动画叠加或者互相覆盖的情况。我遇到过一次:一个按钮本身有transition: all 0.3s,加上animate__animated之后,wow.js 触发时按钮的 transition 和 animation 同时作用,导致按钮先闪一下再动,很别扭。
处理办法是给动画元素单独包一层容器,外层负责 wow.js 动画,内层负责自身的 transition,两者互不干扰:
<div class="wow animate__animated animate__fadeInUp"> <button class="btn">按钮</button> </div>这样 wow.js 操作的是外层 div,按钮自身的 transition 在内层正常生效,不会打架。这个思路在复杂组件里特别有用,凡是既有交互动画又有滚动动画的元素,都建议做一层隔离。
5.4 无障碍与用户偏好的尊重
现在操作系统层面都有"减少动态效果"的设置,用户在系统里开启后,是希望页面少一些动画的。CSS 提供了prefers-reduced-motion媒体查询,可以在用户开启这个偏好时禁用动画:
@media (prefers-reduced-motion: reduce) { .animate__animated { animation: none !important; } }加上这段之后,开启减少动态效果的用户看到的就是静态内容,不会因为滚动动画感到不适。这个细节很多项目会忽略,但做上去成本很低,体现的是对用户的尊重。我在最近几个项目里都加了这个处理,反馈挺好。
6. 一套可直接复用的接入模板
6.1 普通 HTML 项目的完整写法
如果你做的是传统多页项目,接入流程很直接。先引入两个库的 CSS 和 JS,然后在需要动画的元素上加类名和 data 属性,最后初始化 wow.js。
<!DOCTYPE html> <html> <head> <link rel="stylesheet" href="animate.min.css" /> <style> @media (prefers-reduced-motion: reduce) { .animate__animated { animation: none !important; } } </style> </head> <body> <section class="wow animate__animated animate__fadeInUp">// useWow.js import { onMounted, onUnmounted, nextTick } from 'vue'; export function useWow(options = {}) { let wowInstance = null; onMounted(async () => { await nextTick(); const { default: WOW } = await import('wow.js'); wowInstance = new WOW({ animateClass: 'animate__animated', offset: 80, mobile: true, live: true, ...options }); wowInstance.init(); }); onUnmounted(() => { wowInstance = null; }); return { sync: () => wowInstance && wowInstance.sync() }; }用的时候在组件里调用useWow(),如果列表数据更新后需要重新同步,调用返回的sync方法就行。这个封装的好处是把动态导入、nextTick、清理逻辑都处理好了,业务组件里不用关心这些细节。
6.3 几个值得记住的参数组合
最后整理几组我实际用过的参数组合,对应不同场景,可以直接参考:
| 场景 | offset | duration | delay 策略 |
|---|---|---|---|
| 官网首屏模块 | 60 | 0.6s | 无延迟,快速呈现 |
| 产品特性列表 | 80 | 0.7s | 每项递增 0.08s |
| 博客文章卡片 | 100 | 0.8s | 每项递增 0.1s |
| 后台数据看板 | 50 | 0.5s | 无延迟,避免干扰操作 |
| 移动端页面 | 40 | 0.5s | 延迟减半,减少等待 |
这些数值不是固定的,要根据实际页面高度和内容密度微调。我的建议是先在本地把参数调到自己看着舒服,然后在不同尺寸的屏幕上各滚一遍,确认没有元素"动得太早"或者"等太久"的情况。
滚动动画这个东西,做得好是锦上添花,做得不好就是画蛇添足。Animate.css + wow.js 这套组合最大的价值是让你用很低的成本做出及格线以上的效果,剩下的就是靠参数微调和克制使用来提升质感。我在实际项目里的体会是,动画宁少勿多,宁快勿慢,用户是来看内容的,不是来看动画的。把动画当成内容的辅助,而不是主角,这个度把握住了,页面自然就舒服了。