在先前的 DOM 樹狀結構與渲染引擎揭秘:Reflow (重排) 與 Repaint (重繪) 的效能優化全要點 中,我們探討了瀏覽器將 HTML 轉換為 DOM 樹與觸發幾何重排的底層機制。然而在大型專案維護中,即使靜態資源載入順序已妥善配置,頁面在點擊篩選器、展開選單或滾動列表時仍常出現明顯卡頓。開啟 Chrome DevTools 檢查後,往往會發現為了排版對齊或元件封裝而層層堆疊了 30 層以上的 <div> 包裝容器(俗稱 Divitis)。這份筆記將根據 Chrome for Developers、web.dev、W3C 規範與 Chromium Blink 原始碼,拆解 DOM 節點深度與總節點數對樣式重算(Recalculate Style)及 INP(Interaction to Next Paint)互動指標的影響,並提供結構扁平化與批次操作的工程解法。
常見問題(Q&A)與程式碼範本快速跳轉
如果目前正在排查頁面的 Lighthouse「Avoid an excessive DOM size」警告或 INP 互動延遲超標問題,可以直接點擊下方連結跳轉至對應解答與程式碼範本:
- 常見問題快速跳轉:
- Q1:為什麼在 Console 執行
document.querySelectorAll('*').length的數字,和 DevTools Performance Monitor 顯示的DOM nodes數量差了好幾倍? - Q2:為什麼減少 DOM 節點深度不只能加快首屏渲染,還能顯著改善 Core Web Vitals 的 INP(下次繪製互動延遲)指標?
- Q3:使用 CSS
display: contents讓外層包裝容器在排版中「隱形」時,為什麼有時候會導致螢幕閱讀器無法辨識表格、清單或按鈕? - Q4:將原本用
display: none隱藏的數十個彈跳視窗、下拉選單或頁籤內容留在初始 HTML 中,會不會算進 DOM 節點總數並拖慢頁面? - Q5:當頁面對應長列表(如 2,000 筆資料列)無法減少資料筆數時,該如何在不犧牲功能的前提下壓低 DOM 節點數與渲染耗時?
- Q6:在現代瀏覽器中,動態新增 100 個列表項目時,使用
DocumentFragment、Element.append(...nodes)與迴圈呼叫innerHTML +=在底層運作上有什麼差異?
- Q1:為什麼在 Console 執行
- 可直接套用的程式碼範本:
如果不趕時間,建議順著下方章節往下讀,接下來會逐一講解相關底層運作原理與實作細節。
一、 原本常見的巢狀寫法瓶頸與 DOM 規模基準
1. 原本常見的包裝容器寫法與三大效能瓶頸
在傳統格線系統或元件庫層層封裝的專案中,為了實現外距置中、欄位排列、陰影邊框與內距留白,開發者常會在不知不覺中寫出多達 6 至 8 層的純排版 <div> 巢狀結構:
<!-- 傳統過度巢狀寫法(Divitis):僅為了顯示一張商品卡片就消耗了 7 層容器 -->
<div class="section-wrapper">
<div class="container">
<div class="row">
<div class="col-md-4">
<div class="card-outer-shadow">
<div class="card-inner-padding">
<div class="card-content-box">
<h3 class="card-title">高效能雲端主機方案</h3>
<p class="card-desc">提供低延遲網路與自動化備份機制。</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
當單一卡片多出 5 個非必要的包裝容器,一個顯示 100 張卡片的列表就會憑空多出 500 個 DOM 元素。在瀏覽器渲染引擎(如 Chromium Blink)中,過深的 DOM 節點深度與龐大的節點數量會引發以下三個獨立階段的效能瓶頸:
1. 樣式重新計算(Recalculate Style)與 CSS 選擇器由右至左回溯成本:瀏覽器 CSS 引擎在評估選擇器(如 .section-wrapper .row .card-content-box .card-title)是否匹配某個元素時,並非從左往右找。以 Chromium 的 Blink 引擎為例,css_selector.h 的註解寫明組合子(combinator)關係是「由右至左(Right-to-Left)」儲存,比對時先檢查最右側的部分是否符合目標元素;遇到後代組合子(空白)時,selector_checker.cc 會沿著父元素逐層往上找符合的祖先,直到找到或走到根節點為止。Blink 另有 SelectorFilter(類似 Bloom filter 的祖先特徵過濾器),能在走訪祖先前先快速排除明顯不可能匹配的規則,所以並不是每條規則都會完整回溯;但對於通過過濾、需要逐層比對祖先的後代選擇器,DOM 越深,往上走訪的層數就越多。再加上 web.dev 的樣式計算說明 指出,樣式計算的最壞成本約為「元素數量 × 選擇器數量」,巢狀越深、元素越多,Recalculate Style 階段的工作量就越大。
圖:CSS 引擎採由右至左匹配選擇器,後代選擇器需要逐層向上比對祖先節點(實際上 Blink 會先以 SelectorFilter 排除不可能匹配的規則,圖中為簡化流程)
2. 幾何佈局重排(Layout / Reflow)範圍擴大與擠壓 INP 互動預算:依 web.dev 的 INP 說明,Core Web Vitals 的 INP(Interaction to Next Paint,下次繪製互動延遲) 以實際使用者資料的第 75 百分位數評估:200ms 以內(含)為良好、超過 200ms 且在 500ms 以內為需要改善、超過 500ms 為不良。web.dev 的佈局效能說明 指出佈局幾乎都是以整份文件為範圍計算,元素越多,算出每個元素位置與尺寸的時間就越長;web.dev 的大型 DOM 與互動性說明 也提醒,互動時修改 DOM 可能引發昂貴的佈局、樣式、合成與繪製工作。當使用者點擊按鈕切換篩選狀態或動態插入節點時,龐大且未隔離的巢狀結構會讓主執行緒(Main Thread)在樣式與佈局計算上花更多時間,拉長視覺回饋的延遲。
圖:互動事件修改深層 DOM 節點時,連鎖的樣式與幾何重排會拉長畫面繪製延遲並劣化 INP 指標(圖中「突破 200ms 即為不良」為簡化說法:依 web.dev 門檻,超過 200ms 屬於需要改善,超過 500ms 才是不良)
3. 網路傳輸負載與記憶體開銷:每一個多餘的 <div> 標籤不僅增加初始 HTML 文件的網路下載位元組與解析時間,進入記憶體後還會在 Blink 引擎內建立對應的 C++ Element 物件,有參與排版的元素還會再配置排版物件(LayoutObject)。Chrome for Developers 的「Optimize DOM size」洞察說明 也直接指出大型 DOM 會增加記憶體用量。當 JavaScript 執行 document.querySelectorAll('div') 等大範圍 DOM 查詢時,瀏覽器必須走訪整棵樹狀結構,節點越多,查詢與回傳的 NodeList 也越大。
圖:過多冗餘包裝標籤會同步增加 HTML 傳輸體積、C++ DOM 節點記憶體佔用與全域查詢走訪時間
2. Chrome Lighthouse 與 DevTools 的三大 DOM 規模檢測指標
Lighthouse 與 Chrome DevTools 都會回報同樣三個 DOM 規模數字:總元素數(Total elements)、最大巢狀深度(DOM depth) 與 單一父節點最多子元素數(Most children)。但「多少才算超標」的判定方式改過好幾次,網路上常見的 32 層、60 個子元素 都是舊版數字,不能當成現行標準。
規範新舊差異:Lighthouse DOM 規模審計
| Lighthouse 版本 | 審計名稱 | 判定方式 |
|---|---|---|
| 5.x(2019 年) | dom-size(Avoid an excessive DOM size) | 原始碼 寫明總元素建議少於約 1,500 個,「理想範圍」為深度少於 32 層、單一父節點少於 60 個子元素 |
| 12.x 以前(官方文件 最後更新 2024-06-21,頁面已註明自 Lighthouse 13 起移入 Optimize DOM size 洞察) | dom-size | 只依 body 內的元素數評分:約 800 個開始警告、約 1,400 個判定為錯誤(v12 原始碼 的評分參數為 p10: 818、median: 1400);深度與子元素數只列出數值,不參與評分 |
| 13.0.0 起(2025-10-10 發布) | dom-size-insight(Optimize DOM size),取代 dom-size | 不再以元素數門檻判定。官方洞察說明 寫明:只有當追蹤期間出現耗時超過 40ms 的大型佈局(超過 100 個佈局物件)或大型樣式重算(影響超過 300 個元素)時才會提示;總元素數、深度與最多子元素數仍會列出,但沒有官方門檻 |
換句話說,在 Lighthouse 13 以後,「元素數超過 800」本身不再直接讓報告亮燈,真正被標記的是 DOM 太大所造成的「慢樣式重算」與「慢佈局」。官方洞察說明也提到,DOM 很深本身不一定是問題,但通常代表有不必要的巢狀包裝。舊版的 800/1,400 仍可當作整理 DOM 的參考量級,但要知道它們已不是現行 Lighthouse 的判定條件。
同時,為了清楚對比傳統結構與現代最佳實踐的差異,我們整理了四大核心效能維度的影響對照表:
| 效能評估維度 | 傳統多層巢狀結構 (Divitis) | 結構扁平化與 CSS Grid 優化後 |
|---|---|---|
| DOM 巢狀深度 | 包裝層數多,後代選擇器需要往上比對的祖先也多 | 移除無作用的包裝層,深度明顯降低 |
| 樣式重算 (Recalculate Style) | 由右至左回溯祖先節點成本高、耗時長 | 選擇器路徑短、祖先節點少,重算迅速 |
| INP 互動延遲 | 樣式重算與佈局工作量大,互動延遲容易超過 200ms | 需要重算的元素變少,較容易維持在良好範圍 |
| 記憶體與傳輸開銷 | HTML 檔案大、C++ DOM 物件多 | 檔案較小、DOM 物件較少 |
透過將傳統 6 層包裝結構重構為現代 CSS Grid 語意化結構,能直接砍除中間 4 層無意義的過渡容器:
圖:將傳統 6 層 div 格線包裝扁平化為 2 層語意化 CSS Grid 結構,大幅縮減 DOM 節點深度與總節點數
二、 四大結構扁平化與渲染減負策略
要在不影響視覺設計與響應式排版的前提下降低 DOM 節點深度,工程上可依序採取以下四種標準策略:
1. 以語意化容器搭配 CSS Grid / Flexbox 取代多層排版包裝
早期前端開發常依賴 .container > .row > .col 三層結構來處理欄寬與間距,並在內部再包兩層 <div> 控制卡片邊框與內距。在現代瀏覽器中,直接對外層 <section> 設定 display: grid 搭配 repeat(auto-fill, minmax(280px, 1fr)) 與 gap,並將邊框、圓角、陰影與 padding 統一寫在子層 <article> 上,即可用 2 層結構完美取代原本的 6 層嵌套。
2. 前端框架使用 Fragment(<></> / template)消除無意義元件根節點
在使用 React、Vue 等元件化框架開發時,開發者常為了符合單一根節點語法限制,而在每個子元件最外層隨手包一個 <div>。當元件樹嵌套 10 層時,真實 DOM 就會多出 10 層毫無樣式用途的包裝節點。在不需要獨立盒模型容器的元件中,應一律改用框架原生的 Fragment(React 的 <React.Fragment> 或 <></>、Vue 的多根節點或 <template>),讓子元素在轉譯後直接掛載至父容器下。
3. 利用 CSS display: contents 解開跨元件封裝容器限制
若因第三方套件或框架權限限制,無法移除中間層的包裝 <div class="item-wrapper">,但又希望內部子元素直接參與外層父容器的 Grid 或 Flex 佈局,可依據 W3C CSS Display Module Level 3 規範 對該中間層設定 display: contents。
設定 display: contents 的元素本身不會在排版樹(Layout Tree)中產生任何盒模型(Box),其子元素會在視覺排版上升格為外層父容器的直接子代(註:該元素本身的 background、border 與 padding 將不再生效,且切勿套用於 <button>、<table>、<ul> 等具無障礙語意角色的元素上)。
/* 當無法刪除中間層 .framework-wrapper 時,使其盒模型在排版中隱形 */
.product-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
}
.framework-wrapper {
/* 不產生此 div 的盒模型,讓內部的 .product-card 直接成為 .product-grid 的格線項目 */
display: contents;
}
4. 批次動態掛載(DocumentFragment)與離屏渲染隔離(content-visibility: auto)
當透過原生 JavaScript 動態渲染大量列表項目時,瀏覽器通常會把樣式與佈局計算延到這一幀的 JavaScript 執行完之後才做;真正的問題是在迴圈內逐一 appendChild() 的同時,又讀取 offsetHeight、getBoundingClientRect() 等版面數值,這會依 web.dev 的說明 觸發強制同步佈局(Forced Synchronous Layout),連續發生就是 Layout Thrashing。比較穩妥的做法是先呼叫 DOM 標準定義的 DocumentFragment(document.createDocumentFragment())建立不屬於主文件樹的輕量容器,待所有子節點組裝完畢後再一次插入真實 DOM,或是直接使用 Element.append(...nodes) 一次傳入多個節點;需要讀取版面數值時,集中在寫入之前讀完。
圖:透過 DocumentFragment 先在主文件樹之外完成子節點組裝,最後只對真實 DOM 做一次插入(Layout Thrashing 的成因是寫入與讀取版面數值交錯,並非 appendChild 本身)
此外,若長頁面包含大量位於首屏可視範圍(Viewport)以外的複雜區塊,可為離屏容器加上 content-visibility: auto 與 contain-intrinsic-size,讓瀏覽器直接略過離屏子樹的樣式與佈局計算,直到使用者捲動接近該區塊時才按需渲染。
三、 可直接套用的 DOM 扁平化與深度檢測程式碼範本
傳統巢狀結構 vs 結構扁平化:商品卡片重構完整對照
在進入具體程式碼之前,我們可以透過下方對照看出傳統多層包裝與現代 2 層扁平結構的差異:
- 重構前(傳統 6 層巢狀):使用
.section-wrapper > .container > .row > .col > .card-wrap > .card-body,導致 DOM 節點深、CSS 選擇器回溯成本高、樣式重算緩慢。 - 重構後(現代 2 層扁平):僅保留
<section class="pricing-grid">與<article class="pricing-card">,搭配 CSS Grid 輕鬆搞定格線與等高對齊。
範本一:將 6 層巢狀卡片列表重構為 2 層 CSS Grid 與 Subgrid 扁平結構
以下範本展示如何移除傳統 .container > .row > .col > .card-wrap > .card-body 的多餘包裝,僅用 <section> 與 <article>兩層結構配合 CSS Grid 及 subgrid,達成跨卡片標題、內文與底部按鈕自動等高對齊的排版效果:
<!-- 僅需 2 層容器結構(section > article),完全省略多餘的 row / col / wrapper div -->
<section class="pricing-grid" aria-label="方案列表">
<article class="pricing-card">
<h2 class="card-title">基礎開發版</h2>
<p class="card-desc">適合個人小型專案與靜態形象網站部署。</p>
<a href="/signup?plan=basic" class="card-cta">選擇基礎方案</a>
</article>
<article class="pricing-card">
<h2 class="card-title">企業高可用版</h2>
<p class="card-desc">提供跨區自動備援、專屬頻寬保障、自動化監控告警與全天候技術支援服務。</p>
<a href="/signup?plan=enterprise" class="card-cta">選擇企業方案</a>
</article>
</section>
/* 外層容器:直接以 CSS Grid 取代 container + row + col 三層結構 */
.pricing-grid {
max-width: 1140px;
margin-inline: auto;
padding: 2rem 1rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
/* 卡片本體:利用 subgrid 讓不同字數卡片的標題、描述、按鈕自動跨欄水平對齊,無須內部再包 wrapper */
.pricing-card {
grid-row: span 3;
display: grid;
grid-template-rows: subgrid;
gap: 1rem;
padding: 1.5rem;
background-color: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 0.75rem;
box-shadow: 0 4px 6px -1px rgba(15, 23, 42, 0.06);
}
.card-title {
margin: 0;
font-size: 1.25rem;
color: #0f172a;
}
.card-desc {
margin: 0;
color: #475569;
line-height: 1.6;
}
.card-cta {
align-self: end;
display: inline-block;
text-align: center;
padding: 0.625rem 1rem;
background-color: #2563eb;
color: #ffffff;
border-radius: 0.5rem;
text-decoration: none;
font-weight: 600;
}

grid-template-rows 計算值皆為 subgrid,按鈕頂端位置相同範本二:瀏覽器 Console 檢測頁面 DOM 總節點數、最大巢狀深度與超標容器腳本
將下方腳本貼入瀏覽器 DevTools 的 Console 面板執行,即可一次掃描當前頁面的「HTML 元素總數」、「包含文字與註解的總 DOM 節點數」、「最大巢狀深度與最深元素路徑」以及「擁有最多直接子元素的父容器」,並附上舊版 Lighthouse 的參考數字輸出診斷報告(如前述,Lighthouse 13 起已不以這些數字判定,僅供整理 DOM 時參考):
/**
* 檢測當前頁面 DOM 規模(數字參考舊版 Lighthouse dom-size 審計)
* 同時區分 HTML Element 數量與底層 DOM Node 總數,並定位最深節點與最大子元素容器
*/
function auditPageDomSize() {
// 800 / 1400 出自 Lighthouse 12 以前;32 / 60 出自 Lighthouse 5 的建議範圍
const THRESHOLDS = {
WARN_ELEMENTS: 800,
ERROR_ELEMENTS: 1400,
MAX_DEPTH: 32,
MAX_CHILDREN: 60,
};
let totalElements = 0;
let maxDepth = 0;
let deepestElement = null;
let maxChildren = 0;
let widestParent = null;
// 取得元素的精簡選擇器名稱以便識別
const getSelectorLabel = (el) => {
if (!el || !el.tagName) return 'unknown';
const id = el.id ? `#${el.id}` : '';
const cls = typeof el.className === 'string' && el.className.trim()
? `.${el.className.trim().split(/\s+/).slice(0, 2).join('.')}`
: '';
return `${el.tagName.toLowerCase()}${id}${cls}`;
};
// 以堆疊走訪所有 HTML Element,避免超過極端遞迴呼叫上限
const stack = [{ el: document.documentElement, depth: 1 }];
while (stack.length > 0) {
const { el, depth } = stack.pop();
totalElements++;
const childCount = el.children.length;
if (depth > maxDepth) {
maxDepth = depth;
deepestElement = el;
}
if (childCount > maxChildren) {
maxChildren = childCount;
widestParent = el;
}
for (let i = childCount - 1; i >= 0; i--) {
stack.push({ el: el.children[i], depth: depth + 1 });
}
}
// 使用 TreeWalker 統計包含空白文字節點 (Text) 與註解 (Comment) 的總 Node 數
let totalAllNodes = 0;
const walker = document.createTreeWalker(document, NodeFilter.SHOW_ALL);
while (walker.nextNode()) {
totalAllNodes++;
}
// 建立最深元素的祖先鏈路徑
const pathChain = [];
let curr = deepestElement;
while (curr && curr.nodeType === Node.ELEMENT_NODE) {
pathChain.unshift(getSelectorLabel(curr));
curr = curr.parentElement;
}
const report = {
'1. HTML 元素總數 (Elements)': `${totalElements} (舊版參考:警告 > ${THRESHOLDS.WARN_ELEMENTS}, 過大 > ${THRESHOLDS.ERROR_ELEMENTS})`,
'2. 總 DOM 節點數 (含 Text/Comment)': totalAllNodes,
'3. 最大巢狀深度 (Max Depth)': `${maxDepth} 層 (舊版參考 < ${THRESHOLDS.MAX_DEPTH} 層)`,
'4. 最深元素選擇器': getSelectorLabel(deepestElement),
'5. 單一父節點最大子元素數': `${maxChildren} 個 (舊版參考 < ${THRESHOLDS.MAX_CHILDREN} 個)`,
'6. 子元素最多的父容器': getSelectorLabel(widestParent),
};
console.group('🔍 DOM 結構規模與巢狀深度診斷報告');
console.table(report);
console.log('📍 最深元素完整路徑:', pathChain.join(' > '));
console.log('📍 最深 DOM 元素實例:', deepestElement);
console.log('📍 子元素最多的父容器實例:', widestParent);
console.groupEnd();
return { totalElements, totalAllNodes, maxDepth, deepestElement, maxChildren, widestParent };
}
auditPageDomSize();

console.table、console.log 的輸出內容四、 開發者常見問題(Q&A)與排查注意事項
Q1:為什麼在 Console 執行 document.querySelectorAll('*').length 的數字,和 DevTools Performance Monitor 顯示的 DOM nodes 數量差了好幾倍?
原因說明:根據 Google web.dev 官方文件對 DOM Elements 與 DOM Nodes 的定義區分,DOM 元素(element)只指 HTML 元素,而 DOM 節點(node)還包含註解、空白與文字。document.querySelectorAll('*') 僅會統計 元素節點(Node.ELEMENT_NODE);Chrome DevTools 的 Performance Monitor(效能監視器) 所顯示的 DOM nodes 指標則來自 Blink 的節點計數器,從 Blink node.cc 可以看到,每個 Node 物件建立時計數加一、物件被銷毀時才減一,因此統計範圍包含了 HTML 原始碼縮排換行產生的空白文字節點(Node.TEXT_NODE)、HTML 註解節點(Node.COMMENT_NODE)、Shadow DOM 內部節點,以及已被 JavaScript 從畫面移除但因變數仍持有參考而尚未被垃圾回收器(Garbage Collector)釋放的「游離 DOM 節點(Detached DOM Nodes)」。
解法:對照 Lighthouse 報告中的「Total elements」或舊版 800/1,400 參考數字時,請以 document.querySelectorAll('*').length 或上方範本二的 totalElements 作為基準;若發現 Performance Monitor 的 DOM nodes 在多次開關彈窗後持續只升不降,則應切換至 DevTools 的 Memory(記憶體) 面板拍攝 Heap Snapshot,搜尋 Detached 關鍵字找出未解除事件監聽或全域變數參考的記憶體洩漏點。
Q2:為什麼減少 DOM 節點深度不只能加快首屏渲染,還能顯著改善 Core Web Vitals 的 INP(下次繪製互動延遲)指標?
原因說明:依 web.dev 的 INP 說明,Core Web Vitals 的 INP(Interaction to Next Paint) 衡量的是使用者點擊、輕觸或按鍵後,到瀏覽器呈現下一個畫面的延遲(良好標準為 <= 200ms),一次互動的延遲分為「輸入延遲(Input Delay)」、「事件處理器執行時間(Processing Duration)」與「畫面呈現延遲(Presentation Delay)」三部分。當互動事件處理器修改了某個元素的 Class 或插入新節點時,瀏覽器必須在下一個畫面呈現前執行 Recalculate Style(樣式重算)與 Layout(佈局重排);頁面元素越多、選擇器越複雜,切換一個狀態可能連帶讓大量元素重新計算樣式,拉長呈現延遲。這也是 DOM size 洞察在 DevTools 效能洞察原始碼 中被歸在 INP 類別、並以「慢樣式重算/慢佈局」作為提示條件的原因。
解法:在 Chrome DevTools 的 Performance 面板錄製慢速互動後,點選主執行緒火焰圖中的紫色 Recalculate Style 區塊,檢視底部 Summary 面板的 Elements affected(受影響元素數)。若單次局部互動竟影響了數百至數千個元素,除了扁平化 DOM 巢狀層級外,也應降低 CSS 後代選擇器的複雜度,並為獨立區塊加上 contain: layout style; 以截斷跨區域的重排連鎖反應。
Q3:使用 CSS display: contents 讓外層包裝容器在排版中「隱形」時,為什麼有時候會導致螢幕閱讀器無法辨識表格、清單或按鈕?
原因說明:依據 W3C CSS Display Module Level 3 規範,display: contents 會指示瀏覽器只渲染該元素的子節點,而不為該元素本身產生任何排版盒模型(Box)。規範本身明定 display 不影響元素的語意,但 MDN display 的無障礙說明 記載,部分瀏覽器的實作會把 display: contents 的元素本身從無障礙樹(Accessibility Tree)中移除(子元素仍保留),使該元素不再被螢幕閱讀器朗讀,MDN 也註明這是不符合 CSS 規範的錯誤行為。套用在 <table>、<tr>、<ul>、<ol> 或 <button> 這類帶有語意角色的元素上時,就可能讓螢幕閱讀器無法正確朗讀清單項數、表格行列關係或按鈕角色。另外,規範附錄 B 規定 <img>、<input>、<iframe>、<video>、<select>、<textarea> 等替換元素與表單控制項套用 display: contents 時,會直接算成 display: none,整個元素消失。
解法:嚴格遵守「僅對純包裝用途的無語意 <div class="..."> 或 <span class="..."> 使用 display: contents」的原則,絕不可將 display: contents 套用於任何具備語意或互動功能的元素(如 <nav>、<ul>、<li>、<table>、<button>、<a>)以及替換元素(如 <img>、<input>、<iframe>)。若需要讓具有語意與背景邊框的 <article> 或 <li> 內部子元素對齊外層格線,請改用範例一所示的 CSS Grid subgrid(grid-template-rows: subgrid)。
Q4:將原本用 display: none 隱藏的數十個彈跳視窗、下拉選單或頁籤內容留在初始 HTML 中,會不會算進 DOM 節點總數並拖慢頁面?
原因說明:許多開發者誤以為只要加上 display: none 或 hidden 屬性,瀏覽器就會完全忽略這些元素。事實上,display: none 僅會將該元素及其子樹排除在「排版樹(Layout / Render Tree)」之外,省去幾何寬高計算與像素繪製;但這些隱藏標籤在 HTML 解析階段依然會被 100% 實例化為記憶體中的 DOM 節點,完全計入 Lighthouse 的總元素數統計,同時增加初始 HTML 下載體積與 document.querySelectorAll() 全域查詢需要走訪的範圍。
解法:正如同 web.dev「Consider an additive approach」一節 所建議,對於首屏預設不可見的複雜結構(如多步驟表單彈窗、大型 Mega Menu 子選單、未開啟的折疊面板或非當前頁籤內容),應採「按需建立(Additive Approach / Lazy Mounting)」策略:在初始 HTML 或首次渲染時不輸出該子樹,直到使用者點擊開啟或滑鼠懸停時才動態掛載至 DOM,並在關閉後銷毀無須保留狀態的節點。
Q5:當頁面對應長列表(如 2,000 筆資料列)無法減少資料筆數時,該如何在不犧牲功能的前提下壓低 DOM 節點數與渲染耗時?
原因說明:如果將 2,000 筆資料列(每列包含 8 個欄位與操作按鈕)一次性全部渲染進畫面,僅表格儲存格就會產生 16,000 個以上的 DOM 元素,是舊版 Lighthouse 1,400 個元素錯誤門檻的十倍以上;初次掛載容易形成長任務(Long Task),後續的狀態更新也要對大量元素做樣式重算與佈局。
解法:請依據資料規模採取兩階段減負方案:
/* 方案 A:針對數百筆的中長列表或長文區塊,啟用 CSS 離屏延遲渲染 */
.data-row-section {
/* 讓瀏覽器自動跳過可視範圍 (Viewport) 以外區塊的樣式與佈局計算 */
content-visibility: auto;
/* 預留離屏區塊的預估高度(例如 64px),防止捲動時捲軸高度劇烈跳動 */
contain-intrinsic-size: auto 64px;
}
若資料規模達上千筆以上,僅靠 CSS content-visibility: auto 仍無法消除上萬個 DOM 節點的記憶體佔用,此時應引入 虛擬捲動(Virtual Scrolling / Windowing,如 TanStack Virtual 的 @tanstack/react-virtual 或 react-window),根據容器捲動位置動態計算當前可視區間,使真實 DOM 樹只保留可視範圍與前後緩衝區的少量資料列。
Q6:在現代瀏覽器中,動態新增 100 個列表項目時,使用 DocumentFragment、Element.append(...nodes) 與迴圈呼叫 innerHTML += 在底層運作上有什麼差異?
原因說明:最需要避免的反模式是在迴圈中執行 list.innerHTML += '<li>...</li>'。因為每次讀取並賦值 innerHTML += 時,瀏覽器都必須先將容器內所有既有 DOM 節點序列化回 HTML 字串、拼接新字串後,再將容器內原本已建立的所有子節點全數銷毀,並重新啟動 HTML Parser 從頭解析整串 HTML,這不僅讓時間複雜度退化為 O(N^2),還會導致容器內原先綁定的 DOM 事件監聽器全部失效。
解法:若需動態建立並插入大量節點,請使用 document.createDocumentFragment() 或現代 DOM 標準的 Element.append(...nodes),在主文件樹之外建立好所有 <li> 元素後一次插入,既不會破壞既有兄弟節點的事件綁定,也不需要重新解析整串 HTML:
/**
* 批次建立並掛載列表節點(不重複解析 HTML、不破壞既有節點事件、單次插入)
* @param {HTMLElement} listContainer 目標列表容器 (如 <ul>)
* @param {string[]} itemsData 待渲染的文字陣列
*/
function appendListItemsBatch(listContainer, itemsData) {
const fragment = document.createDocumentFragment();
for (const text of itemsData) {
const li = document.createElement('li');
li.className = 'list-item';
li.textContent = text; // 使用 textContent 自動防禦 XSS 並省去 HTML 解析開銷
fragment.appendChild(li);
}
// 將虛擬容器內的子節點一次性移入真實 DOM 樹
listContainer.appendChild(fragment);
}

<img src=x onerror=alert(1)>」三筆資料的對照五、 工程快速檢核清單 (Checklist)
在提交前端切版或元件重構 PR 前,建議對照以下清單完成結構檢查:
- ☐ 控制頁面總元素數量:透過
document.querySelectorAll('*').length檢查元素總數,可用舊版 Lighthouse 的800/1,400當參考量級;Lighthouse 13 起改看 DOM size 洞察是否標出超過40ms的大型樣式重算或佈局。 - ☐ 限制最大巢狀深度:用範本二找出最深路徑,移除無實質排版作用的過渡
<div>(舊版 Lighthouse 的參考值為少於32層)。 - ☐ 以現代 CSS 取代多層包裝:使用 CSS Grid、
subgrid與 Flexbox 的gap屬性處理間距與等高對齊,避免套用三層以上的傳統格線包裝容器。 - ☐ 善用框架 Fragment 語法:在 React / Vue 元件中返回多個並列元素時,優先使用
<></>或<template>,避免汙染真實 DOM 階層。 - ☐ 安全使用
display: contents:僅對無語意的純包裝<div>套用display: contents,絕不套用於<button>、<ul>、<table>等具無障礙語意角色的元素。 - ☐ 延遲掛載不可見區塊與長列表:將預設未開啟的複雜 Modal 與非活動頁籤改為互動時按需掛載(Lazy Mounting),並為長頁面離屏區塊加上
content-visibility: auto。
參考資料與延伸閱讀
- DOM 樹狀結構與渲染引擎揭秘:Reflow (重排) 與 Repaint (重繪) 的效能優化全要點 – 本站前導文章,說明 DOM 樹建構與 Reflow/Repaint 的基礎機制。
- Core Web Vitals LCP 最大內容繪製 – 本站 Core Web Vitals 系列,拆解 LCP 四個子階段的優化方式。
- Chrome for Developers — Optimize DOM size – Lighthouse 13 與 DevTools 現行的 DOM size 洞察說明(40ms、100 個佈局物件、300 個元素的提示條件)。
- Chrome for Developers — Avoid an excessive DOM size – 舊版 Lighthouse
dom-size審計文件(約 800 個元素警告、約 1,400 個元素錯誤)。 - Lighthouse v13.0.0 Release Notes – 移除被效能洞察取代的舊審計項目。
- web.dev — Interaction to Next Paint (INP) – INP 良好/需要改善/不良門檻與三段延遲組成。
- web.dev — How large DOM sizes affect interactivity, and what you can do about it – 說明 DOM 元素與節點的差異、大型 DOM 對互動性的影響,以及按需載入與
content-visibility策略。 - web.dev — Avoid large, complex layouts and layout thrashing – 佈局範圍、強制同步佈局與 Layout Thrashing。
- Chromium Blink —
css_selector.h– Blink 選擇器資料結構註解,說明組合子由右至左儲存。 - web.dev — Reduce the scope and complexity of style calculations – 剖析瀏覽器 CSS 選擇器匹配成本與降低
Recalculate Style耗時的最佳實踐。 - W3C CSS Display Module Level 3 — Box Generation (
display: contents) – W3C 官方定義display: none與display: contents在盒模型生成與排版樹中的底層行為。 - MDN —
DocumentFragment– 文件片段容器的介面說明,頁內附 WHATWG DOM 標準出處。 - MDN —
display(contents無障礙說明) – 記載部分瀏覽器將display: contents元素移出無障礙樹的問題。





發佈留言