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

HTML, 新手 Jamp Holes

HTML main 標籤的唯一性迷思:頁面中真的只能有一個嗎?

HTML main 標籤的唯一性迷思:頁面中真的只能有一個嗎? 特色圖片

💡 文章重點摘要:在前端開發與程式碼審查(Code Review)中,許多工程師常認為「一份 HTML 文件只能寫一個 HTML main 標籤(<main>)」。然而,翻開 WHATWG HTML Living Standard (§4.4.14) 官方規範就會發現:標準真正限制的是「同一時間最多只能有一個未設定 hidden 屬性的 <main> 元素」。本文將從官方規格出發,完整解析單頁應用(SPA)多視圖切換架構、祖先元素階層限制(Hierarchically Correct main),以及 main[hidden] 被自訂 CSS display: flex / grid 意外覆蓋的解決方式。

在規劃網頁骨架時,<main> 負責包覆整張頁面最核心的獨有內容(排除跨頁重複的導覽列、側邊欄與版權頁尾),並讓螢幕閱讀器使用者能透過快捷鍵(或 Skip Link)一鍵跳過頂部選單直達正文。過去在 W3C HTML 5.0 早期規範中,確實曾規定一份文件不得出現超過一個 <main>;但隨著單頁應用(SPA)與多步驟表單介面普及,WHATWG HTML Living Standard 早已調整了對 HTML main 標籤 的規範定義。

究竟什麼情況下可以在 DOM 樹中同時保留多個 <main>?如果用 CSS display: none 代替 HTML 的 hidden 屬性,為什麼還是會違反語法標準?以下先提供 30 秒即可複製使用的多視圖切換範本,再逐步拆解底層規則。


⚡ 快速簡單使用(30 秒複製即用範本)

當你在實作「無須重新載入頁面即可切換主視圖(例如:『數據總覽』切換至『帳務設定』)」的介面,且希望預先將 DOM 結構保留在頁面上時,請遵循以下符合 WHATWG §4.4.14 規範的標準寫法:

<!DOCTYPE html>
<html lang="zh-Hant-TW">
<head>
  <meta charset="UTF-8">
  <style>
    /* 1. 關鍵防護:當對 main 設定 display: grid 或 flex 時,必須補上 [hidden] 保護,避免覆蓋瀏覽器預設隱藏樣式 */
    main[hidden] {
      display: none !important;
    }

    main {
      display: grid;
      gap: 1.5rem;
      padding: 1.5rem;
    }
  </style>
</head>
<body>
  <header>
    <!-- 2. Skip Link 跳至當前可見的主內容區 -->
    <a href="#view-overview" id="skip-to-main">跳至主要內容</a>
    <nav aria-label="後台功能視圖切換">
      <button type="button" data-target="view-overview" aria-current="page">數據總覽</button>
      <button type="button" data-target="view-billing">帳務設定</button>
    </nav>
  </header>

  <!-- 3. 當前可見的唯一 main(不加 hidden 屬性) -->
  <main id="view-overview" tabindex="-1">
    <h1>數據總覽儀表板</h1>
    <p>這裡是當前顯示的主要內容區域。</p>
  </main>

  <!-- 4. 預先載入但目前未啟用的第二個 main(必須明確加上原生 hidden 屬性) -->
  <main id="view-billing" hidden tabindex="-1">
    <h1>帳務與訂閱設定</h1>
    <p>切換至此視圖時,將移除此處的 hidden,並為其他 main 加上 hidden。</p>
  </main>
</body>
</html>

一、 5 秒快速索引:WHATWG 現行規範 vs. 傳統認知對照表

為了協助開發團隊快速核對 HTML main 標籤 的合規條件,以下整理 WHATWG HTML Living Standard (§4.4.14 The main element) 與 W3C HTML-AAM 1.0 的核心規範:

檢核項目早期傳統認知(舊版 HTML 5.0)WHATWG HTML Living Standard 現行官方規範實務影響與檢測工具判定
單頁允許數量整個 DOM 只能有 1 個 <main>可存在多個 <main>,但同一時間最多只能有 1 個「未加 hidden 屬性」的 <main>若同時有 2 個以上無 hidden 的 <main>,W3C Nu Validator 與 Lighthouse 會直接報錯。
隱藏非活動 <main> 的方式混用 .hidden { display: none } 或 hidden規範明確要求使用 HTML 原生 hidden 屬性(含 hidden="until-found")若僅寫 CSS style="display: none" 而未加 hidden 屬性,HTML 語法驗證器仍視為違規。
合法的父層與祖先元素只要放在 <body> 裡即可祖先僅限 <html>、<body>、<div> 或無無障礙名稱的 <form>不可將 <main> 放在 <article>、<section>、<aside>、<header>、<footer> 或 <nav> 內。
W3C 無障礙樹映射角色role="main"階層正確且未隱藏之 <main> 映射為 role="main" 主地標供螢幕閱讀器與鍵盤 Skip Link 直接定位整頁核心正文(詳見《WCAG 無障礙標準全解析》)。
是否支援巢狀(<main> 包 <main>)禁止禁止(<main> 的祖先不得為另一個 <main>)違反階層正確性(Not a hierarchically correct main element)。

二、 官方規格深度解析:什麼才算是合法的 main 標籤配置?

許多文章會將 <main> 的數量限制簡化為「邏輯上的唯一性」,但瀏覽器解析器與 W3C 標準驗證器(Validator)依據的是兩條非常明確的硬性語法規則:

1. 規則一:可見性唯一原則(At most one visible main)

根據 WHATWG §4.4.14 原文定義:

"A document must not have more than one main element that does not have the hidden attribute specified."
(一份文件不得擁有超過一個未指定 hidden 屬性的 main 元素。)

這項規則是專為 SPA 多視圖快取或條件式畫面切換所設計的。當網頁預先渲染了多個視圖容器時,只要確保當前畫面上僅有 1 個 <main> 沒有 hidden 屬性,而其餘所有備用 <main> 皆明確標註了 hidden 屬性,那麼無論在 HTML 語法驗證器、搜尋引擎爬蟲(Googlebot)還是螢幕閱讀器無障礙樹中,都 100% 合法且只會暴露出當前活動的那一個 role="main" 地標。

2. 規則二:階層正確性原則(Hierarchically correct main element)

除了數量限制,WHATWG 還定義了「階層正確的 main 元素(Hierarchically correct main element)」:從 <main> 一路往上追溯到根元素 <html> 的所有祖先節點(Ancestors),只能是 <html>、<body>、<div> 或「未設定無障礙名稱(No accessible name)的 <form>」。

換言之,如果你把 <main> 包在 <section>、<article> 或 <aside> 裡面,即便全頁只有這 1 個 <main>,依然違反了 HTML 標準!請透過下圖的判定流程檢視完整規則:

圖 1:WHATWG HTML Living Standard (§4.4.14) — main 標籤數量與祖先階層合法性判定樹
圖 1:WHATWG HTML Living Standard (§4.4.14) — main 標籤數量與祖先階層合法性判定樹

三、 實務開發常見的 3 個配置盲點與解決方式

在實際專案中導入多重 <main> 或調整版面結構時,最常遇到以下三個技術細節問題:

1. 盲點一:為 main 設定 display: flex 或 display: grid 意外覆蓋了 hidden 屬性

這是在現代 CSS 排版中最容易忽略的樣式優先權問題!

  • 成因解析:HTML 的 hidden 屬性在瀏覽器內建樣式表(User-Agent Stylesheet)中的實作僅為 [hidden] { display: none; }。當你在專案 CSS 中寫下 main { display: grid; } 或 main { display: flex; } 時,因為作者樣式表(Author Stylesheet)的優先權高於瀏覽器預設樣式,這行規則會直接覆蓋掉 [hidden] 的 display: none,導致所有加了 hidden 的備用 <main> 全部同時顯示在畫面上!
  • 解法:在全域 CSS Reset 中加入一行防護規則:
/* 確保帶有 hidden 屬性的元素不會被 display: flex / grid 覆蓋而意外顯示 */
[hidden] {
  display: none !important;
}

2. 盲點二:只用 CSS .d-none { display: none } 隱藏第 2 個 main,卻沒加 HTML hidden 屬性

有些開發者疑問:「既然在渲染樹(Render Tree)與無障礙樹(Accessibility Tree)中,設定 CSS display: none 也會把元素隱藏起來,為什麼一定要寫 HTML 的 hidden 屬性?」

  • 標準規範差異:
    1. HTML 語法與靜態分析層面:W3C HTML Validator、ESLint jsx-a11y 以及部分不解析外部 CSS 的輕量級爬蟲在檢視 HTML DOM 時,是直接檢查 <main> 標籤上是否存在 hidden 屬性。若只靠外部 CSS 類別隱藏,HTML 驗證器依然會判定違反「超過一個未標註 hidden 之 <main>」錯誤。
    2. 無樣式環境與閱讀器模式(Reader Mode):當網路異常導致 CSS 載入失敗,或使用者開啟瀏覽器內建的「閱讀模式」擷取 HTML 原始結構時,原生的 hidden 屬性屬於語意層級的「當前不相關(Not yet or no longer relevant)」,能確保備用視圖不會被誤抓進文章正文。

3. 盲點三:視圖切換時忘記同步更新 Skip Link(跳至主要內容)與鍵盤焦點

當你在 SPA 中透過 JavaScript 將原本的 <main id="view-a"> 加上 hidden,並把 <main id="view-b"> 的 hidden 移除時,別忘了頁首(參考《網頁無障礙:別讓 header 成為螢幕閱讀器的盲點》)還有一顆「跳至主要內容」的 Skip Link!

  • 解法:在切換視圖的函式中,同步將 Skip Link 的 href 指向當前顯示的 <main> ID(例如 #view-b),或將焦點移至新視圖容器,如下圖所示:
圖 2:SPA 多重 main 視圖切換架構、CSS main(hidden) 防護與無障礙樹單一地標映射圖
圖 2:SPA 多重 main 視圖切換架構、CSS main(hidden) 防護與無障礙樹單一地標映射圖

四、 實戰程式碼對比:從常見錯誤結構到符合 WHATWG 標準的多視圖切換

以下我們對比常見的錯誤寫法,並提供包含自動化無障礙與規格檢驗腳本的完整生產級範例。

1. ❌ 常見錯誤寫法(同時暴露多個可見 main、誤包進 section、display: flex 覆蓋 hidden)

<style>
  /* ❌ 問題 1:直接設定 main { display: flex } 且未保護 [hidden],導致下方加了 hidden 的 main 依然顯示在畫面上 */
  main {
    display: flex;
    flex-direction: column;
  }
</style>

<!-- ❌ 問題 2:將 <main> 包在 <section> 內部,違反 WHATWG 階層正確性(Hierarchically correct main)規範 -->
<section class="page-wrapper">
  <!-- ❌ 問題 3:把兩張並排的獨立卡片都寫成 <main>,導致同頁同時存在多個未隱藏的 main 地標 -->
  <main class="card-left">
    <h2>左側熱門文章列表</h2>
  </main>

  <main class="card-right">
    <h2>右側最新公告列表</h2>
  </main>

  <main id="backup-view" hidden>
    <h2>原本想隱藏的備用視圖,卻因上方 main { display: flex } 而意外顯示出來</h2>
  </main>
</section>

2. 🟢 生產級標準重構範例(符合 WHATWG §4.4.14 多視圖 hidden 切換 + 自動化規格診斷腳本)

<!DOCTYPE html>
<html lang="zh-Hant-TW">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>WHATWG 標準多重 main 標籤與 hidden 視圖切換實戰範本</title>
  <style>
    /* ✅ 1. 強制保護 hidden 屬性,避免被 display: grid / flex 覆蓋 */
    [hidden] {
      display: none !important;
    }

    body {
      margin: 0;
      font-family: system-ui, -apple-system, sans-serif;
    }

    .view-switcher {
      display: flex;
      gap: 0.75rem;
      padding: 1rem 1.5rem;
      background: #f8fafc;
      border-bottom: 1px solid #e2e8f0;
    }

    .view-switcher button[aria-current="page"] {
      background: #2563eb;
      color: #ffffff;
      font-weight: 600;
    }

    main {
      display: grid;
      gap: 1rem;
      max-width: 760px;
      margin: 0 auto;
      padding: 2rem 1.5rem;
    }
  </style>
</head>
<body>
  <header>
    <a href="#view-dashboard" id="skip-link" class="skip-link">跳至主要內容</a>
    <nav class="view-switcher" aria-label="主視圖切換">
      <button type="button" data-view="view-dashboard" aria-current="page">數據總覽視圖</button>
      <button type="button" data-view="view-settings">系統設定視圖</button>
    </nav>
  </header>

  <!-- ✅ 視圖 1:預設可見的唯一 <main>(直接位於 <body> 下,符合階層正確性) -->
  <main id="view-dashboard" tabindex="-1">
    <h1>數據總覽視圖(當前活動的 Main 地標)</h1>
    <p>同一時間整張網頁只有這一個 main 沒有 hidden 屬性,無障礙樹僅暴露單一 role="main" 地標。</p>
  </main>

  <!-- ✅ 視圖 2:預先渲染但標註 hidden 的備用 <main> -->
  <main id="view-settings" hidden tabindex="-1">
    <h1>系統設定視圖(切換後才會成為活動的 Main 地標)</h1>
    <p>當切換至此視圖時,上方的數據總覽會自動加上 hidden 屬性。</p>
  </main>

  <script>
    // 1. 多視圖切換控制器:確保任何時刻僅有 1 個 <main> 不帶 hidden 屬性
    const buttons = document.querySelectorAll('.view-switcher button[data-view]');
    const mains = document.querySelectorAll('main');
    const skipLink = document.getElementById('skip-link');

    function switchMainView(targetId) {
      mains.forEach((mainEl) => {
        const isTarget = mainEl.id === targetId;
        mainEl.hidden = !isTarget;
      });

      buttons.forEach((btn) => {
        if (btn.dataset.view === targetId) {
          btn.setAttribute('aria-current', 'page');
        } else {
          btn.removeAttribute('aria-current');
        }
      });

      // 同步更新 Skip Link 的目標錨點
      if (skipLink) {
        skipLink.setAttribute('href', `#${targetId}`);
      }
    }

    buttons.forEach((btn) => {
      btn.addEventListener('click', () => switchMainView(btn.dataset.view));
    });

    // 2. 🔍 WHATWG §4.4.14 <main> 規範合規性自動檢測腳本
    function validateMainElements() {
      const allMains = [...document.querySelectorAll('main')];
      const visibleMains = allMains.filter(m => !m.hasAttribute('hidden'));
      const allowedAncestors = new Set(['HTML', 'BODY', 'DIV', 'FORM']);

      const invalidHierarchyMains = allMains.filter((m) => {
        let parent = m.parentElement;
        while (parent) {
          if (!allowedAncestors.has(parent.tagName)) return true;
          if (parent.tagName === 'FORM' && (parent.hasAttribute('aria-label') || parent.hasAttribute('aria-labelledby'))) {
            return true;
          }
          parent = parent.parentElement;
        }
        return false;
      });

      // 檢查是否有加了 hidden 卻因 CSS 覆蓋而未隱藏的 main
      const brokenHiddenMains = allMains.filter(
        m => m.hasAttribute('hidden') && window.getComputedStyle(m).display !== 'none'
      );

      console.log('=== [WHATWG HTML main 標籤合規性檢測報告] ===');
      console.log('1. DOM 中 <main> 總數 / 當前可見(無 hidden)數量:', `${allMains.length} / ${visibleMains.length}`);
      console.log('2. 符合「最多僅 1 個可見 <main>」規範:', visibleMains.length === 1);
      console.log('3. 所有 <main> 皆符合祖先階層限制 (Hierarchically Correct):', invalidHierarchyMains.length === 0);
      console.log('4. 零 main[hidden] 被 CSS display 意外覆蓋:', brokenHiddenMains.length === 0);
    }

    validateMainElements();
  </script>
</body>
</html>

Expected Output (預期 Console 輸出結果)

=== [WHATWG HTML main 標籤合規性檢測報告] ===
1. DOM 中 <main> 總數 / 當前可見(無 hidden)數量: 2 / 1
2. 符合「最多僅 1 個可見 <main>」規範: true
3. 所有 <main> 皆符合祖先階層限制 (Hierarchically Correct): true
4. 零 main[hidden] 被 CSS display 意外覆蓋: true

五、 上線前 5 點自我檢驗清單 (Checklist)

在完成頁面佈局或實作 SPA 多視圖切換前,請逐一核對以下 5 項指標:

  • ☐ 1. 同一時間僅有 1 個無 hidden 的 <main>:確認頁面上未標註 hidden 屬性的 <main> 數量剛好為 1,避免把頁面內的並排區塊或卡片誤寫成多個可見的 <main>。
  • ☐ 2. 祖先節點符合階層正確性(Hierarchically Correct):確認從 <main> 往上到 <html> 之間,僅經過 <body>、<div> 或無命名的 <form>,沒有被包在 <article>、<section>、<aside>、<header>、<footer> 或 <nav> 內。
  • ☐ 3. 全域 CSS 已配置 [hidden] { display: none !important; }:確認對 <main> 設定 display: flex 或 display: grid 時,不會覆蓋掉備用 <main hidden> 的隱藏狀態。
  • ☐ 4. 使用原生 hidden 屬性而非單靠 CSS class 隱藏:若有多個預先渲染的 <main> 視圖,確認非活動視圖皆直接加上 HTML hidden 屬性(透過 JS element.hidden = true 控制),確保通過 W3C HTML 驗證器檢查。
  • ☐ 5. 支援 Skip Link 跳轉與鍵盤聚焦:確認可見的 <main> 設有對應的 id 與 tabindex="-1",且切換視圖時頁首的「跳至主要內容」連結能正確指向當前活動的 <main>。

參考資料與延伸閱讀

發表迴響