网页滚动视差总卡顿掉帧,CSS新特性如何让动画丝滑如德芙?

3,925字
17–25 分钟
in

现在做个网站,要是滚动的时候没点视觉变化,总感觉少了点啥。前几年特别流行的视差滚动,就是那种背景和前景滚动速度不一样,营造出3D立体感的玩法,让不少网站瞬间显得“高大上”。但以前实现这玩意儿,基本都得靠JavaScript,稍微一不注意,主线程一堵,动画就开始掉帧,用起来跟看PPT似的,体验直接拉跨。现在好了,CSS出了个叫“滚动驱动动画”的新特性,这回终于可以不依赖JS,光靠样式表就让动画跟滚动条深度绑定,跑起来还贼流畅,咱今天就拿一个宇宙主题的页面,把这一套新玩法从头到尾盘一遍。

目录

啥是滚动驱动动画

滚动驱动动画,听着玄乎,其实就是把页面滚动的进度,变成动画播放的进度条。以前搞个动画,要么页面一加载就跑完,要么靠JS监听滚动事件来算位置,费劲不说,还容易卡。现在CSS里多了两个宝贝函数:scroll()view()scroll() 这哥们儿是跟着滚动容器的滚动位置走,滚动条滚多少,动画就走多少,往下滚动画前进,往上滚动画倒退,跟玩进度条似的。view() 就更聪明了,它是盯着目标元素本身,看它在可视区域里露了多少脸,元素出现多少,动画就播放多少,特别适合那种“东西一出现就开始动”的效果。这两个家伙一起上,就能搞出以前费老劲才能实现的视差效果。

先让背景图层动起来

这次拿来做试验的页面,有三个“英雄区域”,每个区域背景都带了个星空图案,还有飘浮的图标。第一个目标,就是让每个区域的背景图,跟着滚动,慢慢往上移。先给背景的移动编个动作。

@keyframes parallax {
  from {
    background-position: bottom 0px center;
  }
  to {
    background-position: bottom -400px center;
  }
}

section.hero {
  animation: parallax 3s linear;
}

@keyframes 定义了一个叫 parallax 的小剧场,一开始背景贴在底部,演到最后背景往上溜了400像素。然后把这段动画挂到每个英雄区域上,设了个3秒的线性播放。刷新页面,能看到动画确实在跑,但问题来了,它不管三七二十一,页面一加载就自己开演,完事儿就歇菜了,跟滚动条半毛钱关系没有。

要想动画听滚动条的指挥,得把动画的进度条交给 scroll() 来管。把动画时长那一项删掉,然后把 animation-timeline 属性改成 scroll(),告诉浏览器:“兄弟,这个动画的进度,就看你滚多少了。”

section.hero {
  animation: parallax linear;
  animation-timeline: scroll(); 
}

再刷新,往下扒拉滚动条,奇迹发生了,背景图开始跟着滚动慢慢挪位置,滚得快,背景跑得快,滚回去,背景也倒着跑回来。而且这动画现在是在浏览器主线程之外跑的,页面上再蹦出什么复杂的JS逻辑,也休想让它掉帧,这感觉,就像给车子换了个V8发动机。

让中间内容也有视差感

光背景动还不够,得让每个区域里的标题和图标也跟着掺和一脚,这样层层叠叠,视差感才到位。给这些内容也编个“飘浮”的动作,让它们从靠上的位置,慢慢落到中间。

@keyframes float {
  from {
    top: 25%;
  }
  to {
    top: 50%;
  }
}

.hero-content {
  position: absolute;
  top: 25%;
  animation: float linear;
  animation-timeline: scroll(); 
}

设置完一看,翻车了。页面往下滚的时候,后面那几个区域的动画,还没轮到它们出场,自己就差不多演完了。这是因为 scroll() 是根据整个页面滚动条的总进度来算的,页面滚到底,所有动画都跑到终点了,后面的区域才刚露脸,当然看不到过程。这时候就得请出另一位主角:view()view() 不看全局滚动条,它只盯着自己所属的那个元素,元素进入视口,动画才开始,元素离开视口,动画结束。把动画时间轴换成 view()

.hero-content {
  animation-timeline: view(); 
}

这下好了,每个区域的内容,都是等到自己出现的时候才开始动,完美解决问题。但又冒出个新麻烦,往回滚动的时候,这些内容会突然“闪现”回原来的位置,特别鬼畜。这是因为 view() 计算动画进度,是基于元素没动之前的原始位置。元素一边动,它的位置一边变,导致“进入视口”这个判断条件乱了套。解决办法是给 view() 加个 inset 参数,这个参数可以调整“视口”的大小。弄个负值,相当于把可视区域往外扩了一圈,让动画的开始和结束都提前或延后那么一点点,刚好能覆盖掉元素移动造成的偏差。

.hero-content {
  animation-timeline: view(-100px);
}

改完之后,背景和内容各跑各的速,一个滚得快,一个挪得慢,视差效果稳得一批,再没有闪瞎眼的尴尬。

精确控制动画的起止点

除了 view() 自带的参数,还有一个叫 animation-range 的属性,能更精细地控制动画从哪开始,到哪结束,就像一个剪刀手,能把动画时间轴随意裁剪。比如页面里有个火箭emoji,想让它在视口里飞得久一点,甚至飞出视口后还能再飞一会儿。

@keyframes launch {
  from {
    transform: translate(-100px, 200px);
  }
  to {
    transform: translate(100px, -100px);
  }
}

#spaceship {
  animation: launch;
  animation-timeline: view();
  animation-range: 0% 120%;
}

这段代码里,动画默认跟着元素在视口里的出现范围走,但 animation-range: 0% 120% 告诉浏览器,动画从元素刚开始进入视口(0%)就开始,一直演到元素完全离开视口后,再延长20%的滚动距离才结束。这就让火箭多飞了一阵,效果更带感。如果想让某个元素晚点出场,比如一个彗星emoji,等它滚到离视口底部还有4rem的时候再开始转。

@keyframes rotate {
  from {
    transform: rotate(0deg) translateX(100px);
  }
  to {
    transform: rotate(-70deg) translateX(0px);
  }
}

#comet {
  animation: rotate linear;
  animation-timeline: view();
  animation-range: 4rem 120%;
}

这里 animation-range 的第一个值设成了 4rem,意思是动画在元素距离视口底部还有4rem的地方才开演,达到了延迟启动的效果。更有意思的是,animation-range 还能让一个元素在不同的滚动阶段,播放完全不同的动画。比如页面顶部的卫星emoji,先让它转着圈飞进来,飞到一定位置后,再换一个平移的动作。

@keyframes orbit-in {
  0% {
    transform: rotate(200deg);
  }
  100% {
    transform: rotate(0deg);
  }
}

@keyframes orbit-out {
  0% {
    transform: translate(0px, 0px);
  }
  100% {
    transform: translate(-50px, -15px);
  }
}

#satellite {
  animation: orbit-in linear, orbit-out ease;
  animation-timeline: view();
  animation-range: 0% 80%, 80% 110%;
}

看明白没,animation 属性里把两个动画串起来,animation-range 里用逗号分隔了两组起止范围。第一个动画 orbit-in 在元素滚动进度0%到80%这段演,演完之后,第二个动画 orbit-out 无缝衔接,从80%演到110%。这一套组合拳下来,动画表现力直接拉满。

给动画上个保险

页面整得花里胡哨,但得考虑有些小伙伴可能看着头晕。浏览器的“偏好减少运动”这个设置就是干这个的。用 @media (prefers-reduced-motion: reduce) 这个媒体查询,可以检测到用户开启了减少运动模式。一旦检测到,二话不说,直接把所有动画一刀切关掉。

@media (prefers-reduced-motion: reduce) {
  * {
    animation: none !important;
  }
}

这么干简单粗暴,对咱这个例子里,关掉动画也不影响页面内容阅读,用户舒坦了,代码也干净。另外,滚动驱动动画这新玩意儿,虽然Chrome、Edge、Opera、Firefox(后面俩得开个实验开关)都支持了,但保不齐有人用老古董浏览器。如果动画是页面的灵魂,没了它就没意思了,可以找个补丁库(Polyfill)来救场,但这玩意儿会让动画又回到主线程,有掉帧风险。要是觉得性能比动画重要,不想让老浏览器用户遭罪,那就用 @supports 来个特性检测,只在支持这特性的浏览器里才启用动画,不支持的就给个静态页面完事。

@supports (animation-timeline: scroll()) {
  section.hero {
    animation: parallax linear;
    animation-timeline: scroll();
  }
  /* 其他动画样式都塞这里面 */
}

这么一套操作下来,一个原本得靠JS、还容易卡成狗的视差滚动,就用纯CSS丝滑地实现了,代码量少了,性能还上去了,关键是滚动条和动画的配合天衣无缝,这感觉就像把一锅原本用高压锅压的肉,换成了文火慢炖,滋味足,还不费劲。