航毛玻璃模糊效果實現(xiàn)與滾動狀態(tài)切換實戰(zhàn))
1. 項目思路與組件選型為什么用HdsNavigation實現(xiàn)1.1 HdsNavigation是什么先把這個組件說清楚。HdsNavigation并不是某個特定框架專屬的導(dǎo)航欄而是很多前端項目里對“頭部導(dǎo)航”的一種通用抽象封裝常見于以組件庫為主導(dǎo)的中后臺系統(tǒng)或者移動端H5頁面里。它通常負(fù)責(zé)承載標(biāo)題、返回按鈕、菜單入口、操作按鈕并且暴露一些狀態(tài)給外部控制比如滾動偏移量、透明度、圖標(biāo)切換等。我在實際項目里第一次接觸HdsNavigation是做一個內(nèi)容型的資訊應(yīng)用。頁面結(jié)構(gòu)很簡單頂部是導(dǎo)航條下面是可滾動的文章流。產(chǎn)品經(jīng)理提了個需求用戶在頁面里向上滑動時頂部導(dǎo)航欄不能只是傻傻停在那里而是要隨著內(nèi)容滑過出現(xiàn)一種“毛玻璃”模糊效果像是把背后文字微微虛化讓導(dǎo)航欄看起來更高級又不遮擋閱讀。當(dāng)時第一反應(yīng)是直接重寫一個自定義頭部但后來發(fā)現(xiàn)既然已經(jīng)有了HdsNavigation的封裝倒不如把它的能力吃透在它的基礎(chǔ)上擴展。這也是為什么這個案例有參考價值——它不只是一個CSS效果而是“組件 滾動行為 視覺反饋”三件事的聯(lián)動。HdsNavigation在上手時并不復(fù)雜核心就是一個容器組件它可以掛在頁面最頂部內(nèi)部渲染導(dǎo)航結(jié)構(gòu)。不過它真正的價值在于預(yù)留了滾動狀態(tài)的接口。只要你愿意它就能感知它所處滾動的階段并反饋給外部讓開發(fā)者有機會去切換樣式。這種設(shè)計思路能幫我們少寫很多臟邏輯。1.2 頂部模糊效果的實現(xiàn)原理頂部模糊效果的視覺底層是CSS的backdrop-filter: blur()。這個屬性會在元素背后的區(qū)域進(jìn)行模糊處理視覺上好像磨砂玻璃一樣。很多導(dǎo)航欄都利用它把背景做成半透明比如.nav-blur { background-color: rgba(255, 255, 255, 0.7); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); }這里backdrop-filter是核心它讓導(dǎo)航欄后面的內(nèi)容被模糊而不是把導(dǎo)航欄自己的子元素模糊。默認(rèn)情況下導(dǎo)航欄背景是實色的當(dāng)內(nèi)容滑動過來時背景會被實色蓋子完全蓋住沒有層次感。而加了模糊之后背景變成半透明同時又讓后面的內(nèi)容看不清保證標(biāo)題可讀。聽起來很簡單但難點在于“什么時候加模糊”和“什么時候不加”。如果一開始就給導(dǎo)航欄加上模糊效果頁面處在頂部時背景可能是空白視覺上會顯得導(dǎo)航欄和內(nèi)容之間有一條模糊的白帶非常尷尬。常見的處理思路是當(dāng)滾動偏移量超過一個閾值比如20px或60px就切換成模糊背景沒超過閾值就保持透明或純色背景。這種交互已經(jīng)被各大App驗證過了比如很多新聞客戶端下拉到頂部時導(dǎo)航欄干凈一旦往下滑動導(dǎo)航欄立刻“霧化”效果非常自然。1.3 方案對比滾動事件監(jiān)聽 vs IntersectionObserver實現(xiàn)這個滾動狀態(tài)切換主要有兩條路。第一條直接在HdsNavigation所在的頁面注冊scroll事件讀取window.scrollY或container.scrollTop然后根據(jù)數(shù)值范圍去切換一個CSS類名。這種方式最直白兼容性也最好幾乎任何前端項目都能用。但缺點是滾動事件觸發(fā)頻率極高如果不加節(jié)流或防抖頁面會頻繁執(zhí)行DOM操作造成性能壓力。第二條使用IntersectionObserver去觀察某個“哨兵元素”比如一個放在頁面頂部、高度為1px的透明塊當(dāng)這個哨兵元素退出可視區(qū)域時就表示內(nèi)容已經(jīng)開始滑動此時把導(dǎo)航欄切到模糊狀態(tài)。這個方案的好處是邏輯更直觀性能更好不用監(jiān)聽滾動事件。不過它的觀察回調(diào)會延遲一小拍而且部分老瀏覽器對IntersectionObserver的支持需要polyfill。從我個人的項目經(jīng)歷看如果滾動容器是window直接用滾動事件反而更穩(wěn)如果滾動容器是一個內(nèi)部元素比如HdsNavigation里嵌了內(nèi)容面板那用IntersectionObserver更容易控制。后面我會把兩種方式的代碼都貼出來方便你按場景選擇。2. 核心實現(xiàn)與實操步驟2.1 環(huán)境準(zhǔn)備與組件安裝先別急著寫代碼把環(huán)境搞對。HdsNavigation在大多數(shù)情況下是隨組件庫一起安裝的比如通過包管理器npm install hds-navigation # 或者 yarn add hds-navigation裝完之后在入口文件引入組件和樣式import HdsNavigation from hds-navigation; import hds-navigation/dist/hds-navigation.css;需要注意不同版本的組件庫依賴的框架版本不同。我用的項目是Vue3 Vite所以直接以ES模塊的方式導(dǎo)入。如果你用的是React往往還需要在組件外層包一層HdsNavigation /并傳入?yún)?shù)。但無論框架怎么變它的核心行為都是一致的渲染頂部導(dǎo)航區(qū)域暴露接收外部狀態(tài)的能力。如果你的HdsNavigation是內(nèi)部封裝好的基礎(chǔ)組件那大概率你已經(jīng)有了一個叫Navigation的組件它的props里可能包含title、leftIcon、rightIcon等。此時你不需要額外安裝只需要知道它有默認(rèn)插槽或者屬性用來嵌套你自己的滑動內(nèi)容區(qū)域。為了統(tǒng)一演示我這里假設(shè)HdsNavigation支持這樣的基本用法HdsNavigation :titlepageTitle :transparentisTop :blurisBlurred /transparent控制是否透明blur控制是否啟用模糊。如果組件本身不支持這些prop我們可以通過樣式類名去控制畢竟我們要的無非是“類A”和“類B”切換。2.2 搭建基本滑動布局先搭建一個最簡單的頁面骨架。為了演示效果內(nèi)容區(qū)域需要一個超高的列表讓頁面可以滾動。template div classpage HdsNavigation title文章詳情 classnav :class{ nav--blur: isBlurred } / main classcontent div v-fori in 30 :keyi classcard 這是第 {{ i }} 個可滑動內(nèi)容的卡片 /div /main /div /template這里有兩個關(guān)鍵點第一導(dǎo)航欄使用fixed定位方式懸浮在頁面頂部第二內(nèi)容區(qū)域正常撐開高度形成滾動。fixed定位在這里非常關(guān)鍵因為如果導(dǎo)航欄使用absolute或者靜態(tài)定位它會隨著內(nèi)容往下滾動走根本看不到模糊效果。除此之外我們要保證導(dǎo)航欄的層級高于內(nèi)容給它加一個z-index: 999。有人會問為什么不用position: stickysticky確實可以讓頭部吸頂?shù)幸粋€問題當(dāng)滾動容器不是window而是某個內(nèi)部元素時sticky的參照對象是父容器容易出各種邊界問題。而fixed雖然會脫離文檔流但勝在穩(wěn)定配合一個占位符把導(dǎo)航欄原本的高度撐開就行。2.3 實現(xiàn)滾動監(jiān)聽與狀態(tài)切換接下來是核心滾動監(jiān)聽。先看最簡單的window滾動方案export default { data() { return { isBlurred: false, scrollThreshold: 30, }; }, mounted() { this.scrollHandler this.handleScroll.bind(this); window.addEventListener(scroll, this.scrollHandler, { passive: true }); this.handleScroll(); }, beforeUnmount() { window.removeEventListener(scroll, this.scrollHandler); }, methods: { handleScroll() { const scrollY window.scrollY || window.pageYOffset; this.isBlurred scrollY this.scrollThreshold; }, }, };這里要注意幾點第一passive: true一定要加它告訴瀏覽器滾動事件不會被preventDefault()阻斷瀏覽器可以放心地走優(yōu)化路徑滾動更流暢不會出現(xiàn)掉幀。第二在頁面初始化時需要先調(diào)用一次handleScroll()刷新一下初始狀態(tài)否則刷新后直接滑到中部導(dǎo)航欄可能還是透明的屬于一個經(jīng)典的狀態(tài)不同步問題。如果滾動容器是某個內(nèi)部元素比如HdsNavigation內(nèi)容區(qū)外面包了一層overflow-y: auto的div那監(jiān)聽對象就不是window而是這個內(nèi)部元素// 假設(shè) containerRef 是滾動容器 containerRef.value.addEventListener(scroll, this.scrollHandler, { passive: true });獲取滾動距離時使用containerRef.value.scrollTop而不是window.scrollY。再看IntersectionObserver方案。先在你的頁面頂部放一個哨兵元素div refsentinel classsentinel/div然后注冊觀察器mounted() { this.observer new IntersectionObserver( (entries) { entries.forEach((entry) { // 當(dāng)哨兵元素離開可視區(qū)域時isBlurred 設(shè)為 true this.isBlurred !entry.isIntersecting; }); }, { threshold: 0 } ); this.observer.observe(this.$refs.sentinel); }, beforeUnmount() { this.observer.disconnect(); }這個方案在移動端H5里表現(xiàn)尤其好因為沒有頻繁的scroll回調(diào)適合對性能敏感的場景。但它的缺點也很明顯如果你希望模糊效果從滾動開始的第一幀就立刻出現(xiàn)可能IntersectionObserver會有極短的延遲視覺上不如滾動監(jiān)聽那么絲滑。所以很多團(tuán)隊干脆兩個都上用滾動監(jiān)聽做主邏輯用requestAnimationFrame做批量狀態(tài)更新。2.4 頂部模糊樣式的關(guān)鍵CSS到這里就該把視覺部分處理到位了。假設(shè)我們給HdsNavigation加了一個類nav--blur那么樣式的核心是.nav { position: fixed; top: 0; left: 0; right: 0; height: 56px; z-index: 999; background-color: rgba(255, 255, 255, 0.85); transition: background-color 0.2s ease; } .nav--blur { background-color: rgba(255, 255, 255, 0.65); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); box-shadow: 0 1px 8px rgba(0, 0, 0, 0.06); }這里有幾個細(xì)節(jié)首先是transition只對background-color做過渡因為backdrop-filter用transition過渡效果很差容易造成性能問題。其次是rgba透明度不要選太透的值否則導(dǎo)航欄后面的文字會穿透過來標(biāo)題變得難讀一般0.6到0.8之間比較保險。最后是box-shadow這個不是必須的但在模糊導(dǎo)航和內(nèi)容之間加一道細(xì)陰影能增加層次感讓導(dǎo)航欄更像懸浮在內(nèi)容之上。如果HdsNavigation內(nèi)部有自己的背景樣式可能需要用!important或者更深的類選擇器去覆蓋。當(dāng)然我更建議直接在組件上通過CSS變量去定制比如.nav { --nav-background: rgba(255, 255, 255, 0.85); --nav-blur-radius: 0px; background-color: var(--nav-background); } .nav--blur { --nav-background: rgba(255, 255, 255, 0.65); --nav-blur-radius: 12px; backdrop-filter: blur(var(--nav-blur-radius)); }這樣寫的好處是后續(xù)如果想調(diào)整透明度和模糊程度只需要改兩個CSS變量完全不用動JS。3. 深入細(xì)節(jié)參數(shù)調(diào)節(jié)與性能優(yōu)化3.1 模糊半徑與背景透明度調(diào)節(jié)很多同學(xué)做完基礎(chǔ)版本后發(fā)現(xiàn)模糊效果要么太重要么輕得跟沒有一樣。問題往往出在參數(shù)上。blur值越大后面內(nèi)容越看不清楚但字體的邊緣也會顯得發(fā)虛所以文章頁面我一般控制在10px到16px之間。如果偏小比如6px基本等同于透明背景模糊意義不大。透明度參數(shù)更是玄學(xué)。背景顏色使用rgba(255, 255, 255, 0.7)代表紅色、綠色、藍(lán)色通道都是255也就是白色alpha通道0.7表示70%不透明。這個值偏大時背景幾乎純白模糊不容易被察覺偏小時文字疊上來會感覺臟。一個經(jīng)驗法則壁紙式背景或圖片內(nèi)容多透明度可以低一些比如0.5讓內(nèi)容若隱若現(xiàn)純文字閱讀類內(nèi)容透明度不要低于0.65保證可讀性。如果產(chǎn)品想要更接近iOS原生的毛玻璃效果可以在背景上疊加一個漸變層。比如.nav--blur { background: linear-gradient( to bottom, rgba(255, 255, 255, 0.75) 0%, rgba(255, 255, 255, 0.55) 100% ); }這樣視覺上會有一點點從實到虛的過渡幾乎沒有人能注意到具體是漸變但整體質(zhì)感會提升。3.2 防抖節(jié)流與passive事件滾動事件是高頻事件很容易在1秒內(nèi)觸發(fā)幾十次甚至上百次。如果每次觸發(fā)都做一次樣式切換或類名切換瀏覽器雖然能扛住但在低端安卓機上會出現(xiàn)肉眼可見的卡頓。這時候節(jié)流和requestAnimationFrame就該上場了。最簡單的方式是節(jié)流methods: { handleScroll() { if (this.ticking) return; this.ticking true; window.requestAnimationFrame(() { this.updateState(); this.ticking false; }); }, updateState() { const scrollY window.scrollY || window.pageYOffset; this.isBlurred scrollY this.scrollThreshold; }, }因為requestAnimationFrame會把狀態(tài)更新放在下一次重繪前瀏覽器能夠把同一幀內(nèi)的多次scroll回調(diào)合并成一次這比單純用setTimeout做節(jié)流要平滑得多。這個技巧在滑動列表的很多場景里都適用不只是導(dǎo)航欄狀態(tài)切換。另外在添加事件監(jiān)聽時一定要加上passive: true讓滾動事件不再被瀏覽器視為“可能被阻止默認(rèn)行為”。如果你忘了加某些版本的瀏覽器會在主線程上額外做一次檢查間接造成滾動變卡。這里順便提一句如果你在scroll事件里調(diào)用了preventDefault()瀏覽器會警告你但有了passive后這個調(diào)用會被忽略所以務(wù)必確認(rèn)自己不需要阻止默認(rèn)滾動行為。3.3 移動端Fixed與Sticky的坑移動端實現(xiàn)頂部模糊導(dǎo)航最大的坑還是fixed定位和iOS的兼容問題。在iOS低版本上position: fixed元素在輸入框聚焦時可能會被頂上去或者出現(xiàn)閃爍。不過現(xiàn)代iOS問題已經(jīng)少了但如果你的HdsNavigation里包含搜索框要格外小心。另外如果頁面整體是overflow-y: auto的容器而不是window滾動fixed元素默認(rèn)相對視口定位不會跟著容器跑。這時候如果導(dǎo)航欄要跟隨某個容器反而要用sticky。sticky這個定位很有意思它既保留在文檔流中又在滾動到頂部時“粘住”。但要注意它只對其最近的滾動祖先生效而且父容器不能設(shè)置overflow: hidden或overflow: auto之外的值否則會失效。我踩過一次比較隱蔽的坑給內(nèi)容區(qū)父節(jié)點設(shè)置了overflow-x: hidden來阻止橫向滾動結(jié)果sticky導(dǎo)航頭無論如何都吸不住。檢查了半天才發(fā)現(xiàn)是overflow-x: hidden破壞了sticky的生效條件。所以我的建議是如果你的導(dǎo)航需要跟隨某個具體容器滾動并且該容器高度有限用sticky如果是全頁面滾動穩(wěn)妥方案是fixed。在使用fixed時務(wù)必給body或者h(yuǎn)tml設(shè)置一個高度或最小高度否則內(nèi)容高度不夠撐不起滾動模糊效果難免會看起來很呆板。3.4 結(jié)合HdsNavigation API的進(jìn)階用法有些版本的HdsNavigation會提供onscroll回調(diào)或者scroll-direction參數(shù)如果你恰好用的是這種帶狀態(tài)感知的組件完全不用自己監(jiān)聽滾動了。它會在內(nèi)部幫你計算滾動方向。比如上滑時導(dǎo)航欄不但可以模糊甚至可以整體隱藏只保留一個收起的手勢下滑時再以模糊狀態(tài)出現(xiàn)。這種組件通常有一個scrollMode配置項取值可能是normal | hide | blur。當(dāng)設(shè)置成blur時組件內(nèi)部會自動實現(xiàn)我上面描述的所有邏輯。如果你遇到這種情況那直接開箱即用就好。但更多情況下我們用的是基礎(chǔ)版HdsNavigation沒有這些高級配置。此時我們可以通過組件暴露的slot或者自定義屬性來擴展。比如在HdsNavigation里放一個動態(tài)類名HdsNavigation :classnavClass /然后navClass返回一個對象根據(jù)isBlurred決定nav--blur是否存在。這種耦合方式雖然不夠優(yōu)雅但在不熟悉組件源碼時是成本最低的辦法。再進(jìn)階一點你可以利用Vue的provide/inject或React的Context把滾動狀態(tài)傳給HdsNavigation內(nèi)部任意子組件。這樣右側(cè)的“分享按鈕”在模糊模式下可以改變底色左側(cè)的“返回圖標(biāo)”可以換顏色整個導(dǎo)航欄的視覺聯(lián)動就會非常完整。4. 常見問題與排查技巧實錄4.1 滾動容器的選擇window還是元素很多人在頁面上明明滾動了但isBlurred一直不變化第一反應(yīng)是監(jiān)聽代碼寫錯了。其實大概率是監(jiān)聽錯了容器。如果你是在一個高度為100vh的div里面做overflow-y: auto然后監(jiān)聽window的scroll那肯定拿不到新的scrollTop因為window本身沒有滾動。這種時候應(yīng)該先檢查目標(biāo)元素是否真的出現(xiàn)了滾動條再決定監(jiān)聽誰。一個快速判斷方法在瀏覽器控制臺執(zhí)行document.querySelector(.content).scrollTop;如果內(nèi)容沒有移動說明滾動條不在.content上而是可能在外層或者根元素上。用這個思路排查很快就能定位。4.2 backdrop-filter兼容性backdrop-filter的兼容性說實話已經(jīng)不是瓶頸但在部分安卓WebView里表現(xiàn)比較差。如果發(fā)現(xiàn)某些手機上模糊效果完全沒生效多半是內(nèi)核不支持這個屬性。最簡單的兜底方案是在模糊導(dǎo)航的樣式后面加一層不依賴backdrop-filter的高透明度背景色至少保證文字可讀.nav--blur { background-color: rgba(255, 255, 255, 0.9); /* 兜底 */ backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); }這樣即使模糊失效視覺上也只是少了一層磨砂感不會完全錯亂。如果你是強迫癥非要在老機器上還原模糊那只能考慮用圖片預(yù)渲染或者用半透明漸變?nèi)ツM不過我覺得沒必要畢竟連iOS和主流安卓都已經(jīng)支持了。4.3 導(dǎo)航欄跳動問題另一個高頻問題導(dǎo)航欄在滾動到閾值邊界的一瞬間背景和陰影突然變化看起來像跳了一下。這個跳動通常來自兩個原因一是沒有加transition導(dǎo)致背景顏色瞬間切換二是box-shadow從無到有瞬間撐出陰影造成了視覺上的位移感。解決方法是把陰影和背景的過渡一起加上并且把陰影值寫得更溫和。比如.nav { transition: background-color 0.25s ease, box-shadow 0.25s ease; }如果還是感覺跳可以把閾值從單個數(shù)值改成一個區(qū)間比如在0到40px范圍內(nèi)漸進(jìn)式調(diào)整透明度。這個操作可以用CSS變量配合JS實現(xiàn)const progress Math.min(scrollY / 40, 1); this.navOpacity 0.85 - progress * 0.2;代碼明明不復(fù)雜但觀感會非常順滑。4.4 與路由切換、頁面緩存的沖突單頁應(yīng)用里HdsNavigation往往放在布局組件中。當(dāng)路由切換時滾動位置會保留但狀態(tài)變量可能會被重置。比如說從A頁面滑到一半此時導(dǎo)航欄是模糊狀態(tài)路由跳轉(zhuǎn)到B頁面B頁面是空表單滾動高度為0但組件的isBlurred仍然為true導(dǎo)致頁面一進(jìn)來導(dǎo)航欄就是模糊的。這種情況下必須在路由切換時主動重置狀態(tài)最簡單的方式是監(jiān)聽路由watch: { $route() { this.isBlurred false; window.scrollTo(0, 0); } }如果使用了keep-alive還需要在activated鉤子里重新計算當(dāng)前滾動位置避免緩存頁面恢復(fù)時狀態(tài)錯亂。這個屬于經(jīng)驗型問題做多了才會遇見。5. 結(jié)尾一些個人經(jīng)驗分享做了這個功能之后最大的體會是導(dǎo)航欄模糊效果本質(zhì)上是一個“滾動環(huán)境感知”問題。我們不能只盯著CSS寫沒寫對還要看滾動容器、組件狀態(tài)、路由生命周期這些周邊因素。如果哪一個環(huán)節(jié)沒有配合好視覺上的小問題就會變成邏輯上的大問題。最后分享一個小技巧如果你做的頁面結(jié)構(gòu)很復(fù)雜無法確定滾動容器到底是哪個你可以在所有可能的元素上臨時打上console.log(scrollTop:, el.scrollTop)在滾動時快速定位。定位完成后再把日志刪除整個過程耗時不超過五分鐘卻能省去后面很多的調(diào)試時間。我這個項目后來還順手實現(xiàn)了“滾動超過頁面高度后返回頂部按鈕的漸隱漸顯”因為已經(jīng)把滾動容器和狀態(tài)管理理清楚了所以加功能非常順手。約等于一份代碼覆蓋好幾處交互場景這大概就是做清晰架構(gòu)的回報。