簽配合 CSS 的現(xiàn)代彈窗實戰(zhàn))
十月八日的深夜整棟樓的窗戶大多已經(jīng)熄了燈。我正在優(yōu)化手賬系統(tǒng)的移動端交互體驗。在手機窄屏上點擊文章末尾的“查看今日烘焙參數(shù)”時需要彈出一個干凈小巧的模態(tài)浮層展示烤箱上下火溫度與發(fā)酵濕度曲線。如果按照現(xiàn)代前端社區(qū)流行的慣性思路實現(xiàn)這個簡單的浮層大多數(shù)人的做法是立刻打開終端npm i headlessui/react或者安裝某個知名的 UI 彈窗包。接著是讓人心累的依賴引入流程打包工具打出來的 JS Bundle 瞬間膨脹了幾十 KB為了處理彈窗背后的背景滾動鎖定Scroll Lock三方庫往往要在document.body上掛載一系列復(fù)雜的計算樣式為了防止焦點跳出彈窗還要運行一段長長的 JavaScript 循環(huán)去監(jiān)聽鍵盤的 Tab 鍵事件。在性能敏感的輕薄本或老舊手機上點開彈窗時經(jīng)常伴隨著極其微妙的卡頓和掉幀。但很多人不知道的是所有的現(xiàn)代主流瀏覽器早就已經(jīng)在底層 C 內(nèi)核中原生實現(xiàn)了dialogHTML5 標(biāo)準模態(tài)標(biāo)簽。不需要下載哪怕 1KB 的外部依賴僅用最純粹的語義標(biāo)簽加上少許 CSS 樣式我們就能實現(xiàn)具備頂層渲染、原生焦點隔離、無縫適配深色溫和自帶Esc退出機制的完美彈窗。為什么原生是彈窗的終極形態(tài)用普通的div去手寫彈窗永遠無法逃避瀏覽器的兩大底層底層機制限制Stacking Context層疊上下文的天然局限如果你試圖把一個 div 彈窗放在某個父級元素內(nèi)部只要父元素設(shè)置了overflow: hidden、filter或transform這個彈窗的z-index哪怕設(shè)到 9999999也會被無情切斷或遮擋。為了解決這個問題三方庫不得不發(fā)明出“Portal傳送門”技術(shù)強行把 DOM 撕裂并投遞到 body 底部。頂層圖層Top Layer的特權(quán)地位原生dialog在被dialog.showModal()喚起時是由瀏覽器直接將其放入超越所有普通 DOM 樹的“Top Layer”渲染層。它不受任何父級樣式的干擾天然處于整張網(wǎng)頁的最頂端。原生無障礙與焦點鎖定Focus Trapping當(dāng)原生模態(tài)窗打開時瀏覽器內(nèi)核會自動讓頁面其他區(qū)域變?yōu)椴豢删劢範(fàn)顟B(tài)InertTab 鍵會自動在彈窗內(nèi)部循環(huán)按鍵盤上的Esc鍵時瀏覽器原生觸發(fā)標(biāo)準的cancel與close事件行云流水。打造水彩手賬風(fēng)格的現(xiàn)代彈窗實戰(zhàn)我們來手寫這個極其清爽的烘焙參數(shù)彈窗。首先是極其純粹的 HTML 結(jié)構(gòu)語義一目了然!-- 觸發(fā)按鈕 -- button idopen-modal-btn classwarm-outline-btn查看今日烘焙溫控/button !-- 原生 dialog 模態(tài)彈窗 -- dialog idbaking-modal classwatercolor-dialog aria-labelledbymodal-heading div classdialog-content header classdialog-header h3 idmodal-heading 蘋果肉桂卷 · 烘焙參數(shù)備忘/h3 button idclose-modal-btn classclose-btn aria-label關(guān)閉?/button /header div classdialog-body pstrong初次發(fā)酵/strong溫度 28℃濕度 75%耗時 50 分鐘面團膨脹至兩倍大。/p pstrong烤箱預(yù)熱/strong上火 185℃底火 175℃中層烘烤 22 分鐘。/p pstrong風(fēng)味秘訣/strong在出爐前 3 分鐘刷一層融化的黃油與楓糖漿表皮會呈現(xiàn)誘人的水彩金黃色澤。/p /div footer classdialog-footer button idconfirm-btn classwarm-solid-btn記住了開始預(yù)熱/button /footer /div /dialog接下來是核心的 CSS 樣式調(diào)配。原生dialog擁有一個獨特的偽元素::backdrop用于直接修飾背后的半透明毛玻璃遮罩:root { --paper-cream: #FAF7F2; --ink-charcoal: #2C2825; --accent-persimmon: #C06C46; } /* 彈窗核心外觀 */ .watercolor-dialog { border: none; border-radius: 12px; padding: 0; background: var(--paper-cream); color: var(--ink-charcoal); box-shadow: 0 16px 48px rgba(44, 40, 37, 0.16); max-width: 480px; width: 90%; /* 優(yōu)雅的進場微動效 */ opacity: 0; transform: translateY(12px) scale(0.98); transition: opacity 0.25s ease-out, transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), display 0.25s allow-discrete; } /* 處于激活展示狀態(tài)的彈窗 */ .watercolor-dialog[open] { opacity: 1; transform: translateY(0) scale(1); } /* 原生自帶的遮罩層 */ .watercolor-dialog::backdrop { background: rgba(44, 40, 37, 0.35); backdrop-filter: blur(6px); /* 細膩溫潤的毛玻璃感 */ opacity: 0; transition: opacity 0.25s ease-out, display 0.25s allow-discrete; } .watercolor-dialog[open]::backdrop { opacity: 1; } /* CSS 新特性支持動畫退出與進入的初始幀 */ starting-style { .watercolor-dialog[open] { opacity: 0; transform: translateY(12px) scale(0.98); } .watercolor-dialog[open]::backdrop { opacity: 0; } } .dialog-content { padding: 28px 24px; } .dialog-header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px dashed rgba(192, 108, 70, 0.2); padding-bottom: 12px; margin-bottom: 16px; } .dialog-header h3 { margin: 0; font-size: 17px; font-weight: 500; color: var(--accent-persimmon); } .close-btn { background: transparent; border: none; font-size: 16px; color: #8C7E72; cursor: pointer; }最后是僅僅十幾行、極其直觀的原生 JavaScriptconst modal document.getElementById(baking-modal); const openBtn document.getElementById(open-modal-btn); const closeBtn document.getElementById(close-modal-btn); const confirmBtn document.getElementById(confirm-btn); // 打開模態(tài)框必須使用 showModal才能獲得 Top Layer 特權(quán) openBtn.addEventListener(click, () { modal.showModal(); }); // 關(guān)閉模態(tài)框 closeBtn.addEventListener(click, () modal.close()); confirmBtn.addEventListener(click, () modal.close()); // 點擊遮罩層空白處時自動從容退出 modal.addEventListener(click, (e) { const rect modal.getBoundingClientRect(); const isInDialog ( rect.top e.clientY e.clientY rect.top rect.height rect.left e.clientX e.clientX rect.left rect.width ); if (!isInDialog) { modal.close(); } });刪去重型依賴后的純粹與踏實把原本項目中引入的那個大幾十 KB 的三方彈窗庫連根拔起、徹底剔除出package.json換上這幾十行原生代碼。在手機上重新打開頁面點擊按鈕。彈窗帶著極其自然的微光暈與毛玻璃遮罩柔和滑入底層的網(wǎng)頁背景沒有發(fā)生任何一絲惡心抽搐的抖動。按一下藍牙鍵盤的Esc鍵彈窗伴隨著淡化動畫優(yōu)雅隱退焦點準確無誤地歸位到原本的觸發(fā)按鈕上。打開打包產(chǎn)物分析工具整套前端應(yīng)用的體積硬生生瘦身了一大圈。在 Web 前端走過這二十多年的今天很多我們曾經(jīng)必須借助重型框架和復(fù)雜插件才能實現(xiàn)的交互特權(quán)早已經(jīng)沉淀為瀏覽器內(nèi)核里堅固優(yōu)雅的標(biāo)準原生能力。學(xué)會停下盲目追逐新組件庫的腳步轉(zhuǎn)過身來重新認識這些被我們忽視的原生底座。刪繁就簡用最少的代碼做最舒服的事這既是對性能的極致尊重也是寫代碼時一種難得的內(nèi)心篤定。