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