1. 滚动视差到底在做什么,为什么我不用 CSS 硬写
滚动视差(Parallax Scroll)这个词听着挺玄,说白了就一句话:不同的元素在页面滚动时以不同速度移动,让人眼产生"这些东西离我远近不一"的错觉。我用 gsap 搭配 ScrollTrigger 做背景层视差已经有一段时间了,大大小小的活动页、品牌官网首屏、产品介绍长页都上过,之所以一直固定用这套组合,不是因为它时髦,而是因为在"滚动进度 → 元素位移"这条映射上,它给的控制粒度确实够细,而且不用自己算滚动位置、不用手写 requestAnimationFrame 循环、不用管节流和回弹。你只要告诉它"从哪个点开始、到哪个点结束、这期间元素从哪走到哪",剩下的采样、插值、刷新它全包了。
我见过太多人一上来就写background-attachment: fixed,或者用background-position配合滚动监听硬改。前者在移动端基本废掉,iOS 上根本不动甚至直接渲染异常;后者每一帧都在改背景位置,等于逼着浏览器对整块区域重绘,一屏两三个元素还行,超过五个就开始肉眼可见地掉帧。这两个方案的共同问题是:它们都在操作"布局或绘制属性",而视差本质上是一件纯粹的"视觉位移"的事,应该交给transform去干。gsap 天生就是围绕transform和opacity做文章的库,ScrollTrigger 又把"滚动进度"这个连续变量抽出来喂给它,两者凑一块正好。
1.1 视差的物理感从哪来
真实世界里你坐在车里往外看,近处的电线杆唰唰往后飞,远处的山几乎不动。这个速度差就是深度线索。放到网页上同理:远景层(天空、远山、雾气)位移量最小,中景层(云、建筑群)位移中等,前景层(树叶、栏杆、人物剪影)位移最大。三层一叠,滚动起来的纵深感立刻就出来了。注意一个反直觉的点——决定视差质量的关键不是"层数多",而是"速度比是否拉开"。我做过的第一个版本用了五层,每层速度差只有 0.05,滚起来跟没动一样,白忙一场。后来砍到三层,速度因子拉到 0.15 / 0.35 / 0.65,马上就有味道了。所以先想清楚速度梯度,再去堆素材。
1.2 纯 CSS 方案的边界在哪
background-attachment: fixed的问题不只是移动端支持差,更根本的是它把背景"钉"在视口上,无论父容器多高,背景都不随内容走。这导致它只能做"固定背景 + 内容上滑"这一种单调效果,做不出前后景反向运动、做不出速度可调的连续位移,也无法在同一个场景里让不同层用不同节奏。CSS 的animation-timeline: scroll()这两年确实能做一些滚动驱动的动画,但它在浏览器之间的成熟度、以及和 GSAP 时间线协同的灵活性,还不足以支撑一个需要精细调速的商业项目。我现在的判断标准很简单:只需要"背景固定"就上 CSS,需要"速度可调、方向可逆、多场景复用",直接 GSAP。
1.3 GSAP + ScrollTrigger 的组合价值
拆分来看,GSAP 负责"动画怎么演",ScrollTrigger 负责"什么时候演、演到哪一步"。前者提供时间线、缓动曲线、fromTo这种精确的起止控制;后者把滚动条变成一根 0 到 1 的进度条,scrub一开,滚动位置就直接驱动时间线进度。这个解耦非常关键——意味着我可以把三层背景的位移写进一条时间线,也可以用三条独立补间分别控制;可以整体加缓动,也可以某层单独设ease: 'none'保持线性。后面讲的调参、降级、复用封装,全都是建立在这个解耦之上的。
2. 开工前的准备:依赖、结构与三个坐标概念
动手之前把地基打牢,后面能省一半的调试时间。这部分我踩过的坑最多,尤其是"以为写对了其实没注册插件"这种低级错误。
2.1 依赖引入与目录组织
GSAP 本体和 ScrollTrigger 是两个文件,ScrollTrigger 属于插件,必须单独引入并且注册。官方渠道下载后放到项目里,不依赖任何外部网络,构建和离线调试都省心。目录我一般这么摆:
project/ index.html css/style.css js/main.js libs/gsap.min.js libs/ScrollTrigger.min.js img/far-sky.webp img/mid-cloud.webp img/near-bush.webpindex.html里按顺序引入,顺序不能反,ScrollTrigger 依赖 GSAP 的全局对象:
<script src="./libs/gsap.min.js"></script> <script src="./libs/ScrollTrigger.min.js"></script> <script src="./js/main.js"></script>main.js的第一行永远是这句,没有例外:
gsap.registerPlugin(ScrollTrigger);注意:忘记
registerPlugin是最常见的"代码看着没错但就是不生效"的原因。表现是控制台报ScrollTrigger is not defined或者动画完全不动,但也不会红屏,特别容易被忽略。我现在养成习惯,把这行写进一个公共的setup.js里,所有页面统一引,杜绝漏写。
2.2 必须吃透的三个坐标
ScrollTrigger 的start和end写起来像黑话,"top bottom"到底是谁的 top 碰到谁的 bottom?我用一句话定义清楚:斜杠前面是"触发元素"的边,后面是"视口"的边。所以:
start: "top bottom"意思是触发元素的顶部,碰到视口的底部——也就是元素刚要从屏幕下边缘露头的那一刻。end: "bottom top"意思是触发元素的底部,碰到视口的顶部——元素完全从屏幕上方离开的那一刻。
这一对组合覆盖了元素完整穿过整个视口的全过程,滚动距离 =元素高度 + 视口高度。如果你的场景块是整屏 100vh,视口也是 100vh,滚动距离就是 200vh。这个数字后面算位移速度要用,先记下来。
另一种常见配法是start: "top top"/end: "bottom bottom",含义是元素顶部贴住视口顶部开始,元素底部贴住视口底部结束,滚动距离 = 元素高度 − 视口高度。这种适合"元素高度远大于视口"的长内容块,比如一个 300vh 的产品介绍区。配错这对组合的直接后果是动画提前结束或者迟迟不开始,很多人以为是scrub的问题,其实根源在start/end。
第三个概念是scrub。它接受true或一个数字。true表示动画进度 1:1 跟随滚动,松手即停;数字表示"用多少秒追上目标进度",比如scrub: 0.6就是带一点惯性的柔和跟随。这个后面单独讲。
2.3 场景容器的基础样式
场景容器是整个视差效果的"取景框"。它必须有明确的尺寸、必须裁掉溢出部分,否则层移动出去会撑出滚动条,页面高度一抖动,ScrollTrigger 的进度就跟着乱。基础样式长这样:
.parallax { position: relative; height: 100vh; overflow: hidden; isolation: isolate; /* 建立独立层叠上下文,避免和外部 z-index 打架 */ }height: 100vh这里有个隐患:移动端浏览器地址栏收起展开会改变vh的实际值。稳妥做法是用min-height: 100svh兜底,或者用 JS 把真实视口高度写进 CSS 变量。我一般是这么干的:
const setVH = () => { document.documentElement.style.setProperty('--vh', window.innerHeight * 0.01 + 'px'); }; setVH(); window.addEventListener('resize', setVH);CSS 里写height: calc(var(--vh, 1vh) * 100)。这样即使地址栏变化,场景高度也是准的。
3. 三层背景视差的核心实现
这部分是全文的重心,我会把 DOM、CSS、位移计算、GSAP 绑定一步一步拆开,每一步都说清楚"为什么这么写"。
3.1 DOM 结构与层级规划
结构上我坚持一个原则:每一层是"容器 + 内层"的两级结构。外层负责定位和裁切,内层负责位移和放素材。为什么要多这一层?因为如果你把图片直接放在绝对定位的外层上做translateY,位移一出界就会露出底下的空白;而多一个内层,你可以给内层设置比外层更大的高度,用多出来的部分当"活动余量"。这是后面计算的物理基础。
<section class="parallax" id="scene-hero"> <div class="parallax__layer">.parallax__layer { position: absolute; inset: 0; will-change: transform; pointer-events: none; /* 各层不拦截鼠标,点击事件留给内容层 */ } .parallax__inner { position: absolute; left: 0; width: 100%; height: 130%; /* 比外层高 30%,多出来的是活动余量 */ top: -15%; /* 向上挪 15%,让多出的部分上下均分 */ background-size: cover; background-position: center; background-repeat: no-repeat; transform: translate3d(0, 0, 0); /* 强制走 GPU 合成层 */ backface-visibility: hidden; } .parallax__inner--far { background-image: url("../img/far-sky.webp"); } .parallax__inner--mid { background-image: url("../img/mid-cloud.webp"); } .parallax__inner--near { background-image: url("../img/near-bush.webp"); } .parallax__content { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 10; pointer-events: auto; }height: 130%+top: -15%这个组合是核心。设外层高度为H,内层高度就是1.3H,内层顶部在-0.15H,底部在1.15H。这意味着内层向下最多可以移动0.15H才会露出顶部空白,向上最多可以移动0.15H才会露出底部空白。内层的安全位移区间是 ±0.15H,也就是整体0.3H的可移动范围。这个数字后面直接喂给 GSAP。
提示:
background-size: cover在层被拉高后可能会放大图片导致模糊。源图建议按"容器宽度 × 1.3 倍高度"来准备,或者干脆用<img>配object-fit: cover。我现在的做法是一律用 img,因为 img 的loading="lazy"和decode()更好控制,图片加载完还能精确触发ScrollTrigger.refresh()。
3.3 位移量的计算过程
纸上算一遍,写代码的时候就不用瞎试了。
已知场景高度H = 100vh,视口高度V = 100vh,start: "top bottom"/end: "bottom top",那么总滚动距离D = H + V = 200vh。
我想让最远景的速度因子是 0.15(也就是滚动 200vh,它只位移 30vh),中景 0.35,前景 0.65。代入公式:
位移量 Δ = D × speed- far:
200vh × 0.15 = 30vh→ 从+15vh到-15vh - mid:
200vh × 0.35 = 70vh→ 从+35vh到-35vh - near:
200vh × 0.65 = 130vh→ 从+65vh到-65vh
远景位移 30vh,正好等于内层的 0.3H 冗余空间,完美卡在不露白的极限。这就是 130% 这个数字的由来——不是随便定的,是反推出来的。如果你把速度调到 0.2,位移就变成 40vh,超出了 30vh 的冗余,滚动到底部会露白。这时候有两个选择:把内层高度加到160%(冗余变成 ±0.3H = ±30vh……等等,要0.2H每侧即 ±20vh,总 40vh,那需要内层高度1 + 0.4 = 140%)。让我重新说清楚这个换算:
若某层的位移量为Δ,则内层需要的额外高度为Δ,即height = (H + Δ) / H × 100%,top = -Δ / (2H) × 100%。
举两个实例:
| 层 | 速度因子 | 位移量 Δ | 内层 height | 内层 top |
|---|---|---|---|---|
| far | 0.15 | 30vh | 130% | -15% |
| mid | 0.35 | 70vh | 170% | -35% |
| near | 0.65 | 130vh | 230% | -65% |
这个表格就是可以直接抄的作业。可以看到 mid 和 near 的内层被拉得很高,背景图会被放大不少。如果图片分辨率不够,这时候就要么降低速度因子,要么改用"元素本身不铺满"的做法——前景层放一棵树、一丛草的 PNG 透明图,只占容器一部分,位移大了也不会露白,因为它本来就不需要覆盖整个画面。
注意:判断"这层需不需要铺满"是选方案的分水岭。铺满的层(天空、雾)必须用 height 冗余法,位移量受限于冗余;不铺满的层(剪影、装饰物)可以随便位移,甚至能移出容器外,靠
overflow: hidden裁掉就行。
3.4 用 GSAP 把滚动进度绑上去
有了速度因子和位移量,JS 就非常短了。核心是把>gsap.registerPlugin(ScrollTrigger); const scene = document.querySelector('#scene-hero'); const layers = gsap.utils.toArray('#scene-hero .parallax__inner'); layers.forEach((layer, i) => { const speed = parseFloat(layer.parentElement.dataset.speed) || 0.15; const travel = 100 * speed; // 200vh 总滚动距离 × speed ÷ 2 = 100 × speed gsap.fromTo( layer, { y: `${travel}vh` }, { y: `${-travel}vh`, ease: 'none', scrollTrigger: { trigger: scene, start: 'top bottom', end: 'bottom top', scrub: true, invalidateOnRefresh: true } } ); });
推导一下travel = 100 × speed是怎么来的:总滚动距离D = 2H(H 是场景高度),总位移Δ = D × speed = 2H × speed,因为内层居中,所以单侧位移是Δ / 2 = H × speed。当H = 100vh时,单侧位移就是100 × speedvh。所以 far 层speed = 0.15→travel = 15vh,从+15vh走到-15vh,总位移 30vh,闭环。
invalidateOnRefresh: true这个属性特别重要。它告诉 ScrollTrigger:窗口尺寸变化重新计算时,把fromTo的起止值重新解析一遍。因为vh会跟着视口变,不加重算就会用旧值,窄屏上就能看到位移量不匹配、露白或者位移过头。
ease: 'none'也是必须的。视差动画的进度是滚动位置线性映射过来的,中间再加缓动会让人觉得"手感发黏",滚动和画面不同步。缓动该加在别的地方(比如内容淡入),不该加在滚动驱动的位移上。
4. 让效果更耐看的几个进阶处理
能跑起来只是及格线,真正拉开质感差距的是下面这些细节。
4.1 scrub 的数值观感差异
scrub: true是硬跟随,滚动条动多少画面就动多少,手指一停立刻静止。这种响应非常"实",适合机械感、工业风的页面。但它有个副作用:鼠标滚轮一格一格滚动时,画面会一跳一跳的,因为滚轮本身是离散事件。
scrub: 0.6到scrub: 1是柔和跟随,动画会用指定秒数"追上"目标进度,产生一点延迟惯性。触控板上特别舒服,滚轮也顺滑很多。代价是快速甩动页面时会有轻微的拖影感。
我的经验取值是0.5 ~ 0.8,这个区间既有顺滑感又不会让人觉得画面"跟不上手"。超过 1.5 就开始明显发飘,低于 0.3 基本等同于true。另外别给不同层设不同的 scrub 值,会让层与层之间产生错位感,看着很怪——要错位靠速度因子错,不靠延迟错。
4.2 内容的次级视差与节奏控制
标题、副标题、按钮这些内容层也可以加一点轻微位移,方向和背景相反(背景向上走,内容向下走一点点),纵深会更立体。位移量要小,5vh 到 10vh 就够,太大了会晕。
gsap.fromTo( '#scene-hero .parallax__content', { y: 0, opacity: 0.4 }, { y: '-8vh', opacity: 1, ease: 'none', scrollTrigger: { trigger: scene, start: 'top bottom', end: 'center center', scrub: 0.6 } } );注意这里的end用的是"center center",也就是场景中心碰到视口中心就结束。内容层的动画区间应该比背景层短,因为内容在场景中心就位之后就不该再动了,再动会显得浮。这种"背景走全程、内容走半程"的节奏差,是让整个场景不呆板的关键。
4.3 响应式与移动端的降级策略
移动端视口小、GPU 弱、地址栏还会变高。三个处理必须做:
第一,用ignoreMobileResize。ScrollTrigger 有个全局配置,开启后移动端地址栏收起展开引发的 resize 不会触发重新计算,避免滚动到一半进度突然跳一下:
ScrollTrigger.config({ ignoreMobileResize: true });第二,小屏降低层数和位移量。三层在 375px 宽的屏幕上根本看不出纵深,反而增加负担。用gsap.matchMedia()做条件分支,窄屏只保留两层、速度因子砍半:
const mm = gsap.matchMedia(); mm.add('(min-width: 768px)', () => { // 桌面:三层全开,全速 initLayers({ far: 0.15, mid: 0.35, near: 0.65 }); return () => {}; // 清理函数,切换断点时会自动调用 }); mm.add('(max-width: 767px)', () => { // 移动:只开远、近两层,速度减半 initLayers({ far: 0.08, mid: 0, near: 0.3 }); return () => {}; });gsap.matchMedia()的好处是切断点时会自动执行 cleanup 并且把该断点内建的 ScrollTrigger 全部销毁,不用自己手动kill()。这比写window.matchMedia监听 + 手动清理省心太多。
第三,准备降级兜底。检测prefers-reduced-motion,用户开了"减少动态效果"就完全跳过视差,只留静态图:
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (reduceMotion) { // 直接 return,不做任何绑定 } else { // 正常初始化 }5. 性能、刷新与踩坑实录
这段是我最想写的部分,因为文档里基本不会提,全是真金白银试出来的。
5.1 性能清单
视差本身不重,重的是错误用法。按重要程度排:
- 只动
transform和opacity。这两者走合成层,不触发布局和绘制。任何对top、left、background-position、width的动画都会强制重排重绘,帧率立刻掉到 30 以下。 - 慎用
will-change。它能提示浏览器提前建层,但给每一层都常驻will-change: transform会占用大量显存,低端机反而更卡。我的做法是只在动画区间内加,结束后移除;或者干脆不加,靠translate3d的 3D 变换强制提升图层。 - 背景图控制体积。三层 1920px 宽的图,不压缩的话随随便便几兆。WebP 或 AVIF,质量 75 左右,配合响应式
srcset,这步省下来的时间比任何 JS 优化都值。 - 别在 ScrollTrigger 回调里做重活。
onUpdate里如果每帧都读offsetHeight、改 DOM 结构,会触发强制同步布局。要读缓存的几何信息,用ScrollTrigger.refresh()统一重算。
5.2 问题速查表
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 完全没动效 | 忘了registerPlugin,或脚本加载顺序反了 | 检查控制台,确认 ScrollTrigger 在 GSAP 之后引入 |
| 一进页面动画就播完了 | start/end配错,或触发元素高度为 0 | 用ScrollTrigger.getAll()打印配置,检查 start/end |
| 滚动到底部背景露白 | 位移量超过内层冗余高度 | 按 3.3 的公式重算内层 height 和 top |
| 页面高度莫名其妙变高 | 层的位移撑出了容器,或漏写overflow: hidden | 给场景容器加裁切 |
| 窗口缩放后位置全乱 | 没加invalidateOnRefresh | 补上该属性,或监听 resize 手动 refresh |
| 图片加载完后位置偏了 | 图片加载改变了布局,ScrollTrigger 没重算 | 在图片onload里调ScrollTrigger.refresh() |
| 移动端滚到一半跳一下 | 地址栏展开收起触发 resize | ScrollTrigger.config({ ignoreMobileResize: true }) |
| 加了 pin 之后后面的内容重叠 | pinSpacing被关掉或容器高度写死 | 保留默认pinSpacing: true,别给容器固定高度 |
| 滚动时明显掉帧 | 动画属性不是 transform,或层数过多 | 换成y位移,减少同时动画的层数 |
5.3 我踩过的几个坑
坑一:vh单位和内层高度的恶性循环。我最早把内层高度写成height: calc(100% + 30vh),结果在某些浏览器里100%解析依赖父级高度,父级又是100vh,再加上移动端地址栏变化,算出来的值来回跳。后来统一改成纯百分比130%/top: -15%,全部相对容器,稳定得多。结论:层内几何尽量用百分比,只在最外层用 vh。
坑二:图片没加载完就初始化。首屏背景图是懒加载的,JS 执行时图还没解码,容器高度是按占位算的,ScrollTrigger 拿到的 start/end 是错的。滚动到一半图加载完,位置突然跳一下。修复很简单,等图 load 完再 refresh:
const imgs = [...document.querySelectorAll('.parallax__inner')].map(el => { const m = el.style.backgroundImage.match(/url\("?(.+?)"?\)/); return m ? m[1] : null; }).filter(Boolean); Promise.all(imgs.map(src => new Promise(resolve => { const img = new Image(); img.onload = img.onerror = resolve; img.src = src; }))).then(() => ScrollTrigger.refresh());坑三:把scrub写成字符串。scrub: '0.6'带引号,ScrollTrigger 会把它当成 truthy 的字符串,实际按true处理,你以为有惯性其实没有。这种错最恶心,因为不报错、效果也"差不多",只有对比着看才发现不对。
坑四:pin和视差一起用时的顺序问题。如果场景里有需要 pin 住的元素,pin 的 ScrollTrigger 要先于视差的创建,否则 pin 产生的 spacer 会改变布局高度,导致后面创建的那些 trigger 的 start/end 全部算错。规则是:先 pin,再位移,最后内容。
提示:调试期间在 ScrollTrigger 配置里加
markers: true,页面上会画出 start/end 的标线,一眼就能看出区间对不对。上线前记得全局搜一遍删干净,我因为忘了删被测试提过一次缺陷单。
6. 把视差场景封装成可复用组件
一个页面里通常有三到四个视差区,复制粘贴三份 JS 是最差的做法,改一个参数要动三处。
6.1 data 驱动的配置方式
思路是把所有可变量都外置到 DOM 上:速度、方向、是否铺满、缓动感。JS 只负责读配置、套公式、建 trigger。
function initParallaxScene(sceneEl) { const layers = sceneEl.querySelectorAll('.parallax__inner'); const sceneH = sceneEl.offsetHeight; const viewportH = window.innerHeight; const scrollDist = sceneH + viewportH; layers.forEach(layer => { const cfg = layer.parentElement.dataset; const speed = parseFloat(cfg.speed) || 0.15; const from = parseFloat(cfg.from) || speed; const to = parseFloat(cfg.to) || -speed; const scrub = parseFloat(cfg.scrub) || true; // 单侧位移 = 总滚动距离 × 速度因子 ÷ 2 const travel = (scrollDist * speed) / 2; gsap.fromTo( layer, { y: from * travel }, { y: to * travel, ease: 'none', scrollTrigger: { trigger: sceneEl, start: cfg.start || 'top bottom', end: cfg.end || 'bottom top', scrub: scrub, invalidateOnRefresh: true } } ); }); } // 批量初始化 gsap.utils.toArray('.parallax').forEach(initParallaxScene);注意我把位移量从vh改成了像素,用scrollDist实时算。这样不管场景是 100vh 还是 80vh,公式都成立,不用再关心vh和百分比之间的换算。这是封装时的一个必要改动——组件化的第一原则是"不依赖调用方的具体尺寸"。
6.2 封装后的调用方式与扩展
调用就退化成了写 HTML:
<section class="parallax"> <div class="parallax__layer">CFileDialog 模板嵌入自定义控件:机制、实现与排查
1. 先搞清楚 CFileDialog 到底封装了什么1.1 这个需求为什么会反复出现做桌面工具类软件的同行大概都遇到过类似的场景:用户点了“打开文件”,弹出来的标准对话框里只有文件名、文件类型、几个常用按钮,但你偏偏还想在这个窗口里塞一个“以只…
Redis字符串与哈希类型核心解析及实战应用
1. Redis字符串类型深度解析Redis的String类型是最基础也是最常用的数据类型,它不仅仅是简单的键值存储,而是提供了丰富的操作能力。在实际项目中,我经常用它来实现计数器、缓存对象、分布式锁等功能。1.1 String类型的底层实现Redis的String…
bridge-utils 1.0.4-rc3 源码编译与内核桥接原理详解
简介:本资源是Linux网络桥接核心工具brctl的官方源码包bridge-utils-1.0.4-rc3,面向系统运维工程师、虚拟化平台开发者及Linux网络进阶学习者,用于编译安装和深度理解内核桥接子系统的控制机制。包内共47个文件,涵盖8个C源码&…
Android系统镜像底层工具链:sparse格式解析与ext4定制打包
简介:本资源是一套开箱即用的Android系统镜像处理工具集,专为ROM开发者、系统定制工程师及嵌入式调试人员设计,解决日常解包、打包、稀疏镜像转换与ext4文件系统构建等核心需求。压缩包共含4个已编译可执行文件:img2simgÿ…
企业私域大模型失控原因与解决方案
1. 私域大模型失控现象解析最近半年,我走访了23家部署了内部大模型的企业,发现一个惊人的共同点:超过80%的私域大模型项目都出现了不同程度的"失控"现象。这些企业投入数百万构建的AI系统,最终要么沦为昂贵的聊天机器人…
营销技能不是工具包,而是可验证的商业操作系统
1. 这不是“学个PPT就能搞定”的营销技能——它是一套可测量、可拆解、可复盘的商业操作系统“marketingskills”这个词最近在招聘平台、自由职业接单社区和职场学习社群里高频出现,但它绝不是简历上一句轻飘飘的“熟悉新媒体运营”或“会做海报写文案”能覆盖的。我…