[Web] Drawer 關閉時,DOM 該保留還是移除?
/ 15 min read
Updated:Table of Contents
Drawer 元件看起來很簡單:打開、關閉、滑出來
但有個問題是我想了一下的,就是當使用者把 Drawer 關掉時,到底應該:
- 立刻把它從 DOM 中移除?
- 還是先保留,等退場動畫播完再移除?
- 又或者乾脆一直保留,只是用樣式把它藏起來?
這些選擇不只影響動畫,也會影響元件狀態、效能、可維護性與互動體驗
這篇文章想整理的,就是 Drawer 元件在 DOM 出現與消失策略上的幾種常見做法,以及它們各自的優缺點
為什麼這個問題值得討論
如果 Drawer 只是單純顯示一段文字,那麼可能會直覺地寫成:
if (!open) return null;但當 Drawer 開始包含下面這些需求時,事情就會變得不一樣:
- 想要退場動畫
- 想保留表單輸入內容
- 想保留內部的 scroll 位置
- 想支援 ESC 關閉、overlay click 關閉
- 想讓不同場景共用同一套 drawer shell
這時候,open 就不只是「顯示或不顯示」而已
它開始牽涉到:
- 元件何時該 mount
- 何時該變成可見
- 何時該開始退場
- 何時才能真的從 DOM 消失
也就是說,這個問題本質上是在談元件的生命週期設計,而不是單純的 CSS 動畫
三種常見做法
Drawer 的 DOM 策略,大致可以分成三類:
- 關閉就直接卸載
- 永遠保留 DOM,只切換可見狀態
- 先隱藏,等退場動畫播完再卸載
下面分別來看
1. 關閉就直接卸載
最常見、也最簡單的寫法就是條件渲染:
function Drawer({ open }: { open: boolean }) { if (!open) return null;
return <div className="fixed inset-0">Drawer</div>;}優點
- 實作最簡單
- 關閉後完全不佔用 DOM
- 不容易殘留 focus、事件、互動狀態
- 對輕量元件來說很乾淨
缺點
- 幾乎沒有退場動畫空間
- 關閉當下元件就直接消失
- 內部 state 會被清掉
- 再次打開時需要重新初始化內容
適合什麼情境
- 內容很簡單
- 不在意退場動畫
- 不需要保留表單或 scroll 狀態
- 元件只是暫時顯示資訊,不是複雜互動容器
如果 Drawer 很單純,這種寫法完全合理,而且通常是最務實的選擇
2. 永遠保留 DOM,只切換可見狀態
另一種常見做法是:Drawer 永遠存在,只是透過 class 切換讓它顯示或隱藏
function Drawer({ open }: { open: boolean }) { return ( <div className={ open ? "translate-x-0 opacity-100" : "-translate-x-full opacity-0" } > Drawer </div> );}優點
- 動畫很容易做
- 內部 state 可以保留
- 再次打開時不需要重新 mount
- 如果 Drawer 裡有表單或複雜 UI,體驗通常更穩定
缺點
- DOM 會一直存在
- 關閉後雖然看不到,但其實它還在
- 需要另外處理 aria、focus、pointer-events、tab 順序
- 如果內容很多、元件很重,長期保留可能增加負擔
適合什麼情境
- 希望保留內部狀態
- Drawer 開關頻率很高
- 內容比較重,重新 mount 成本高
- 動畫比 DOM 精簡更重要
這種方式的核心代價在於:買到了流暢與狀態保留,但也接受了「關閉不代表真的離開頁面」
3. 先隱藏,等退場動畫播完再卸載
這通常是 Drawer、Modal、Toast 這類元件最平衡的做法
它的核心概念是:
open只代表業務意圖- 但真正的 UI 顯示還需要額外狀態控制
實作上常見的拆法是這樣:
const [isRendered, setIsRendered] = useState(open);const [isVisible, setIsVisible] = useState(false);這兩個狀態各自代表:
isRendered:Drawer 是否還保留在 DOM 中isVisible:Drawer 是否進入可見狀態
接著配合 useEffect 與 transitionend 控制生命週期:
useEffect(() => { if (open) { setIsRendered(true); return; }
setIsVisible(false);}, [open]);
useEffect(() => { if (!isRendered || !open) { return; }
let frame1 = 0; let frame2 = 0;
frame1 = window.requestAnimationFrame(() => { frame2 = window.requestAnimationFrame(() => { setIsVisible(true); }); });
return () => { window.cancelAnimationFrame(frame1); window.cancelAnimationFrame(frame2); };}, [isRendered, open]);關閉時則是在 transition 結束後再真正卸載:
const handleTransitionEnd = (event: React.TransitionEvent<HTMLDivElement>) => { if (event.target !== event.currentTarget) { return; }
if (!open) { setIsRendered(false); }};優點
- 有完整進場與退場動畫
- 關閉後最終仍能把 DOM 清掉
- 比永遠 keep mounted 更節制
- 非常適合做成可重用的 Drawer Primitive
缺點
- 實作複雜度最高
- 需要管理多一層狀態
- 需要處理
transitionend - 第一次打開的動畫時機不一定一次就寫對
適合什麼情境
- 很重視進退場動畫
- 又不想讓 DOM 永遠保留
- 想把 Drawer 做成可重用元件
- 要平衡體驗、狀態與結構
對多數有動畫需求的 Drawer 來說,這通常是最實用的方案
為什麼第一次打開有時候沒有動畫
第一次寫這種元件時,會以為只要:
setIsRendered(true)- 立刻
setIsVisible(true)
動畫就會自然發生
但實際上,瀏覽器不一定來得及先 paint 一次「隱藏狀態」的 DOM
結果就是 Drawer 第一次出現時,已經直接處在最終狀態,看起來像沒有動畫
這也是為什麼有些實作會刻意把可見狀態延後到下一個或下幾個 animation frame,再讓 Drawer 進場
換句話說,這個問題不是動畫 class 有沒有寫,而是瀏覽器有沒有真的經歷過 hidden -> visible 的狀態切換
open 不等於 DOM 生命週期
這是我覺得寫 Drawer 元件時最值得記住的一件事
open 的意義通常是:
- 從產品或業務角度看,Drawer 現在應該打開還是關閉
但這不代表:
- DOM 現在應該立即存在或立即消失
如果元件有動畫,通常就會發現:
- 業務狀態
- DOM 存活狀態
- 視覺可見狀態
這三者未必是同一件事
當意識到這件事時,Drawer 的設計會清楚很多
三種策略怎麼選
如果把它們整理成表格,大概會像這樣:
| 策略 | 優點 | 缺點 | 適合情境 |
|---|---|---|---|
| 關閉就卸載 | 簡單、乾淨、省資源 | 沒退場動畫、狀態不保留 | 輕量 UI |
| 保留 DOM 只隱藏 | 動畫容易、狀態可保留 | DOM 常駐、需額外處理互動與無障礙 | 複雜內容、頻繁開關 |
| 先隱藏後卸載 | 動畫與結構平衡 | 實作較複雜 | Drawer、Modal、Toast |
如果不知道該選哪個,可以先用下面的方式判斷:
選「關閉就卸載」
如果在意的是:
- 實作簡單
- 結構乾淨
- 關閉後不保留任何狀態
選「保留 DOM 只隱藏」
如果在意的是:
- 內部狀態保留
- 反覆開關的流暢性
- 避免重新 mount 的成本
選「先隱藏後卸載」
如果在意的是:
- 有完整退場動畫
- 關閉後仍希望結構清爽
- 想做成可重用元件
範例程式碼:從簡單到進階
如果只看概念,有時候還是會有點抽象
下面用三段逐步升級的程式碼,把這三種策略對應到實際寫法
這些例子都刻意保持簡短,目的是讓差異清楚,而不是直接當成 production code 貼上去就用
範例一:關閉就直接卸載
這是最小、最直覺的 Drawer
function SimpleDrawer({ open }: { open: boolean }) { if (!open) return null;
return ( <div className="fixed inset-0 bg-black/40"> <aside className="h-full w-80 bg-white shadow-xl">Drawer</aside> </div> );}這段程式碼的特徵很明確:
open=false時直接return null- DOM 會立刻消失
- 寫法非常乾淨
但它的限制也很明顯:
- 沒有退場動畫
- 內部狀態不會保留
- 每次重新打開都等於重新 mount
如果 Drawer 很輕量,這種寫法通常完全夠用
範例二:保留 DOM,只切換可見狀態
如果希望保留狀態,可以改成讓 DOM 一直存在,只切樣式:
function PersistentDrawer({ open }: { open: boolean }) { return ( <div aria-hidden={!open} className={ open ? "fixed inset-0 opacity-100 transition-opacity duration-300" : "pointer-events-none fixed inset-0 opacity-0 transition-opacity duration-300" } > <div className="absolute inset-0 bg-black/40" /> <aside className={ open ? "relative h-full w-80 translate-x-0 bg-white shadow-xl transition-transform duration-300" : "relative h-full w-80 -translate-x-full bg-white shadow-xl transition-transform duration-300" } > Drawer </aside> </div> );}這段的重點是:
- Drawer 關閉後仍然留在 DOM 裡
- 只是用
opacity、translateX、pointer-events-none去控制表現 - 重新打開時,內部狀態還在
這種方式常見於:
- 內部有表單
- 需要保留 scroll 位置
- 使用者會頻繁打開 / 關閉
但也會開始承擔更多責任,例如:
aria-hidden要處理pointer-events要處理- 若有 focus trapping,也得一起考慮
換句話說,DOM 沒卸載並不代表事情變簡單,只是複雜度換了位置
範例三:先隱藏,動畫結束後再卸載
如果同時想要退場動畫與乾淨的 DOM 結構,就會進入這種寫法
import { useEffect, useState } from "react";
function AnimatedDrawer({ open }: { open: boolean }) { const [isRendered, setIsRendered] = useState(open); const [isVisible, setIsVisible] = useState(false);
useEffect(() => { if (open) { setIsRendered(true); return; }
setIsVisible(false); }, [open]);
useEffect(() => { if (!isRendered || !open) return;
let frame1 = 0; let frame2 = 0;
frame1 = window.requestAnimationFrame(() => { frame2 = window.requestAnimationFrame(() => { setIsVisible(true); }); });
return () => { window.cancelAnimationFrame(frame1); window.cancelAnimationFrame(frame2); }; }, [isRendered, open]);
const handleTransitionEnd = ( event: React.TransitionEvent<HTMLDivElement> ) => { if (event.target !== event.currentTarget) return; if (!open) setIsRendered(false); };
if (!isRendered) return null;
return ( <div onTransitionEnd={handleTransitionEnd} className={ isVisible ? "fixed inset-0 opacity-100 transition-opacity duration-300" : "fixed inset-0 opacity-0 transition-opacity duration-300" } > <div className="absolute inset-0 bg-black/40" /> <aside className={ isVisible ? "relative h-full w-80 translate-x-0 bg-white shadow-xl transition-transform duration-300" : "relative h-full w-80 -translate-x-full bg-white shadow-xl transition-transform duration-300" } > Drawer </aside> </div> );}這段程式碼的核心,在於把責任拆成兩層:
isRendered:控制 Drawer 是否保留在 DOMisVisible:控制 Drawer 是否進入可見狀態
這種拆分的好處是:
- 打開時可以先 mount,再顯示
- 關閉時可以先退場,再卸載
- 動畫與 DOM 結構兩邊都兼顧到
但同時它也是三種方式裡最複雜的一種
範例四:更接近實際產品的版本
如果把 Drawer 當成真正可用的 UI 元件,通常還會再補上:
- overlay click close
- ESC close
- close button
aria-label
例如:
type DrawerProps = { open: boolean; onClose: () => void; children: React.ReactNode;};
function ProductDrawer({ open, onClose, children }: DrawerProps) { const [isRendered, setIsRendered] = useState(open); const [isVisible, setIsVisible] = useState(false);
useEffect(() => { if (open) { setIsRendered(true); return; }
setIsVisible(false); }, [open]);
useEffect(() => { if (!isRendered || !open) return;
let frame1 = 0; let frame2 = 0;
frame1 = window.requestAnimationFrame(() => { frame2 = window.requestAnimationFrame(() => { setIsVisible(true); }); });
return () => { window.cancelAnimationFrame(frame1); window.cancelAnimationFrame(frame2); }; }, [isRendered, open]);
useEffect(() => { if (!isRendered) return;
const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { onClose(); } };
window.addEventListener("keydown", handleKeyDown);
return () => { window.removeEventListener("keydown", handleKeyDown); }; }, [isRendered, onClose]);
const handleTransitionEnd = ( event: React.TransitionEvent<HTMLDivElement> ) => { if (event.target !== event.currentTarget) return; if (!open) setIsRendered(false); };
const handleOverlayClick = (event: React.MouseEvent<HTMLDivElement>) => { if (event.target !== event.currentTarget) return; onClose(); };
if (!isRendered) return null;
return ( <div aria-hidden={!open} onClick={handleOverlayClick} onTransitionEnd={handleTransitionEnd} className={ isVisible ? "fixed inset-0 opacity-100 transition-opacity duration-300" : "fixed inset-0 opacity-0 transition-opacity duration-300" } > <div className="absolute inset-0 bg-black/40" />
<aside aria-label="Drawer" className={ isVisible ? "relative h-full w-80 translate-x-0 bg-white shadow-xl transition-transform duration-300" : "relative h-full w-80 -translate-x-full bg-white shadow-xl transition-transform duration-300" } > <button type="button" onClick={onClose}> Close </button> {children} </aside> </div> );}這段就開始接近真正能做成共用元件的型態了
它也剛好說明了一件事:當 Drawer 從「一段 UI」變成「可重用元件」時,DOM 是否保留這件事就不只是動畫選擇,而是整個元件設計的一部分
Drawer 不只是動畫元件,也是互動元件
很多時候在談 Drawer,會把焦點只放在「有沒有滑出來」
但其實它同時也是互動元件,所以 DOM 要不要保留還會影響:
- 焦點管理
- 鍵盤操作
- overlay click 行為
- 螢幕閱讀器可見性
- 使用者是否能不小心點到背景內容
也就是說,Drawer 的設計不只是動畫問題,而是 UI 行為與生命週期一起被設計的問題
這也是為什麼看起來只是「關閉時要不要把 DOM 拿掉」,實際上會牽連整個元件體驗
結語
如果想要最簡單的實作,直接卸載就好
如果想保留狀態,可以選擇 keep mounted
如果同時想要退場動畫與乾淨的 DOM 結構,那麼「先隱藏,動畫結束後再卸載」通常是最平衡的方案