
视差滚动这个效果说穿了其实一句话就能讲明白让不同层级的内容以不同的速度滚动制造出一种“立体”的视错觉。但就是这层窗户纸很多人捅了很久也捅不透。我第一次做视差滚动的时候满脑子都是“这玩意儿到底怎么跟滚动事件关联”后来才发现核心思路远没有想象中复杂——你只需要三个变量滚动距离、每个层的速度系数、以及层本身的定位方式。这篇东西我不打算讲那些花里胡哨的库也不打算一上来就甩出一堆炫技代码。我会把视差滚动的原理、纯CSS玩法、JavaScript手写方案、以及开发中一定会踩的坑全部拆开揉碎讲清楚。不管你是刚入行的前端新手还是在做品牌站、专题页、产品展示页时需要给页面加点质感的老手这篇都能直接拿来用。1. 视差滚动的核心逻辑先理解“为什么”再纠结“怎么写”1.1 视差效果产生的底层原理视差Parallax这个词最早其实来自天文学——因为观察位置不同近处恒星和远处恒星在视觉上产生的相对位移差异。我们在网页里做的视差滚动就是把这个天文现象搬到了浏览器里。想象一下你坐在火车上窗外的电线杆“嗖嗖”往后飞远处的小山包却慢悠悠地移动。这中间的速度差就是视差的本质。网页里的视差滚动原理完全一样背景层滚动的速度比前景层慢你就会觉得背景在“更深”的地方。用代码来描述的话就是滚动事件触发时浏览器会告诉我们“用户滚了多少像素”window.scrollY。我们把这个数值乘以每个层的速度系数比如背景层0.3前景层1.2。把计算出的位移值实时应用到层对应的元素上通常是transform: translate3d()因为它在合成阶段处理不会触发重排和重绘。公式就是层位移 页面滚动距离 × 速度系数就这么简单。所有视差插件、库、框架不管外面套了多少层壳核心永远是这个公式。1.2 三种主流的实现方案选型实现视差滚动有三大类玩法我按照“技术含量从低到高”给你排个序实现方案核心依赖适用场景性能表现上手难度CSSbackground-attachment: fixed纯CSS单图背景视差一般最低纯CSS 3D transformtranslateZperspective纯CSS内容分层滚动优秀中等JavaScript scroll事件JS自定义任意层速度取决于写法较高GSAP ScrollTrigger或同类库第三方库复杂场景、进入时动画优秀中等不是所有场景都值得你引入GSAP这种重库。如果你只是想给Hero区的背景图加一点滚动速度差一个background-attachment: fixed就搞定但如果你的页面有多个连续的视差区每个区里的内容还不一样那JS方案跑不掉。我的习惯是先问自己“我要几层、每层干什么”再决定用哪种。99%的情况下纯CSS加一点原生JS就绰绰有余。2. 纯CSS实现代码量最少但要清楚它的边界在哪2.1 background-attachment fixed的经典玩法与致命短板先写一个最简单的。HTML长这样section classhero h1这是一段视差背景/h1 /section section classcontent p正常滚动的正文内容。/p /sectionCSS这样写.hero { min-height: 80vh; background-image: url(your-image.jpg); background-size: cover; background-attachment: fixed; background-position: center; }效果就是当用户往下滚动.hero背景图片不跟随元素移动而是固定在视口上直到元素离开视口背景才“撕裂”离开。光这一个属性就能让背景产生一种“后面还有一层空间”的错觉。但这条路的坑非常明显。iOS Safari 从发布到 15.2 之前一直不支持background-attachment: fixed在移动端的正常工作——只要你是触摸滑动这个属性就会被当成scroll处理图片会跟着页面一起滚没有任何视差效果。Android 老版本 Chrome 也偶发。你辛辛苦苦做的效果在用户手机上直接失效。所以现在我做项目几乎不用这个方案做全屏背景视差。除非目标用户明确是桌面端或者搭配移动端降级策略通过媒体查询在移动端强制改成background-attachment: scroll。2.2 真正优雅的纯CSS视差translateZ 配合 perspective另一个纯CSS玩法是利用3D transform中perspective透视和translateZZ轴位移的天然属性。关键点在同一个3D渲染上下文里同样一个滚动量离视点越远的元素其在屏幕上的视觉位移就越小。这就天然形成了视差。写一下div classparallax-container div classlayer layer-1背景层/div div classlayer layer-2中景层/div div classlayer layer-3前景层/div /div.parallax-container { perspective: 1px; height: 100vh; overflow-y: auto; overflow-x: hidden; } .layer { position: absolute; top: 0; left: 0; right: 0; bottom: 0; } .layer-1 { transform: translateZ(-3px) scale(4); } .layer-2 { transform: translateZ(-2px) scale(3); } .layer-3 { transform: translateZ(-1px) scale(2); }为什么背景层要加scale因为translateZ(-3px)会把元素缩小视觉上离远了你需要用scale把它放大回原来的尺寸。有一个标准公式scale 1 (-translateZ) / perspective在这个例子里perspective: 1pxtranslateZ(-3px)所以scale 1 3/1 4。这套方案真正的优势是完全不需要监听滚动事件浏览器自己就在合成阶段算好了所有层的偏移滚动力度、惯性、还弹全部白给。性能上极度省心。但它的缺点也明显层级一变多每个层的位置同步、缩放系数计算维护起来头大而且perspective: 1px这种写法对新手来说很不直观你很难一眼看出来当前层的速度差到底是多少。它适合“一眼就能看到效果”的场景但如果你需要精细控制每个层在不同滚动阶段的位移曲线还是得靠JS。3. JavaScript手写方案把命运攥在自己手里的核心代码3.1 最简化版本监听滚动计算位移如果纯CSS满足不了你的精细化控制需求那就上JS。先来一个最小可用版本不依赖任何库。div classsection section-bg>.section { position: relative; min-height: 70vh; display: flex; align-items: center; justify-content: center; overflow: hidden; } .section-bg { background: url(bg.jpg) no-repeat center / cover; } .parallax-el { will-change: transform; }const parallaxEls document.querySelectorAll([data-speed]); function updateParallax() { const viewportCenter window.innerHeight / 2; parallaxEls.forEach((el) { const rect el.getBoundingClientRect(); const elementCenter rect.top rect.height / 2; // 元素中心相对视口中心的偏移量 const offsetFromCenter viewportCenter - elementCenter; const speed parseFloat(el.dataset.speed) || 0.3; // 核心位移公式 const translateY offsetFromCenter * speed; el.style.transform translate3d(0, ${translateY}px, 0); }); } window.addEventListener(scroll, () { updateParallax(); }, { passive: true }); updateParallax();这里解释一下offsetFromCenter的算法逻辑我们希望视口中心的那个元素刚好不动而偏离视口中心的元素根据速度系数产生不同位移。所以先算出每个元素中心与视口中心的差再乘上速度系数就得到它的偏移量。speed 0.3的层会以较慢速度滚动speed 1.3的层会超过普通滚动速度往另一方向掠过去。这就是“近快远慢”的最标准实现。3.2 性能优化不用防抖节流用浏览器自带机制很多人一看到滚动监听第一反应是“要加节流、加防抖”。这个想法本身没错但是在实现上很多人的做法是加setTimeout、加requestAnimationFrame节流其实都不太对。滚动事件触发的频率跟浏览器的帧率通常是60帧/秒并不一致。也就是说滚动事件每秒可能触发上百次但你屏幕呈现画面的能力只有60帧你算再多次浏览器也只能画60帧。正确的姿势是用requestAnimationFrame来“收集”最新的滚动值在每个绘制帧内只计算一次。不过requestAnimationFrame还不够彻底。更优雅的方案是直接不监听滚动事件本身而是用一种叫做“合成线程驱动”的方式来处理。这里我不展开讲得太深但给你一个工业级写法的简化版let ticking false; function onScroll() { if (!ticking) { requestAnimationFrame(updateParallax); ticking true; } } function updateParallax() { // 上面那段计算逻辑 ticking false; } window.addEventListener(scroll, onScroll, { passive: true });用ticking作为锁保证一次动画帧内只计算一次。这个写法比在外面包一层lodash.throttle(fn, 100)要合理得多——因为节流是固定时间间隔可能会在一帧内执行多次也可能会在两次帧之间错过最新的滚动位置而rAF ticking跟浏览器绘制频率完全同步不多算也不少算。另外把translate3d用在位移上是因为它能让浏览器把这个元素丢给GPU独立合成不会因为改了transform就导致整个页面重新排版。这是视差性能的关键不能省。3.3 让滚动位移更高级起始位置、结束位置的精细控制上面的版本有一个问题它只做了“跟随式”视差——元素在滚动全程都在动加起来的效果其实跟纯CSS方案差不多。但很多时候我们要的是“某个元素进入视口时才动离开视口前结束”。比如一个产品展示区图片刚开始进入视口时是模糊的滚到正中间时变清晰继续滚又淡出。这种“进入式”动画光靠速度系数就不够了。你需要引入另一个概念滚动进度progress。代码如下function updateParallaxWithProgress() { parallaxEls.forEach((el) { const rect el.getBoundingClientRect(); const viewportHeight window.innerHeight; // 元素进入视口的进度0表示刚露出1表示完全离开 const progress 1 - (rect.top rect.height) / (viewportHeight rect.height); const clampedProgress Math.min(Math.max(progress, 0), 1); // 位移距离可以从 0 到 el.dataset.distance const distance parseFloat(el.dataset.distance) || 100; const translateY clampedProgress * distance; el.style.transform translate3d(0, ${translateY}px, 0); }); }这个写法的好处是你可以在元素的全生命周期里精确地控制它“从哪开始动、到哪结束”。比如你想让一张图片在滚动经过屏幕的三分之二处开始上移就可以在progress上加一个偏移量。这种精细控制纯CSS做不到GSAP也是在这个基础上封装的。4. 不想手写GSAP ScrollTrigger 是效率利器但它没替你写原理4.1 ScrollTrigger 的三个必会配置参数GSAPGreenSock Animation Platform加上它的官方滚动插件 ScrollTrigger是当下做复杂滚动视觉的主流选择。很多大厂的获奖网站都在用这套组合。我个人的态度是如果项目里已经有了GSAP那就用它如果为了一个视差专门引入GSAP大约几十KB那得掂量掂量。但不管用不用理解它的核心参数对你写任何方案都有帮助。最核心的三个参数trigger指定滚动到什么元素时触发动画。start/end从哪里开始到哪里结束。scrub是否把动画和滚动进度关联而不是按时间播放。基础写法gsap.registerPlugin(ScrollTrigger); gsap.to(.parallax-fg, { y: -200, ease: none, scrollTrigger: { trigger: .section-fg, start: top bottom, // 元素顶部的确没有再和视口底部交汇的那一瞬 end: bottom top, // 元素底部触碰到视口顶部的那一瞬 scrub: true // 让动画进度跟着滚动条走 } });解释一下start: top bottom第一个词是触发元素的位置这里是元素顶部第二个词是视口的位置这里是视口底部。合起来就是“当元素顶部碰到视口底部时开始播放动画”。end: bottom top同理“元素底部碰到视口顶部时播放结束”。这两个值一旦定好动画就跟滚动手势牢牢绑定特别顺滑。4.2 配合ScrollTrigger实现多阶段视差ScrollTrigger 还有几个有意思的用法。比如你想让背景层在元素滚动经过的前半段以0.3倍速滚动后半段以0.8倍速滚动。你可以写两个动画放进同一个timelineconst tl gsap.timeline({ scrollTrigger: { trigger: .long-section, start: top top, end: bottom bottom, scrub: true } }); tl.fromTo(.layer-bg, { yPercent: 0 }, { yPercent: 30, duration: 1, ease: linear }) .fromTo(.layer-bg, { yPercent: 30 }, { yPercent: 80, duration: 1, ease: linear });但注意用了时间轴后动画的控制权从“页面滚动”转移到“动画时间”你需要额外处理“时长”和“滚动距离”之间的换算。大多数场景我并不建议把时间长度的概念引入滚动动画——除非你需要做线性的多阶段组合否则用多个独立的scrollTrigger会让代码更可控。GSAP 最大的价值其实是用它的缓动函数ease和动画管理机制让你写出“物理感”更强的滚动。但这也意味着你在用GSAP时绕不开对滚动原理的掌握。别指望一个插件就自动出效果。5. 从“跑通”到“好用”常见问题、体验设计与性能清单5.1 移动端失效与降级的处理实录移动端的视差问题是所有方案的通病。我之前接的一个品牌专题页在iOS上测试背景层完全不动排查了半天最后定位到是background-attachment: fixed的锅。后来改成 JS transform 方案Android上又出现了滚动到底部时图层“卡顿回弹”的问题——原因是移动端的滚动在松开手指后还有一个惯性滑行的阶段而scroll事件在惯性阶段并不会持续触发。最后解决方案是在移动端干脆禁用大位移的视差只保留轻微位移速度系数控制在0.2以内并且把will-change属性去掉减少GPU内存占用。这个降级逻辑可以用一个简单的宽度判断const isMobile window.matchMedia((max-width: 768px)).matches; if (isMobile) { parallaxEls.forEach(el { el.style.transform none; }); }5.2 会让视觉违和的“图层错位”与“边缘露出”视差滚动最怕的就是“要露不露”。背景层在位移过程中如果容器高度不够露出了下面一层的底色或空白整个视差感瞬间崩塌。处理办法有两个在背景层外面包一层容器高度比内容层多出20%30%再配合绝对定位让背景层填满容器。这样背景层再怎么动也不至于穿帮。使用background-size或object-fit让背景内容比容器大一圈oversize。图片本来就比看得见的区域大位移过程中会像看窗外的风景怎么动窗户里都有画面。这个细节是我每次做视差都会在代码里预设的“默认安全值”。5.3 性能优化与体验设计清单做视差滚动本质上是让浏览器每帧都在做额外的合成计算。我用实际项目经验担保下面这些点你最好一开始就注意否则后期优化会想哭场景遇到的问题排查与解决方案背景图模糊大尺寸背景图位移时出现抖动、模糊给背景图加transform: translateZ(0)强制走GPU合成图片输出为WebP尺寸不超过容器2倍滚动卡顿CPU飙升动画过程中一直在触发重排layout只使用transform和opacity避免改动top/left/margin用DevTools Performance面板抓取长任务滚动后元素错位JS计算值和CSS布局不同步在resize事件中重新计算getBoundingClientRect()不要缓存元素的offsetTop不更新除非你确定布局不变页面加载时闪烁初始渲染时JS还没算完把非视口层的偏移设为默认值JS只在scroll里覆盖它设置visibility或opacity避免首次绘制炸裂操作习惯不兼容用户开着“减少动态效果”辅助功能用prefers-reduced-motion: reduce媒体查询直接禁用所有滚动视差回归正常的滚动页面另外还有一个体验层面的大坑视差滚动应该“锦上添花”而不是“内容主角”。如果你的产品文案、核心数据必须通过视差滚动才能看到你就是在拿用户的操作成本做赌注。真正常的做法是让用户在完全关闭视差的情况下依然可以无障碍地阅读到页面上的所有信息。5.4 几个快速上手的现成代码片段再分享两个我在实战中积累的小片段。第一个是带“缓入”效果的单元素进入式视差gsap.utils.toArray([data-parallax]).forEach((el) { const speed parseFloat(el.dataset.parallaxSpeed) || 0.4; const distance parseFloat(el.dataset.parallaxDistance) || 120; gsap.fromTo(el, { y: distance }, { y: 0, ease: none, scrollTrigger: { trigger: el, start: top bottom, end: top center, scrub: true } } ); });第二个是处理“图片模糊”问题的通用CSS.parallax-img { transform: translateZ(0); backface-visibility: hidden; filter: blur(0); }这个组合能让背景图在GPU合成时更稳定减少视觉上模糊、撕裂的概率。不要小看这三行在老旧安卓机上它们的价值等同于救命。从我这几年的实践经验来看做视差滚动最怕的不是“做不出来”而是“做出来却收不住”。它的表现力太强了容易让人随手就在页面上堆一堆动效最后用户一滚屏满屏都是元素在飞信息主次全乱。现在我做这类效果给自己定了几条死规矩每个页面最多三个视差层视差的速度系数控制在0.2到1.3之间不做满屏大图的“无意义位移”所有重要内容必须脱离视差效果也能独立阅读。守住这些底线视差滚动才能真正变成一个让页面“有层次”的工具而不是一个炫技的摆设。