紀錄工作經驗、相關知識,解決技術相關問題。

HTML, 新手 Jamp Holes, 網站優化, 網站相關

DOM 節點深度與結構扁平化優化實作:避免過度巢狀、降低樣式重算與提升 INP 互動效能

DOM 節點深度與結構扁平化優化實作:避免過度巢狀、降低樣式重算與提升 INP 互動效能 特色圖片

Table of Contents

在先前的 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 互動延遲超標問題,可以直接點擊下方連結跳轉至對應解答與程式碼範本:

如果不趕時間,建議順著下方章節往下讀,接下來會逐一講解相關底層運作原理與實作細節。


一、 原本常見的巢狀寫法瓶頸與 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 選擇器由右至左向上回溯深層父節點示意圖

圖: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 超標示意圖

圖:互動事件修改深層 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 層過度巢狀結構與現代 2 層 CSS Grid 扁平結構對照圖

圖:將傳統 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 在記憶體批次組裝節點並一次性掛載示意圖

圖:透過 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;
}
兩張方案卡片的描述文字一行與兩行不同,標題、描述列與底部按鈕仍各自水平對齊
Chrome 156(headless)、Windows 11 執行結果:兩張卡片的 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();
auditPageDomSize 診斷報告:HTML 元素 118 個、總 DOM 節點 189 個、最大巢狀深度 44 層、最深元素 div.lv.lv-40、子元素最多的父容器 ul#wide 有 70 個子元素
Chrome 156(headless)、Windows 11 執行結果:測試頁含 40 層巢狀 div 與 70 個 li 的清單,圖中表格與路徑為腳本 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);
}
左欄以 appendListItemsBatch 插入 3 筆,原本的 li 保留且點擊監聽器觸發 1 次;右欄以迴圈 innerHTML += 插入,原本的 li 被替換、監聽器觸發 0 次,含 img 字串的項目被解析成 img 元素
Chrome 156(headless)、Windows 11 執行結果:同樣插入「第一項、第二項、<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。

參考資料與延伸閱讀

發佈留言