网页动画一换设备就崩,响应式设计到底该咋整?

3,785字
16–24 分钟
in

不同尺寸设备上看网页动画,经常出现位置跑偏、缩放失常的糟心事。这里分享从实际项目里摸出来的处理经验,包括流体动画用视口单位、SVG的viewBox妙用、容器查询新特性,还有FLIP技术搞定复杂布局切换。针对手机和电脑的不同交互方式,也能用媒体查询做精准控制。GSAP的matchMedia和ScrollTrigger插件能省不少力气。这些方法能解决大部分适配难题。

目录

流体动画

动画在不同屏幕宽度下自动伸缩,靠的就是流体思路。核心原则:别跟浏览器较劲,给它定好规则就行。比如移动一个方块,如果写死left: 200px,换到手机屏幕上直接就飞出边界了。换成视口单位或百分比,浏览器自己会算。

实操一个卡片飞入效果:

.box {
  transform: translateX(100%);
  animation: slideIn 0.5s forwards;
}
@keyframes slideIn {
  to { transform: translateX(0); }
}

这里用百分比位移,不管父容器多宽,最终位置都在原位。要是改成translateX(300px),小屏设备上就露馅了。

另外注意,改left/top这类布局属性会触发重排,动画一卡一卡的像幻灯片。必须用transformopacity,浏览器走合成器线程,丝滑很多。

流体动画里另一个神器是视口单位。假设做一个跟着屏幕高度走的入场效果:

.hero {
  transform: translateY(100vh);
  transition: transform 0.8s;
}
.hero.active {
  transform: translateY(0);
}

100vh代表整个视口高度,无论手机还是带鱼屏,元素总能从屏幕外滑进来。但要留意移动端地址栏收起时vh会变,动画终点可能抖一下。折中办法用min-height: -webkit-fill-available做垫底。

SVG单位

SVG天生就是响应式选手,名字里“可缩放”不是白叫的。关键在viewBox属性——它定义了画布上的坐标网格。比如viewBox="0 0 100 50",宽100个单位、高50个单位。这时候把圆点从x=0移到x=100,它永远横跨整个SVG宽度,不关心实际像素。

实际操作一个加载动画:

<svg viewBox="0 0 200 100" xmlns="http://www.w3.org/2000/svg">
  <circle cx="0" cy="50" r="10" fill="#f90">
    <animate attributeName="cx" values="0;200;0" dur="1s" repeatCount="indefinite" />
  </circle>
</svg>

圆点从左边滚到右边,始终占满容器宽度。但有个坑:如果父级SVG被CSS限制了宽高比,圆点可能被压扁。解决方案是加preserveAspectRatio,例如preserveAspectRatio="MidYMid meet"让内容居中且完整显示,或者用slice模式裁剪填充。

手机上看小动画时,常常希望它不超出某块区域。套一个固定高度的父容器,SVG设overflow: visible,再配合preserveAspectRatio="MinYMin meet",宽屏下两侧多出的舞台内容会自然露出来,窄屏下自动裁掉两边。

容器查询

以前做响应式只能拿浏览器宽度当裁判,现在容器查询来了——直接问父元素“你多宽”。Chrome和Safari已经支持,Firefox也快了。定义容器:

.card-container {
  container-type: inline-size;
}
.card {
  animation: slide 0.3s;
}
@container (min-width: 400px) {
  .card {
    animation-duration: 0.6s;
  }
}

父容器宽超过400px时,动画时长翻倍。这招特别适合组件库里的卡片、轮播图,不用再监听全局resize。

写代码时记得把container-type加给包裹层,不是动画元素本身。如果动画依赖父容器宽度的具体数值(比如移动距离等于父宽的一半),可以用容器单位cqwtransform: translateX(50cqw),1cqw等于父容器宽度的1%。再也不用JS算偏移量了。

FLIP技巧

有时候布局变化太大,比如弹窗从按钮位置飞到屏幕中央,或者列表项换了个父容器。直接改位置根本没法加过渡动画。FLIP技术就是专治这种“不可能动画”的。

名字拆开看:First(记录起点),Last(记录终点),Invert(算出偏移量并反向施加transform),Play(移除反向transform)。手动实现挺麻烦,但GSAP的FLIP插件一键搞定。

操作流程(拿一个卡片移动到另一个列表举例):

let state = Flip.getState('.card');
document.querySelector('.new-list').appendChild(card);
Flip.from(state, {
  duration: 0.5,
  ease: 'power1.inOut',
  scale: true
});

插件会自动抓取卡片移动前后的所有变化(位置、大小、旋转),然后补间回去。注意一点:移动后必须等DOM更新完再调用Flip.from,不然拿到的“Last”状态不对。如果移动过程中还有其他样式冲突,可以在Flip.from里设absolute: true,临时把元素提成绝对定位,动画结束再归位。

目标动画

手机屏幕小、性能也弱,有时候直接阉割动画反而是最优解。用媒体查询定向干掉复杂动效:

@media (max-width: 640px) {
  .complex-animation {
    animation: none;
    opacity: 1;
  }
}

光用CSS隐藏还不够,JS驱动的动画得彻底销毁。老办法各种监听resize、手动kill动画,容易留内存泄漏。GSAP的matchMedia直接封装了这套逻辑:

let mm = gsap.matchMedia();
mm.add("(min-width: 1024px)", () => {
  let tl = gsap.timeline();
  tl.to(".box", {x: 200, duration: 1});
  return () => {
    // 清理代码可写在这,但GSAP会自动还原
  };
});

当屏幕宽度低于1024px时,里面的动画自动回滚,元素上的内联样式也被清掉。再也不用担心手机还在偷偷跑桌面动画。

更细致的判断还能用prefers-reduced-motion照顾晕动症用户:

mm.add("(prefers-reduced-motion: no-preference)", () => {
  // 只有用户没关动画才执行
});

滚动触发

用GSAP ScrollTrigger做滚动动画时,屏幕尺寸一变就容易数值错乱。比如一个横向移动的画廊,宽度依赖窗口大小。解决方式:

ScrollTrigger.create({
  trigger: ".gallery",
  start: "top top",
  end: () => `+=${document.querySelector('.gallery').scrollWidth}`,
  invalidateOnRefresh: true,  // 关键开关
  onUpdate: (self) => {
    gsap.to(".gallery", {x: -self.progress * (galleryWidth - window.innerWidth)});
  }
});

invalidateOnRefresh告诉ScrollTrigger:每次窗口尺寸变化后重新计算那些依赖尺寸的值。不然用户横屏转竖屏,动画直接卡在半路上。

另外移动端滚动时地址栏会隐藏,触发放置resize事件。GSAP 3.10后可以忽略这种小波动:

ScrollTrigger.config({
  ignoreMobileResize: true
});

这样只有真正的大幅度尺寸变化才会刷新,避免动画抽搐。

运动原则

距离越远,跑的时间就得越长。桌面大屏上飞一个元素穿越大半个屏幕,还用0.3秒就太假了,像瞬移。动态调整时长:

let distance = Math.abs(parseFloat(endX) - parseFloat(startX));
let duration = gsap.utils.mapRange(0, 1000, 0.3, 1.2, distance);
gsap.to(".box", {x: endX, duration: duration, ease: "power2.out"});

mapRange把距离0~1000像素映射到0.3~1.2秒,超出范围的用clamp截断。

还有数量问题:飘浮的树叶、粒子效果,大屏上稀疏一片看着寒酸,小屏上密密麻麻又卡爆。根据宽度算数量:

let count = Math.floor(window.innerWidth / 200);
count = Math.min(count, 20); // 最多20个
for(let i=0; i<count; i++) {
  createFloatingElement();
}

宽屏每200像素加一个元素,手机只显示两三个。删除多余元素时别直接remove,给个淡出动画体面退场。