skip to content
BlogZzz

[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 策略,大致可以分成三類:

  1. 關閉就直接卸載
  2. 永遠保留 DOM,只切換可見狀態
  3. 先隱藏,等退場動畫播完再卸載

下面分別來看

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 是否進入可見狀態

接著配合 useEffecttransitionend 控制生命週期:

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 來說,這通常是最實用的方案

為什麼第一次打開有時候沒有動畫

第一次寫這種元件時,會以為只要:

  1. setIsRendered(true)
  2. 立刻 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 裡
  • 只是用 opacitytranslateXpointer-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 是否保留在 DOM
  • isVisible:控制 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 結構,那麼「先隱藏,動畫結束後再卸載」通常是最平衡的方案