原生JS滚动渐变导航栏:scroll节流与IntersectionObserver实战
简介这是一份面向前端初学者与进阶练习者的滚动渐变导航栏实战小实例围绕HTML5、CSS3与JavaScript三项核心技术展开帮助读者理解页面滚动时导航栏背景色、透明度与视觉层次如何随滚动位置平滑过渡。资源包共5个文件包含1个html页面、1个css样式表、2张jpg图片素材与1个gif动效演示压缩包约1.48MB结构精简便于直接运行与二次修改。已有156人学习下载适合作为前端交互练习、课程作业或组件封装参考。读者可从中获取完整的页面骨架、样式组织方式与滚动监听逻辑观察CSS过渡与JS事件配合的细节并借助图片与动效素材快速还原效果进而迁移到个人项目或企业官网的导航模块中。1. 滚动渐变导航栏一个被低估的交互细节藏着三个技术决策很多前端页面顶部那条导航栏静止时半透明、滚动后变实心加阴影看起来只是「好看一点」。但真到自己动手写你会发现三个绕不开的问题滚动事件触发频率太高导致掉帧、渐变过程生硬像开关、移动端滚动方向判断不准。这个 HTML5CSS3JS 小实例要解决的就是把这三点一次性处理干净。它适合谁刚学完 CSS 定位和 JS 事件、想找一个「麻雀虽小五脏俱全」的练手项目的人也适合做了几年页面、但导航栏一直靠 UI 库现成组件、想搞清楚底层怎么实现的人。整个实例不依赖任何框架纯原生三件套代码量控制在百行以内但涉及scroll事件节流、classList状态切换、requestAnimationFrame调度这几个高频考点。下面按「结构怎么搭 → 渐变怎么算 → 坑在哪」的顺序拆开讲。2. 结构先行HTML 骨架与 CSS 过渡的配合逻辑2.1 导航栏的 DOM 结构为什么越简单越好滚动渐变导航栏最容易翻车的地方不在 JS而在 HTML 结构设计。很多人习惯把 logo、菜单、搜索框、用户头像全塞进一个nav里然后用绝对定位各自摆放。这种写法在静态时没问题一旦要加滚动渐变就会遇到「背景色变化时子元素层级错乱」的玄学问题。我一般会采用两层结构外层header负责背景、阴影、高度变化内层div classnav-inner负责内容布局。这样渐变只作用在外层内层内容不受影响。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title滚动渐变导航栏/title link relstylesheet hrefstyle.css /head body !-- 外层承载背景与阴影的渐变 -- header classsite-header idsiteHeader div classnav-inner a href# classlogoBRAND/a ul classnav-menu lia href#首页/a/li lia href#文档/a/li lia href#示例/a/li lia href#关于/a/li /ul /div /header !-- 占位内容制造滚动空间 -- main classpage-content section classhero向下滚动查看导航栏变化/section section classfiller/section section classfiller/section /main script srcmain.js/script /body /html这段结构的关键决策是header用position: fixed固定在顶部main通过padding-top或占位元素避免被遮挡。nav-inner只做水平排列不参与任何渐变逻辑。这样 JS 只需要操作siteHeader一个元素的 class职责边界清晰。参数说明idsiteHeader是 JS 的唯一钩子不要用querySelector(header)这种宽泛选择器页面里如果有多个 header 会选错。nav-menu用ul而非div包裹是为了保留列表语义对 SEO 和屏幕阅读器都更友好。2.2 CSS 过渡为什么用 transition 而不是 animation渐变效果有两种实现路径CSStransition和 CSSanimation。前者是状态驱动后者是时间轴驱动。滚动渐变属于「根据滚动位置切换状态」天然适合transition。/* 基础重置 */ * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, Segoe UI, PingFang SC, sans-serif; background: #f5f6f8; } /* 导航栏默认态透明背景 */ .site-header { position: fixed; top: 0; left: 0; width: 100%; height: 64px; background: transparent; box-shadow: none; /* 关键过渡属性只写会变化的避免全属性过渡拖性能 */ transition: background-color 0.3s ease, box-shadow 0.3s ease, height 0.3s ease; z-index: 1000; } /* 滚动后态实心背景 阴影 */ .site-header.is-scrolled { background: rgba(255, 255, 255, 0.96); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); height: 56px; } .nav-inner { max-width: 1200px; height: 100%; margin: 0 auto; padding: 0 24px; display: flex; align-items: center; justify-content: space-between; } .logo { font-size: 20px; font-weight: 700; color: #1a1a1a; text-decoration: none; letter-spacing: 1px; } .nav-menu { display: flex; gap: 32px; list-style: none; } .nav-menu a { font-size: 15px; color: #333; text-decoration: none; transition: color 0.2s; } .nav-menu a:hover { color: #0066ff; } /* 占位内容 */ .hero { height: 100vh; display: flex; align-items: center; justify-content: center; font-size: 24px; color: #666; background: linear-gradient(135deg, #e0e7ff, #f0f4ff); } .filler { height: 80vh; background: #fff; margin: 24px; border-radius: 12px; }这里有几个参数值得说清楚。transition只列了background-color、box-shadow、height三个属性没有用all。用all在简单页面里看不出问题但一旦导航栏里有子元素做动画all会触发不必要的重绘。ease是默认缓动实际项目中如果想让渐变更「跟手」可以换成cubic-bezier(0.4, 0, 0.2, 1)这是 Material Design 常用的标准曲线。is-scrolled这个 class 名不是随便起的。用is-前缀是 BEM 变体命名的常见约定好处是 JS 里classList.toggle(is-scrolled, condition)一眼能看出是状态类不会和布局类混淆。提示background用rgba而非#fff是为了保留一点透明度滚动时底层内容若隐若现视觉上比纯实心更透气。如果设计要求完全不透明直接写#fff即可但过渡会更「硬」。3. JS 滚动监听从 scroll 事件到 requestAnimationFrame 的调度3.1 最朴素的写法为什么会在低端机上掉帧先看最直觉的实现// 版本一直接监听 scroll不节流 const header document.getElementById(siteHeader); window.addEventListener(scroll, () { if (window.scrollY 50) { header.classList.add(is-scrolled); } else { header.classList.remove(is-scrolled); } });这段代码逻辑没错但scroll事件在移动端和触控板上触发频率极高一秒可以到 60 次以上。每次触发都执行classList操作虽然浏览器对重复添加同一个 class 有优化但scrollY读取会强制触发布局计算reflow。在低端安卓机上快速滚动时导航栏渐变会明显滞后甚至出现「闪一下才变」的现象。这就是典型的 scroll 性能坑。常见做法是用节流throttle把执行频率降到 100ms 一次但节流有个副作用渐变会「跳」因为状态切换被延迟了。3.2 用 requestAnimationFrame 做帧对齐更好的方案是用requestAnimationFramerAF做调度。rAF 的回调在浏览器下一次重绘前执行天然和屏幕刷新率对齐不会多执行也不会少执行。// 版本二rAF 调度 状态缓存 const header document.getElementById(siteHeader); const THRESHOLD 50; // 触发渐变的滚动阈值单位 px let ticking false; // 帧锁防止同一帧内重复调度 let isScrolled false; // 缓存当前状态避免重复操作 DOM function updateHeader() { const shouldScroll window.scrollY THRESHOLD; // 只有状态真正变化时才操作 DOM if (shouldScroll ! isScrolled) { isScrolled shouldScroll; header.classList.toggle(is-scrolled, isScrolled); } ticking false; // 释放帧锁 } window.addEventListener(scroll, () { if (!ticking) { ticking true; window.requestAnimationFrame(updateHeader); } }, { passive: true });这段代码有三个关键点。第一ticking帧锁保证一帧内只调度一次updateHeader即使scroll触发了十次也只会执行一次。第二isScrolled缓存了上一次的状态只有shouldScroll和它不一致时才碰 DOM。这比每次无脑classList.add/remove省掉了大量无意义的样式计算。第三{ passive: true }告诉浏览器这个监听器不会调用preventDefault()浏览器可以立即滚动而不等待 JS 执行移动端滚动流畅度提升明显。参数说明THRESHOLD 50是经验值。太小比如 10会导致轻微滚动就触发导航栏频繁闪烁太大比如 200会让用户觉得「滚了半天才变」。50 到 80 之间是比较舒服的区间具体看页面首屏高度。如果首屏是满屏 hero 图可以设到 80如果首屏内容紧凑50 就够。注意window.scrollY在 IE 上不兼容需要window.pageYOffset兜底。虽然现在 IE 基本退场但如果项目要求兼容旧环境写成window.scrollY || window.pageYOffset更稳妥。3.3 滚动方向判断向上滚和向下滚要不要区别对待有些设计会在向下滚动时隐藏导航栏、向上滚动时显示这需要判断滚动方向。实现思路是缓存上一次的scrollY和当前值比较。// 版本三带方向判断的扩展 let lastScrollY window.scrollY; let ticking false; function updateHeaderWithDirection() { const currentScrollY window.scrollY; const scrollingDown currentScrollY lastScrollY; const shouldHide scrollingDown currentScrollY 200; header.classList.toggle(is-hidden, shouldHide); header.classList.toggle(is-scrolled, currentScrollY 50); lastScrollY currentScrollY; ticking false; } window.addEventListener(scroll, () { if (!ticking) { ticking true; window.requestAnimationFrame(updateHeaderWithDirection); } }, { passive: true });对应的 CSS 加一条.site-header.is-hidden { transform: translateY(-100%); }这里transform比改top性能好因为transform不触发 reflow只触发 composite。is-hidden和is-scrolled是两个独立状态可以同时存在向下滚且超过 200px 时隐藏向上滚时显示但保持实心背景。这个逻辑在移动端阅读类站点很常见用户往下看内容时导航栏让位想返回顶部时上滑一下就出来。参数说明200是隐藏阈值比渐变阈值大目的是避免刚滚动一点就隐藏、用户还没进入阅读状态就找不到导航。这个值一般设为一屏高度的 1/4 到 1/3。4. 避坑与排查滚动渐变导航栏的五个血泪经验4.1 现象导航栏渐变时内容「抖一下」原因height从 64px 变到 56px如果main的padding-top写死 64px导航栏变矮后内容不会跟着上移但视觉上导航栏底部和内容之间会多出 8px 空隙看起来像抖了一下。解决要么保持高度不变只改背景和阴影要么用 CSS 变量统一管理高度。我一般会保持高度不变因为高度变化对布局影响最大收益却最小。:root { --header-height: 64px; } .site-header { height: var(--header-height); } .page-content { padding-top: var(--header-height); }4.2 现象移动端滚动时导航栏渐变延迟半秒原因scroll监听器没有加{ passive: true }浏览器需要等 JS 执行完才滚动造成「滚动被 JS 拖住」的错觉。解决所有不调用preventDefault()的滚动、触摸监听器都加上passive: true。这是移动端性能优化里性价比最高的一行代码。4.3 现象页面刷新后导航栏状态和滚动位置不匹配原因浏览器刷新时会恢复滚动位置但scroll事件不会在页面加载时自动触发导致导航栏停留在默认透明态而页面已经滚到中间。解决在 JS 初始化时手动调用一次更新函数。// 页面加载完成后同步一次状态 window.addEventListener(load, () { updateHeader(); });如果用了DOMContentLoaded注意此时scrollY可能还没恢复用load更稳。4.4 现象classList.toggle第二个参数在旧浏览器不生效原因classList.toggle(name, force)的双参数形式在 IE11 上不支持会忽略第二个参数导致状态切换逻辑失效。解决如果必须兼容 IE改成手动判断。if (shouldScroll) { header.classList.add(is-scrolled); } else { header.classList.remove(is-scrolled); }现代项目直接忽略这条但接手老项目时要留意。4.5 现象多个滚动监听器互相干扰原因页面里可能同时有「回到顶部按钮」「懒加载图片」「导航栏渐变」三个 scroll 监听器各自独立执行滚动时一帧内触发三次回调。解决合并成一个 scroll 监听器在同一个 rAF 回调里处理所有逻辑。这是架构层面的优化比单独优化每个监听器更有效。5. 进阶技巧用 IntersectionObserver 替代 scroll 监听5.1 为什么可以不用 scroll 事件前面所有方案都依赖scroll事件但scroll本质上是「滚动发生了」的通知我们真正关心的是「某个位置是否进入了视口」。IntersectionObserver就是干这个的它由浏览器在后台线程计算不占用主线程性能比 scroll 监听高一个量级。思路是在导航栏下方放一个 1px 高的哨兵元素当哨兵滚出视口顶部时说明页面已经滚过阈值导航栏该变实心了。header classsite-header idsiteHeader div classnav-inner.../div /header !-- 哨兵放在导航栏正下方高度 1px -- div classscroll-sentinel idscrollSentinel/div.scroll-sentinel { position: absolute; top: 50px; /* 和 THRESHOLD 对应 */ left: 0; width: 100%; height: 1px; pointer-events: none; /* 不拦截点击 */ visibility: hidden; }const header document.getElementById(siteHeader); const sentinel document.getElementById(scrollSentinel); const observer new IntersectionObserver( (entries) { entries.forEach((entry) { // 哨兵不可见 已经滚过阈值 header.classList.toggle(is-scrolled, !entry.isIntersecting); }); }, { rootMargin: 0px, threshold: 0 } ); observer.observe(sentinel);这段代码没有任何 scroll 监听滚动时浏览器自动计算哨兵是否可见回调只在状态变化时触发一次。rootMargin可以微调触发时机比如设成-50px 0px 0px 0px相当于把阈值上移 50px。5.2 两种方案的对比与选型维度scroll rAFIntersectionObserver主线程占用每帧执行回调浏览器后台计算兼容性全兼容IE 不支持需 polyfill方向判断容易实现需要额外逻辑代码量稍多更少适用场景需要精细控制滚动位置只关心「是否滚过某点」选型建议如果只需要「滚过 50px 变实心」这一种状态用 IntersectionObserver代码更干净。如果需要方向判断、多阈值、滚动进度条还是 scroll rAF 更灵活。我自己的习惯是新项目优先 IntersectionObserver老项目改造看兼容要求。5.3 一个容易忽略的细节哨兵元素的定位上下文哨兵用position: absolute时它的定位基准是最近的position: relative祖先。如果直接放在body下top: 50px是相对文档流的页面滚动时哨兵跟着内容走逻辑正确。但如果父容器有transform或filter会创建新的包含块哨兵位置可能偏移。稳妥做法是给哨兵一个明确的定位父级或者直接用position: fixed配合top: 50px让它固定在视口坐标里。.scroll-sentinel { position: fixed; top: 50px; left: 0; width: 100%; height: 1px; pointer-events: none; visibility: hidden; }用fixed后哨兵始终在视口 50px 处页面滚动时它不动但IntersectionObserver判断的是它和视口的交叉状态——等等这样它永远在视口内观察就失效了。所以哨兵必须跟着文档流走用absolute是对的。这个细节我第一次写的时候踩过观察器一直不触发排查了半天才发现是定位方式选错了。从那以后我每次用 IntersectionObserver 做滚动触发都会先确认哨兵元素的定位上下文和滚动容器是否一致再写观察逻辑。希望这个实例和这些踩坑记录能帮到你代码可以直接复制成三个文件在浏览器打开改改阈值和过渡曲线就是你自己项目里的导航栏了。本文还有配套的精品资源点击获取