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

HTML, Javascript, 新手 Jamp Holes, 網站相關

React JSX 轉譯與 DOM 結構:Fragment 應用、多態元件 as 模式與層級錯位排查

React JSX 轉譯與 DOM 結構:Fragment 應用、多態元件 as 模式與層級錯位排查 特色圖片

Table of Contents

在先前的系列文章中,我們已經探討過原生 HTML 結構與渲染效能的基礎規範:包含在 HTML 大小寫與引號規範:別讓細節成為生產環境的隱形炸彈 中整理的 HTML 屬性與 JSX 駝峰屬性(className、htmlFor、tabIndex)對照表、在 別再只用 div 與 span:HTML 內聯與區塊元素(Inline vs Block)底層渲染邏輯與常見語法地雷 中剖析的 <p> 內嵌區塊元素自動閉合機制,以及在 DOM 節點深度與結構扁平化優化實作:避免過度巢狀、降低樣式重算與提升 INP 互動效能 中介紹的 Lighthouse DOM 深度閾值與 CSS Subgrid 扁平化佈局。

本篇將不再重複上述靜態 HTML 規則與基礎屬性表,而是聚焦於現代前端工程的 「React JSX 轉譯與元件封裝下的真實 DOM 結構控制」——解析為什麼把 HTML 拆成獨立元件後會破壞父子層級與 SSR 水合(Hydration),並提供 <React.Fragment> 迴圈鍵值綁定、多態元件(Polymorphic as Prop)動態標籤切換,以及 TypeScript ComponentPropsWithoutRef 屬性透傳與型別約束的完整解決思路。


常見問題(Q&A)與程式碼範本快速跳轉

如果目前正在排查元件拆分後導致 Flexbox / CSS Grid 排版失效、Next.js / React SSR 跳出 Hydration failed 表格結構錯誤,或第三方 UI 元件無法套用外部 className 與 ref 等問題,可以直接點擊下方連結跳轉至對應解答與程式碼範本:

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


一、 為什麼元件化拆分會讓 React JSX 轉譯結果偏離預期的 DOM 結構?

1. 常見的元件封裝寫法與三大 DOM 層級錯位瓶頸

在傳統的單一 HTML 檔案中,標籤之間的父子階層一目瞭然:<ul> 的直接子元素是 <li>、<table> 的 <tbody> 底下直接放置 <tr>,而設定了 display: flex 或 display: grid 的容器則直接控制其第一層子節點的排列。

然而在 React 或 Vue 等元件化框架中,開發者為了複用邏輯,會將一個頁面拆分為數十個獨立的子元件(如 <CardGroup />、<TableRow />、<CustomListItem />)。若子元件在封裝時習慣性地在外層包上一層 <div className="wrapper">,或是漏掉原生屬性的轉發,就會在轉譯為真實 DOM 樹時引發三個常見的結構斷裂問題:

// 常見的組件封裝問題寫法:子組件內部多包一層無語意 div,且未轉發外部原生屬性
function MetricCardGroup({ items }) {
  // 陷阱 1:為了滿足單一根節點限制,隨手包了一層 <div>,阻斷父層的 Grid 佈局
  return (
    <div className="group-wrapper">
      {items.map((item) => (
        <article key={item.id} className="metric-card">{item.title}</article>
      ))}
    </div>
  );
}

1. 子元件外層多包一層包裝 div,阻斷父層 Flexbox / CSS Grid 與 > 子代選擇器:當父容器設定了 .card-grid { display: grid; grid-template-columns: repeat(3, 1fr); } 預期讓三張卡片橫向三欄排列時,由於子元件 <MetricCardGroup /> 內部多回傳了一層 <div class="group-wrapper">,瀏覽器實際收到的直接子元素變成了單一個 div.group-wrapper。結果三張 <article class="metric-card"> 全部被擠在第一欄的同一個格子內垂直堆疊,且所有依賴 CSS 直接子代選擇器(>) 的樣式規則也會全部失效。

子元件內部多包一層 div 導致父層 CSS Grid 與子代選擇器失效流程圖

圖:子元件內部多包一層 div.wrapper 後,改變了真實 DOM 的父子層級,導致三張卡片被塞進同一個 Grid 儲存格

2. 跨元件拆分破壞嚴格的 HTML 父子語法規範,引發瀏覽器強制移位與 SSR 水合失敗:HTML 標準對於特定標籤有著嚴格的直接子元素限制(例如 <ul> / <ol> 的直接子元素只能是 <li>、<table> 的 <tbody> 底下只能是 <tr>,兩者另外只允許 <script>、<template> 這類 script-supporting 元素;<select> 底下也只接受 <option>、<optgroup>、<hr> 等規範列出的元素)。當開發者將表格列抽成 <DataRowWrapper> 元件卻在外層包了 <div>(產出 <tbody><div><tr>...</tr></div></tbody>),根據 WHATWG HTML 解析規範的 in table body 插入模式,瀏覽器 HTML Parser 會執行錯誤修復(Foster Parenting),把非法的 <div> 移到 <table> 前面,<tr> 則留在 <tbody> 內;到了客戶端 React 執行 hydrateRoot 水合比對時,發現實際 DOM 結構與虛擬 DOM 樹不一致,便會回報 Hydration failed because the server rendered HTML didn't match the client 錯誤,並在客戶端重新產生這棵樹。

表格元件外層誤包 div 觸發瀏覽器 HTML Parser 強制移位與 React SSR 水合失敗流程圖

圖:在 tbody 子元件內誤包 div 會觸發瀏覽器解析器強制移位,導致 React SSR 水合比對失敗與表格結構崩潰

3. 自訂元件寫死根節點標籤且未轉發 ...restProps,導致外部樣式與無障礙屬性斷裂:原生 HTML 標籤天生支援傳入 className、id、aria-*、data-* 與事件處理器;但當團隊封裝了自訂元件 <UserCard className="mt-4" aria-label="個人資料" />,若元件內部沒有接收並展開剩餘屬性(...restProps),外層傳入的 className 與 aria-label 會被元件忽略,不會出現在渲染出的 DOM 上;此外,若元件內部將根節點寫死為 <div>,當列表容器 <ul> 需要將其作為 <li> 渲染時,就只能被迫寫出 <ul><li><UserCard /></li></ul>,平白增加不必要的 DOM 深度。

自訂元件未展開剩餘屬性且寫死根標籤導致外部樣式與語意中斷流程圖

圖:自訂元件若未展開 ...restProps 或將根標籤寫死為 div,外部傳入的 className 與 aria-* 屬性就不會出現在渲染出的 DOM 上


2. React JSX 四種元件封裝寫法與真實 DOM 產出對照表

為了在元件化開發中精準掌控最終生成的 HTML 結構,我們可以將常見的四種 JSX 封裝模式及其對真實 DOM 樹的影響整理如下:

JSX 元件封裝模式轉譯後真實 DOM 產出適用場景與結構影響
1. 包裹外層容器 (<div>...</div>)產生實體 <div> 節點包裹子元素僅適用於該層本身需要獨立背景、邊框或 Flex/Grid 盒模型時;誤用會阻斷父層排版
2. 短語法片段 (<>...</>)不產生任何 DOM 節點(子元素直接掛載於父層下)適合靜態回傳多個相鄰兄弟節點(如 <td> 群組),但不支援傳入 key 屬性
3. 具名 <Fragment key={id}>不產生任何 DOM 節點(保留 React 對齊用之虛擬鍵值)適合在 .map() 迴圈中一次產出多個平級元素(如 dt + dd 或主列 + 展開明細列)
4. 多態元件 (<Box as="li" />)依 as 指定標籤直接輸出單一 <li class="box">兼顧樣式封裝與 HTML 語意,避免在語意標籤外再多包一層冗餘 <div>

下圖直觀對比了「包裹外層 div」、「使用 React.Fragment / Vue 多根節點」與「使用多態元件 as 模式」三種寫法在轉譯為真實瀏覽器 DOM 樹時的結構差異:

JSX 元件樹轉譯為真實瀏覽器 DOM 樹之三種輸出路徑對照圖

圖:三種 JSX 封裝模式的真實 DOM 產出對比——善用 Fragment 與多態 as 屬性可徹底消除夾在中間的冗餘包裝節點


二、 React JSX 轉譯底層機制與多態元件(Polymorphic Component)設計

1. 從 JSX 語法糖到 _jsx(type, props):為什麼 Fragment 能保持 DOM 結構扁平?

在現代 React(React 17 引入的新版 JSX Transform react/jsx-runtime)中,設定為新版轉譯的編譯器(例如 Babel 7.9.0 以上、TypeScript 4.1 以上)會將 JSX 標籤轉譯為 _jsx(type, props, key) 或 _jsxs() 函式呼叫。例如 <p key="k" id="i" /> 會轉譯為 jsx("p", { id: "i" }, "k"),<>...</> 則轉譯為 jsxs(Fragment, { children: [...] }):

  • 當 type 為小寫字串(如 'div'、'ul'、'tr')時,React DOM 會建立對應的實體 DOM 元素。
  • 當 type 為 React.Fragment(Symbol react.fragment) 時,React 不會為它建立任何 DOM 元素:依 React 官方文件,用 Fragment 分組對產生的 DOM 沒有任何影響,結果和這些元素沒有被分組時相同,其內部的子節點會直接成為父層 DOM 容器的子節點;具名 Fragment 上的 key 則只供 React 辨識列表項目使用。

這意味著無論你在元件樹中拆分了多少層以 <Fragment> 為根的子元件,最終在瀏覽器 Elements 面板中看到的 HTML 結構依然能保持 100% 扁平,完美維持父容器的 CSS Grid / Flexbox 直接父子關係與 <ul> > <li>、<tbody> > <tr>、<dl> > <dt> + <dd> 等嚴格語法結構。

2. 多態元件(Polymorphic as Prop)與 TypeScript 泛型屬性約束

當一個子元件本身需要掛載 CSS 樣式類別(例如 .card 或 .btn)、接收 ref 或綁定點擊事件時,我們無法使用不產生實體節點的 Fragment。此時若為了語意化而在外層包 <li className="item"><Card /></li>,又會造成多餘的 DOM 層級(關於過度巢狀對樣式計算與 INP 效能的衝擊,請參閱 DOM 節點深度與結構扁平化優化實作)。

實務上面對此類需求,主流設計系統採用 「多態元件(Polymorphic Component)」:允許呼叫端動態決定元件最終渲染的 HTML 標籤,例如 Chakra UI 的 as 屬性、Mantine 的 component 屬性(例如 Mantine 的 Button 預設渲染為 button,可透過 component 改成其他元素)。自行實作時可搭配 TypeScript 的 React.ElementType 與 ComponentPropsWithoutRef<T>,讓編譯器依目前的標籤檢查合法屬性(例如 as="a" 時可以傳 href、傳 disabled 會報錯;as="button" 時 type 只接受 button、submit、reset):

多態元件 Polymorphic as Prop 與 TypeScript 泛型推導流程圖

圖:多態元件結合 as 屬性與 TypeScript ComponentPropsWithoutRef<T>,在單一 DOM 節點上同時滿足樣式封裝、語意標籤切換與屬性型別安全


三、 可直接套用的 React JSX 結構控制與多態元件程式碼範本

範本一:使用具名 Fragment 與 key 保持表格 tbody 與描述清單 dl 扁平 DOM 結構

在開發「可展開明細的資料表格(Expandable Data Table)」或「規格鍵值描述清單(<dl>)」時,每一筆資料往往需要同時渲染兩個平級的兄弟節點(例如一列主資料 <tr> 搭配一列摺疊明細 <tr>,或一個 <dt> 標題搭配一個 <dd> 數值)。以下範本示範如何使用具名 <Fragment key={item.id}> 在不插入任何非法外層 <div> 的前提下完成元件化渲染:

import { Fragment } from 'react';

interface ServerRecord {
  id: string;
  hostname: string;
  status: 'online' | 'maintenance';
  ipAddress: string;
  region: string;
}

/**
 * 1. 表格列子組件:回傳兩個相鄰的 <tr>(主資料列 + 明細列)
 * 使用 Fragment 確保父層 <tbody> 的直接子元素 100% 為合法的 <tr>
 */
function ServerTableRow({ record, isExpanded }: { record: ServerRecord; isExpanded: boolean }) {
  return (
    <>
      <tr className="server-row-primary">
        <td>{record.hostname}</td>
        <td>{record.status === 'online' ? '運作中' : '維護中'}</td>
      </tr>
      {isExpanded && (
        <tr className="server-row-detail">
          <td colSpan={2}>
            {/* 2. 描述清單 <dl>:在迴圈中使用具名 <Fragment key={...}> 直接產出平級的 <dt> 與 <dd> */}
            <dl className="server-spec-list">
              {[
                { label: 'IP 位址', value: record.ipAddress },
                { label: '機房區域', value: record.region },
              ].map((spec) => (
                <Fragment key={spec.label}>
                  <dt className="spec-term">{spec.label}</dt>
                  <dd className="spec-desc">{spec.value}</dd>
                </Fragment>
              ))}
            </dl>
          </td>
        </tr>
      )}
    </>
  );
}

/**
 * 父層表格組件:顯式包含 <thead> 與 <tbody>,防止 SSR Hydration Mismatch
 */
export function ServerStatusTable({ records, expandedIds }: { records: ServerRecord[]; expandedIds: string[] }) {
  return (
    <table className="server-table">
      <thead>
        <tr>
          <th scope="col">主機名稱</th>
          <th scope="col">目前狀態</th>
        </tr>
      </thead>
      <tbody>
        {records.map((record) => (
          <ServerTableRow
            key={record.id}
            record={record}
            isExpanded={expandedIds.includes(record.id)}
          />
        ))}
      </tbody>
    </table>
  );
}

範本二:支援 as 動態標籤切換與 TypeScript ComponentPropsWithoutRef 型別推導的多態元件

以下範本提供一個型別安全的通用多態容器元件 <SurfaceBox>:預設渲染為 <div>,但放在 <ul> 內可指定 as="li"、作為獨立文章卡可指定 as="article"、作為可點擊卡片連結可指定 as="a",且自動將外部傳入的 className、aria-*、data-* 與事件完整合併轉發至最終的單一 HTML 標籤上:

import { ComponentPropsWithoutRef, ElementType, ReactNode } from 'react';

/**
 * 定義多態組件專屬 Props:允許傳入泛型標籤 T(繼承自 React.ElementType)
 */
type SurfaceBoxOwnProps<T extends ElementType = 'div'> = {
  as?: T;
  variant?: 'elevated' | 'outlined' | 'subtle';
  className?: string;
  children?: ReactNode;
};

/**
 * 結合自訂 Props 與目標 HTML 標籤 T 的所有原生屬性(排除名稱衝突欄位)
 */
type SurfaceBoxProps<T extends ElementType = 'div'> = SurfaceBoxOwnProps<T> &
  Omit<ComponentPropsWithoutRef<T>, keyof SurfaceBoxOwnProps<T>>;

/**
 * 多態容器組件:零多餘外層包裝,直接輸出指定的語意化 HTML 標籤並轉發所有原生屬性
 */
export function SurfaceBox<T extends ElementType = 'div'>({
  as,
  variant = 'elevated',
  className = '',
  children,
  ...restProps
}: SurfaceBoxProps<T>) {
  const Component = as || 'div';
  const mergedClassName = ['surface-box', `surface-box--${variant}`, className]
    .filter(Boolean)
    .join(' ');

  return (
    <Component className={mergedClassName} {...restProps}>
      {children}
    </Component>
  );
}

// ==========================================================
// 呼叫端使用範例:同一組件在不同 DOM 上下文中自動切換為合法 HTML 標籤
// ==========================================================
export function FeatureShowcase() {
  return (
    <ul className="feature-grid">
      {/* 1. 在 <ul> 內部直接渲染為 <li class="surface-box ...">,保持 ul > li 結構 */}
      <SurfaceBox as="li" variant="outlined" data-feature-id="dom-clean">
        <h3>保持乾淨的父子層級</h3>
        <p>不產生多餘的包裝 div,直接作為列表項目與 Grid 子項。</p>
      </SurfaceBox>

      {/* 2. 渲染為 <li class="surface-box ..."> 並在內部放置具備 href 型別檢查的 <a> */}
      <SurfaceBox as="li" variant="elevated">
        <SurfaceBox as="a" href="/docs/architecture" variant="subtle">
          閱讀完整架構文件
        </SurfaceBox>
      </SurfaceBox>
    </ul>
  );
}

四、 開發者常見問題(Q&A)與排查注意事項

Q1:為什麼在 .map() 迴圈中渲染多個相鄰節點時,Fragment 短語法無法傳入 key 屬性,必須改寫為具名 Fragment?

原因說明:在 JSX 語法規範中,<></> 是專門設計給靜態分組使用的極簡語法糖(Shorthand Syntax),編譯器在解析 <></> 時不會讀取任何屬性括號。因此如果你在 .map() 迴圈中寫 <key={item.id}> 會直接觸發語法解析錯誤(例如 esbuild 回報 Expected ">" but found "=");若寫 <></> 而不加 key,React 則會在主控台輸出 Each child in a list should have a unique "key" prop. 錯誤訊息。

解法:凡是在 .map() 迴圈中需要以 Fragment 作為最外層容器回傳多個元素時,請從 'react' 顯式匯入 Fragment(或寫 <React.Fragment key={item.id}>)。需特別注意的是,Fragment 只接受 key 與 ref(ref 自 React 19.3 起為穩定功能),不接受 className 或 style。


Q2:為什麼將表格的 tr 抽離成獨立元件並在外層誤包 div,或直接寫 table 漏寫 tbody,會在 React SSR 觸發 Hydration Mismatch 錯誤?

原因說明:根據 WHATWG HTML 解析規範的 in table 與 in table body 插入模式,瀏覽器的 HTML 解析器在將伺服器端傳來的 HTML 字串建構為 DOM 樹時,會執行兩項強制規則:

  1. 若看到 <table> 底下直接出現 <tr>(未寫 <tbody>、<thead> 或 <tfoot>),瀏覽器會自動在 <table> 與 <tr> 之間插入一個隱式的 <tbody> 節點;但客戶端的 React 虛擬 DOM 裡若沒有寫 <tbody>,水合比對時就會發現多了一層 <tbody> 而報錯。
  2. 若在 <tr> 外層誤包了 <div>(例如 <tbody><div><tr>),瀏覽器會將該 <div> 搬移到整個 <table> 的前面(Foster Parenting),<tr> 則留在 <tbody> 內,伺服器端輸出與瀏覽器解析出的 DOM 結構因此不同。

以 React 19.3 為例,這兩種寫法在開發模式水合時都會先輸出巢狀結構錯誤:漏寫 <tbody> 時是 In HTML, <tr> cannot be a child of <table>. Add a <tbody>, <thead> or <tfoot> to your code to match the DOM tree generated by the browser.,<tbody> 內包 <div> 時則是 In HTML, <div> cannot be a child of <tbody>.;接著再回報 Hydration failed because the server rendered HTML didn't match the client.,並在客戶端重新渲染整棵樹。

解法:在 React / Next.js / Nuxt 等支援 SSR 的框架中撰寫 <table> 時,務必顯式寫出 <tbody>(有表頭列時另寫 <thead>);且抽離表格列子元件時,元件根節點必須直接為 <tr> 或 <Fragment>,嚴禁包裹 <div>(至於一般 <p> 內誤包 <div> 的解析行為,請參閱 HTML 內聯與區塊元素底層渲染邏輯與常見語法地雷)。


Q3:當父元件需要對子元件掛載 ref、綁定原生 DOM 事件或套用 className 時,為什麼子元件根節點不適合使用 Fragment?

原因說明:由於 Fragment 在轉譯為真實 DOM 時會完全消失,它在瀏覽器中並沒有對應的 HTMLElement 實體,因此 className、style 與 onMouseEnter 這類 JSX 事件屬性都無處掛載。React 19.3 起 Fragment 可以接收 ref,但拿到的是 FragmentInstance 而不是 DOM 元素:它提供 addEventListener、focus、getClientRects、observeUsing、scrollIntoView 等方法,作用對象是 Fragment 內第一層的 DOM 子節點,沒有 getBoundingClientRect(),也不能套用 className。如果某個子元件(例如 <TooltipTrigger>)需要讓父元件以單一元素量測 getBoundingClientRect() 座標或套用定位 className,仍然需要一個實際的根元素。

解法:

  1. 若子元件預期渲染單一元素,可採用本文範本二的 多態元件 as 模式,在元件內部將 ...restProps 轉發至實際渲染的根元素。React 19 起函式元件可以直接把 ref 當成 prop 接收;範本二的型別使用 ComponentPropsWithoutRef<T>,傳入 ref 會被 TypeScript 拒絕,需要接收 ref 時改用 ComponentPropsWithRef<T>。
  2. 若希望將事件與 className 直接合併到傳入的單一 children 子元素上(不產生任何外層標籤),可參考 Radix UI 的 Slot(asChild 模式),它會把自身的 props 與 ref 合併到唯一的子元素上。

Q4:在 Vue 3 單檔案元件(SFC)中使用多根節點(Multi-root Template)時,為什麼外部傳入的 class 或 id 會消失並觸發主控台警告?

原因說明:Vue 3 雖然原生支援在 <template> 中直接撰寫多個平級根節點(底層同樣以 Fragment VNode 實作),但根據 Vue 3 官方文件 — Fallthrough Attributes ($attrs),當一個元件只有單一根節點時,外部傳入的 class、style 與 id 會自動繼承到該根節點上;一旦元件有多個根節點,Vue 無法猜測應該把 class 掛在哪一個節點上,因此會放棄自動掛載並輸出 Extraneous non-props attributes (class) were passed to component but could not be automatically inherited 警告。

解法:在 Vue 3 多根節點元件中,請在真正需要接收外部樣式與屬性的目標元素上顯式綁定 v-bind="$attrs"(例如 <li v-bind="$attrs">),即可消除警告並精準控制屬性落點。


Q5:如何讓封裝好的共用元件(如 Button 或 Card)既能渲染為 button / article,又能在特定情境無縫切換為 a 或 li 且不產生多餘外層 div?

原因說明:在實務介面中,外觀相同的按鈕有時是觸發儲存的 <button type="button">,有時則是跳轉頁面的 <a href="/login">;若將 <Button> 寫死為 <button> 並在外面套 <a href="/login"><Button>登入</Button></a>,會產生 <a> 內部嵌套 <button> 的非法互動元素衝突(WHATWG 規範 <a> 元素不得包含互動內容後代;兩者又都是可聚焦元素,鍵盤 Tab 會在同一個按鈕上停留兩次)。

解法:採用本文第 2.2 節與範本二的 多態元件(Polymorphic as Prop) 設計,讓 <Button as="a" href="/login">登入</Button> 直接在底層將根標籤替換為單一個 <a class="btn" href="/login">登入</a>,同時維持視覺樣式一致與 HTML 語意合法。


五、 總結與官方參考文獻

掌握 React JSX 轉譯與真實 DOM 結構的對應關係,核心在於養成「透過元件邊界預見最終 HTML 階層」的工程習慣:

  1. 區分「元件邏輯邊界」與「DOM 盒模型邊界」:拆分 React / Vue 子元件時,若該元件不需要獨立的視覺盒模型,優先使用 <>...</> 或 <Fragment key={id}>,避免無意間塞入外層 <div> 破壞父容器的 Flexbox、CSS Grid 或 > 子代選擇器。
  2. 嚴格遵守特定 HTML 容器的直接子元素規範:在封裝 <table>(<thead> / <tbody> / <tr>)、<ul> / <ol>(<li>)、<dl>(<dt> / <dd>)與 <select>(<option>)的子元件時,務必確保轉譯後的直接父子關係合法,防止觸發瀏覽器強制移位與 SSR Hydration Mismatch。
  3. 善用多態元件(as Prop)與 ...restProps 屬性透傳:針對需要掛載樣式類別或 ref 的共用 UI 元件,透過泛型 as 屬性與 ComponentPropsWithoutRef<T> 讓呼叫端靈活指定最終輸出的語意化標籤(需要接收 ref 時改用 ComponentPropsWithRef<T>)。

站內系列文章與官方權威規範延伸閱讀

發佈留言