漸變導(dǎo)航欄實(shí)現(xiàn)指南:scroll監(jiān)聽、IntersectionObserver與純CSS方案)
簡(jiǎn)介這是一份面向前端初學(xué)者與進(jìn)階練習(xí)者的滾動(dòng)漸變導(dǎo)航欄實(shí)戰(zhàn)小實(shí)例圍繞HTML5、CSS3與JavaScript三項(xiàng)核心技術(shù)展開幫助讀者理解頁(yè)面滾動(dòng)時(shí)導(dǎo)航欄背景色與透明度動(dòng)態(tài)變化的實(shí)現(xiàn)思路適合用于個(gè)人練習(xí)、課程作業(yè)或小型項(xiàng)目中的導(dǎo)航組件參考。資源包共5個(gè)文件包含1個(gè)html頁(yè)面、1個(gè)css樣式表、2張jpg圖片素材以及1個(gè)gif動(dòng)效演示壓縮包整體約1.48MB結(jié)構(gòu)精簡(jiǎn)便于直接打開運(yùn)行與對(duì)照修改。目前已有156人學(xué)習(xí)下載可作為前端交互練習(xí)的參考案例。通過該實(shí)例讀者能夠掌握滾動(dòng)事件監(jiān)聽、樣式動(dòng)態(tài)切換與過渡動(dòng)畫的配合方式理解導(dǎo)航欄在不同滾動(dòng)位置下的視覺反饋邏輯并借助圖片素材與動(dòng)效演示快速還原效果適合作為鞏固DOM操作與CSS3過渡知識(shí)的練手素材。1. 滾動(dòng)漸變導(dǎo)航欄從透明到實(shí)心的那條臨界線在哪頁(yè)面頂部一條完全透明的導(dǎo)航欄用戶往下滾了 200px 之后它慢慢變成白底加陰影——這個(gè)效果你在絕大多數(shù)官網(wǎng)、后臺(tái)、落地頁(yè)上都見過。它叫滾動(dòng)漸變導(dǎo)航欄用 HTML5 搭結(jié)構(gòu)、CSS3 做過渡、JS 監(jiān)聽滾動(dòng)位置來切換狀態(tài)三件套各管一段誰都不越界。很多人第一次做會(huì)寫成「滾動(dòng)超過某個(gè)值就加個(gè) class」能跑但滾回去的時(shí)候閃、移動(dòng)端卡、iOS 上還會(huì)因?yàn)榈刂窓谑湛s導(dǎo)致閾值亂跳。真正要解決的問題不是「怎么加 class」而是「在哪一刻切、用什么驅(qū)動(dòng)切、切的時(shí)候怎么不抖」。這篇面向的是已經(jīng)會(huì)寫基礎(chǔ) HTMLCSSJS、想把這類交互做扎實(shí)的前端從最小可跑版本一路講到 requestAnimationFrame 節(jié)流、IntersectionObserver 替代方案和移動(dòng)端適配的邊界??赐昴隳苤苯映咭惶捉Y(jié)構(gòu)也能判斷什么場(chǎng)景下不該用滾動(dòng)監(jiān)聽。2. 三種實(shí)現(xiàn)路線scroll 監(jiān)聽、IntersectionObserver 和純 CSS 的取舍2.1 先想清楚「漸變」到底漸變的是什么很多人一上來就寫 JS其實(shí)得先把視覺目標(biāo)拆開。滾動(dòng)漸變導(dǎo)航欄的「漸變」通常包含三層變化背景色從 transparent 到某個(gè)實(shí)色、文字顏色從淺到深或反過來、陰影從無到有。這三層可以同時(shí)發(fā)生也可以錯(cuò)開。錯(cuò)開是有意義的——背景先變、文字后變視覺上更順因?yàn)槲淖诸伾蛔儽缺尘巴蛔兏萄?。從?shí)現(xiàn)機(jī)制看能驅(qū)動(dòng)這三層變化的手段有三種。第一種是 JS 監(jiān)聽 scroll 事件讀 scrollTop超過閾值就給導(dǎo)航欄加一個(gè) .scrolled 類CSS 里用 transition 做過渡。第二種是 IntersectionObserver觀察頁(yè)面頂部一個(gè)哨兵元素它離開視口就觸發(fā)切換。第三種是純 CSS 的 scroll-driven animations用 animation-timeline: scroll() 直接把滾動(dòng)進(jìn)度映射到樣式不需要 JS。選型不是看哪個(gè)新而是看你的兼容底線和交互復(fù)雜度。如果只需要「過線變色」這種二元切換IntersectionObserver 最省心它不占用主線程滾動(dòng)時(shí)不會(huì)掉幀。如果要做「隨滾動(dòng)進(jìn)度連續(xù)變化」比如背景透明度跟滾動(dòng)距離線性相關(guān)scroll-driven animations 最干凈但目前兼容性還有限得留降級(jí)。如果要做「多個(gè)閾值、多個(gè)元素聯(lián)動(dòng)」那還是老老實(shí)實(shí) scroll 監(jiān)聽加節(jié)流可控性最強(qiáng)。我一般的判斷順序是先問是不是二元切換是就 IntersectionObserver再問要不要連續(xù)映射要就 scroll-driven 加降級(jí)剩下復(fù)雜聯(lián)動(dòng)才上 scroll 監(jiān)聽。下面三節(jié)分別給最小可跑代碼。2.2 scroll 監(jiān)聽版最小可跑結(jié)構(gòu)先給 HTML 骨架導(dǎo)航欄固定在頂部下面放足夠高的內(nèi)容讓頁(yè)面能滾。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title滾動(dòng)漸變導(dǎo)航欄/title link relstylesheet hrefstyle.css /head body !-- 導(dǎo)航欄初始透明滾動(dòng)后加 .scrolled -- header classnavbar idnavbar div classlogoBrand/div nav classmenu a href#首頁(yè)/a a href#產(chǎn)品/a a href#關(guān)于/a /nav /header !-- 首屏內(nèi)容故意做高保證能滾動(dòng) -- section classhero向下滾動(dòng)看導(dǎo)航欄變化/section section classcontent正文內(nèi)容區(qū)域/section script srcmain.js/script /body /htmlCSS 負(fù)責(zé)兩件事固定定位以及定義 .scrolled 狀態(tài)下的樣式和過渡。/* 基礎(chǔ)重置 */ * { margin: 0; padding: 0; box-sizing: border-box; } .navbar { position: fixed; /* 固定在頂部 */ top: 0; left: 0; right: 0; height: 64px; display: flex; align-items: center; justify-content: space-between; padding: 0 32px; background: transparent; /* 初始透明 */ color: #fff; /* 只過渡需要的屬性別寫 all */ transition: background-color .3s ease, box-shadow .3s ease, color .3s ease; z-index: 1000; } /* 滾動(dòng)后的實(shí)心狀態(tài) */ .navbar.scrolled { background: #ffffff; color: #1a1a1a; box-shadow: 0 2px 12px rgba(0, 0, 0, .08); } .hero { height: 100vh; background: linear-gradient(135deg, #4a6cf7, #7b3ff2); display: flex; align-items: center; justify-content: center; color: #fff; font-size: 24px; } .content { height: 150vh; padding: 40px; }JS 只做一件事讀滾動(dòng)位置決定加不加類。// 緩存 DOM 引用避免每次滾動(dòng)都查詢 const navbar document.getElementById(navbar); // 閾值滾動(dòng)超過 80px 就切換可按首屏高度調(diào)整 const THRESHOLD 80; let ticking false; function updateNavbar() { const scrolled window.scrollY THRESHOLD; // classList.toggle 第二個(gè)參數(shù)為 true 時(shí)添加false 時(shí)移除 navbar.classList.toggle(scrolled, scrolled); ticking false; } window.addEventListener(scroll, () { // requestAnimationFrame 節(jié)流一幀內(nèi)只執(zhí)行一次 if (!ticking) { window.requestAnimationFrame(updateNavbar); ticking true; } }, { passive: true }); // passive 告訴瀏覽器不阻止默認(rèn)滾動(dòng)提升移動(dòng)端性能邏輯說明scroll 事件觸發(fā)頻率極高直接在里面操作 DOM 會(huì)導(dǎo)致布局抖動(dòng)。用 requestAnimationFrame 把更新壓到每幀一次配合 ticking 標(biāo)志位防止重復(fù)排隊(duì)。{ passive: true }是關(guān)鍵它讓瀏覽器知道這個(gè)監(jiān)聽器不會(huì)調(diào)用 preventDefault從而可以立即滾動(dòng)而不等待 JS 執(zhí)行移動(dòng)端體感差別明顯。參數(shù)說明THRESHOLD 是切換閾值設(shè)成 0 就是「一滾就變」設(shè)成首屏高度就是「滾過首屏才變」。實(shí)際項(xiàng)目里我一般取導(dǎo)航欄高度的 1.5 到 2 倍64px 的導(dǎo)航欄取 80 到 120 之間比較自然。transition 的時(shí)長(zhǎng) 0.3s 是經(jīng)驗(yàn)值低于 0.2s 顯得生硬高于 0.5s 會(huì)拖沓。2.3 IntersectionObserver 版不碰主線程的切換思路是在導(dǎo)航欄正下方放一個(gè)高度為 1px 的哨兵元素它滾出視口頂部時(shí)說明用戶已經(jīng)滾過導(dǎo)航欄高度此時(shí)切換狀態(tài)。header classnavbar idnavbar.../header !-- 哨兵緊貼導(dǎo)航欄下方高度 1px不可見 -- div classsentinel idsentinel/divconst navbar document.getElementById(navbar); const sentinel document.getElementById(sentinel); // rootMargin 頂部留出導(dǎo)航欄高度讓觸發(fā)點(diǎn)對(duì)齊導(dǎo)航欄底邊 const observer new IntersectionObserver( ([entry]) { // isIntersecting 為 false 表示哨兵已滾出視口頂部 navbar.classList.toggle(scrolled, !entry.isIntersecting); }, { rootMargin: -64px 0px 0px 0px, threshold: 0 } ); observer.observe(sentinel);邏輯說明IntersectionObserver 的回調(diào)只在交叉狀態(tài)變化時(shí)觸發(fā)滾動(dòng)過程中不產(chǎn)生任何 JS 執(zhí)行性能比 scroll 監(jiān)聽好一個(gè)量級(jí)。rootMargin 的-64px表示把觀察區(qū)域的頂部?jī)?nèi)縮 64px正好是導(dǎo)航欄高度這樣哨兵一碰到導(dǎo)航欄底邊就觸發(fā)視覺上切換點(diǎn)和 scroll 版一致。參數(shù)說明threshold 設(shè) 0 表示只要有一像素交叉就觸發(fā)。rootMargin 的負(fù)值用來調(diào)整觸發(fā)時(shí)機(jī)想讓切換更早發(fā)生就把負(fù)值調(diào)小更晚就調(diào)大。這個(gè)方案在 iOS Safari 上表現(xiàn)穩(wěn)定不受地址欄收縮影響因?yàn)樗谝暱诮徊娑蔷唧w滾動(dòng)像素。2.4 純 CSS scroll-driven 版連續(xù)映射的寫法如果想讓背景透明度隨滾動(dòng)連續(xù)變化而不是二元切換可以用 scroll-driven animations。注意這個(gè)特性目前需要較新瀏覽器生產(chǎn)環(huán)境要配降級(jí)。.navbar { position: fixed; top: 0; left: 0; right: 0; height: 64px; /* 用動(dòng)畫驅(qū)動(dòng)背景色時(shí)間軸綁定到滾動(dòng) */ animation: nav-bg linear both; animation-timeline: scroll(root block); animation-range: 0 200px; /* 滾動(dòng) 0 到 200px 之間完成動(dòng)畫 */ } keyframes nav-bg { from { background: rgba(255, 255, 255, 0); color: #fff; } to { background: rgba(255, 255, 255, 1); color: #1a1a1a; box-shadow: 0 2px 12px rgba(0, 0, 0, .08); } }邏輯說明animation-timeline 把動(dòng)畫進(jìn)度綁定到滾動(dòng)容器的滾動(dòng)進(jìn)度animation-range 限定在 0 到 200px 之間。這樣滾動(dòng) 100px 時(shí)背景正好半透明是真正的連續(xù)映射不需要任何 JS。參數(shù)說明animation-range 的第一個(gè)值是起始滾動(dòng)位置第二個(gè)是結(jié)束位置單位可以是 px 或百分比。用scroll(root block)表示監(jiān)聽根滾動(dòng)容器的塊軸方向。降級(jí)方案是保留前面的 scroll 監(jiān)聽版用supports (animation-timeline: scroll())做特性檢測(cè)不支持時(shí)走 JS 分支。3. 閾值、過渡和性能三個(gè)必須調(diào)對(duì)的參數(shù)3.1 閾值不是拍腦袋定的閾值決定了導(dǎo)航欄在哪個(gè)滾動(dòng)位置切換。定得太小用戶剛滾一點(diǎn)就變感覺突兀定得太大首屏內(nèi)容都滾完了才變用戶會(huì)覺得導(dǎo)航欄一直透明看不清。合理的做法是讓閾值和首屏視覺重心對(duì)齊。如果首屏是一張全屏大圖導(dǎo)航欄文字是白色那閾值應(yīng)該設(shè)在「大圖底部即將離開視口」的位置通常是視口高度的 60% 到 80%。如果首屏是淺色背景導(dǎo)航欄文字本來就是深色那閾值可以設(shè)小一點(diǎn)甚至不需要切換背景只加陰影即可。我一般會(huì)先在瀏覽器里手動(dòng)滾找到視覺上「該變了」的那個(gè)點(diǎn)用window.scrollY打印出來再取整。別用固定值 100 或 200 套所有頁(yè)面不同首屏高度差很多。3.2 transition 只寫需要的屬性CSS 里寫transition: all .3s是常見的偷懶寫法但它會(huì)讓所有可過渡屬性都參與動(dòng)畫包括你不想動(dòng)的 width、height、padding。滾動(dòng)時(shí)如果導(dǎo)航欄內(nèi)部有布局變化all 會(huì)引發(fā)額外的重排和重繪移動(dòng)端直接掉幀。正確做法是只列需要過渡的屬性background-color、box-shadow、color。如果文字顏色變化想慢一點(diǎn)可以單獨(dú)給 color 設(shè)更長(zhǎng)的時(shí)長(zhǎng)做出錯(cuò)峰效果。.navbar { transition: background-color .3s ease, box-shadow .3s ease, color .2s ease .1s; /* 延遲 0.1s等背景先變 */ }3.3 移動(dòng)端滾動(dòng)監(jiān)聽的三個(gè)坑第一個(gè)坑是 iOS 的橡皮筋效果。頁(yè)面滾到頂部繼續(xù)下拉時(shí)scrollY 會(huì)變成負(fù)值如果閾值判斷寫的是scrollY THRESHOLD負(fù)值不會(huì)觸發(fā)沒問題但如果寫的是Math.abs(scrollY) THRESHOLD就會(huì)誤觸發(fā)。別用絕對(duì)值。第二個(gè)坑是地址欄收縮。移動(dòng)端瀏覽器滾動(dòng)時(shí)地址欄會(huì)收起視口高度變化如果閾值是基于window.innerHeight計(jì)算的會(huì)在滾動(dòng)過程中跳變。解決辦法是用固定像素閾值或者用 IntersectionObserver 基于視口交叉判斷。第三個(gè)坑是滾動(dòng)方向判斷。有些設(shè)計(jì)是「向下滾隱藏導(dǎo)航欄向上滾顯示」這需要記錄上一次的 scrollY 做差值。差值判斷要加一個(gè)最小位移量比如 5px否則手指微抖就會(huì)來回切換視覺上像抽搐。let lastY 0; const MIN_DELTA 5; // 最小位移過濾抖動(dòng) function onScroll() { const y window.scrollY; const delta y - lastY; if (Math.abs(delta) MIN_DELTA) return; // 位移太小忽略 if (delta 0 y 64) { navbar.classList.add(hidden); // 向下滾隱藏 } else { navbar.classList.remove(hidden); // 向上滾顯示 } lastY y; }4. 避坑排查滾動(dòng)漸變導(dǎo)航欄最常見的五個(gè)翻車現(xiàn)場(chǎng)4.1 滾回去的時(shí)候?qū)Ш綑陂W一下才變透明現(xiàn)象往下滾導(dǎo)航欄變白往上滾回頂部時(shí)導(dǎo)航欄先閃一下白色再變透明或者過渡不連貫。原因scroll 事件在快速滾動(dòng)時(shí)可能丟幀classList.toggle 在臨界點(diǎn)附近反復(fù)切換transition 被不斷打斷重啟。另外如果閾值判斷用的是和混用臨界點(diǎn)會(huì)抖動(dòng)。解決確保切換邏輯是冪等的用 toggle 的第二個(gè)參數(shù)明確指定目標(biāo)狀態(tài)而不是手動(dòng) add/remove。同時(shí)在臨界點(diǎn)加一點(diǎn)遲滯hysteresis比如向下滾超過 80px 才加類向上滾回到 60px 才移除中間 20px 是緩沖帶。const ON 80, OFF 60; function update() { const y window.scrollY; if (y ON) navbar.classList.add(scrolled); else if (y OFF) navbar.classList.remove(scrolled); // 在 60 到 80 之間保持當(dāng)前狀態(tài)不變 }4.2 移動(dòng)端滾動(dòng)時(shí)導(dǎo)航欄文字顏色突變現(xiàn)象背景色是平滑過渡的但文字顏色一瞬間從白變黑很突兀。原因文字顏色可能被寫在.navbar.scrolled a這種后代選擇器里而 transition 只寫在 .navbar 上后代元素沒有繼承過渡屬性。解決把 transition 寫在所有會(huì)變色的元素上或者用 CSS 變量統(tǒng)一控制。用變量的話只過渡變量本身所有引用它的屬性一起平滑變化。.navbar { --nav-fg: #fff; --nav-bg: transparent; color: var(--nav-fg); background: var(--nav-bg); transition: --nav-fg .3s, --nav-bg .3s; /* 需要 property 注冊(cè)才能過渡變量 */ }注意CSS 變量默認(rèn)不能過渡需要用property注冊(cè)類型。兼容性要求高的話還是老老實(shí)實(shí)給每個(gè)屬性寫 transition。4.3 頁(yè)面內(nèi)容被固定導(dǎo)航欄擋住現(xiàn)象導(dǎo)航欄固定后頁(yè)面第一屏內(nèi)容頂部被遮住一截錨點(diǎn)跳轉(zhuǎn)也對(duì)不準(zhǔn)位置。原因position: fixed 讓導(dǎo)航欄脫離文檔流不占空間下面的內(nèi)容從頁(yè)面頂部開始排。解決給 body 或首個(gè) section 加 padding-top值等于導(dǎo)航欄高度。錨點(diǎn)跳轉(zhuǎn)用 scroll-margin-top 處理。body { padding-top: 64px; } /* 或者只給首屏加 */ .hero { padding-top: 64px; } /* 錨點(diǎn)目標(biāo)元素留出導(dǎo)航欄空間 */ section[id] { scroll-margin-top: 64px; }4.4 滾動(dòng)時(shí)頁(yè)面卡頓、掉幀現(xiàn)象滾動(dòng)不跟手導(dǎo)航欄變化有延遲低端安卓機(jī)上尤其明顯。原因scroll 回調(diào)里做了重排操作比如讀 offsetHeight、getBoundingClientRect或者改了會(huì)影響布局的屬性。讀寫交替會(huì)強(qiáng)制同步布局。解決回調(diào)里只做 class 切換不讀布局信息。如果必須讀先在滾動(dòng)開始前緩存。另外確認(rèn)加了{(lán) passive: true }并且用 requestAnimationFrame 節(jié)流。如果還卡直接換 IntersectionObserver。4.5 首屏加載時(shí)導(dǎo)航欄狀態(tài)錯(cuò)誤現(xiàn)象頁(yè)面刷新時(shí)如果瀏覽器恢復(fù)了上次的滾動(dòng)位置導(dǎo)航欄可能停在錯(cuò)誤狀態(tài)——明明在頂部卻顯示實(shí)心或者滾到中間卻還是透明。原因初始化時(shí)沒有根據(jù)當(dāng)前 scrollY 同步一次狀態(tài)只依賴 scroll 事件而恢復(fù)滾動(dòng)位置不一定觸發(fā) scroll。解決頁(yè)面加載后立即調(diào)用一次更新函數(shù)并且監(jiān)聽 pageshow 事件處理瀏覽器緩存恢復(fù)的情況。// 初始化時(shí)同步一次 updateNavbar(); // 處理瀏覽器前進(jìn)后退恢復(fù)滾動(dòng)位置 window.addEventListener(pageshow, updateNavbar);5. 進(jìn)階用 CSS 變量做主題化漸變以及一個(gè)驗(yàn)證技巧做到這里基本功能已經(jīng)穩(wěn)了。但實(shí)際項(xiàng)目里導(dǎo)航欄往往不止一種狀態(tài)——首頁(yè)透明、內(nèi)頁(yè)實(shí)心、深色模式又要換一套顏色。如果每個(gè)狀態(tài)都寫一遍 CSS維護(hù)起來很痛苦。我的做法是用 CSS 變量把「透明態(tài)」和「實(shí)心態(tài)」抽成兩組變量切換時(shí)只換變量值。.navbar { --bg: transparent; --fg: #ffffff; --shadow: none; background: var(--bg); color: var(--fg); box-shadow: var(--shadow); transition: background-color .3s, color .3s, box-shadow .3s; } .navbar.scrolled { --bg: #ffffff; --fg: #1a1a1a; --shadow: 0 2px 12px rgba(0, 0, 0, .08); } /* 深色模式只覆蓋變量不動(dòng)結(jié)構(gòu) */ media (prefers-color-scheme: dark) { .navbar.scrolled { --bg: #1a1a1a; --fg: #f5f5f5; --shadow: 0 2px 12px rgba(0, 0, 0, .4); } }這樣切換邏輯還是那一個(gè) class但視覺表現(xiàn)可以無限擴(kuò)展。內(nèi)頁(yè)需要默認(rèn)實(shí)心就在 HTML 上直接加 .scrolled 類JS 初始化時(shí)會(huì)根據(jù)滾動(dòng)位置決定是否移除邏輯自洽。驗(yàn)證技巧方面我習(xí)慣用 Chrome DevTools 的 Rendering 面板打開「Paint flashing」?jié)L動(dòng)頁(yè)面時(shí)看導(dǎo)航欄區(qū)域有沒有頻繁重繪。正常情況下只有狀態(tài)切換的那一兩幀會(huì)閃綠如果滾動(dòng)全程都在閃說明有屬性在持續(xù)觸發(fā)重繪通常是 box-shadow 或 background 被寫在了會(huì)頻繁變化的元素上。另一個(gè)技巧是在 Performance 面板錄一段滾動(dòng)看主線程有沒有長(zhǎng)任務(wù)如果有超過 50ms 的任務(wù)基本就是 scroll 回調(diào)里做了重活。還有一個(gè)容易被忽略的點(diǎn)will-change別亂加。有人為了「優(yōu)化」給導(dǎo)航欄加will-change: background-color結(jié)果瀏覽器提前把它提升為合成層反而增加內(nèi)存占用。只在確實(shí)觀察到掉幀、且確認(rèn)是合成問題時(shí)才加加完要測(cè)。最后說個(gè)我自己的習(xí)慣。每次做完這類滾動(dòng)交互我都會(huì)在真機(jī)上從頂部快速甩到底部再甩回來重復(fù)十幾次看有沒有狀態(tài)錯(cuò)亂。模擬器永遠(yuǎn)測(cè)不出 iOS 橡皮筋和安卓慣性滾動(dòng)的真實(shí)表現(xiàn)。這個(gè)動(dòng)作花不了一分鐘但能擋掉大部分上線后才會(huì)暴露的玄學(xué) bug。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取