PrimeVue DataTable Frozen Column 錯位之謎:兩層 Root Cause,與一個 ResizeObserver Directive 解法

· updated 2026-07-03·20 min read

專案裡的 PrimeVue DataTable 用了 frozen column(凍結欄),QA 回報一個詭異的現象:表格內容變多、垂直 scrollbar 出現的那一刻,凍結欄的位置就錯了——欄與欄之間出現縫隙或互相重疊,橫向捲動時整個版面破掉。更麻煩的是它「時好時壞」,看起來像隨機發生。

這篇文章記錄了從 PrimeVue 原始碼追出兩層 root cause、用 Playwright 把「壞掉的瞬間」量化成數據、再迭代出最終解法的完整過程。

先說明這篇的特殊之處:整個調查是我與 AI(Claude Fable 5)協作完成的。實際上幾乎所有 research——讀 PrimeVue 原始碼、架 repro、寫 Playwright 量測腳本、驗證每一版提案——都是 AI 自己跑完的;我只給了開頭的 prompt 和過程中的幾個需求約束與質疑。文末有一段協作側記。

問題現象

觸發條件組合起來是這樣的:

  • DataTable 使用 scrollable + 多個 frozen column
  • 欄寬是流動的(auto layout / 百分比,沒有固定 px)
  • 表格內容或容器尺寸會變化(分頁切換、資料載入、視窗 resize)

當垂直 scrollbar 出現或消失、或任何原因造成欄寬重新分配時,凍結欄的 sticky 位置不會跟著更新,於是破版。

另一個實務陷阱:macOS 預設是 overlay scrollbar(不佔寬度),所以開發時常常看不到這個 bug,部署到 Windows(或 macOS 設定「always show scrollbars」)才爆。

第一層 Root Cause:offset 是寫死的 inline style

PrimeVue 的 frozen column 原理是 position: sticky1 加上動態計算的 left / right 偏移量。計算邏輯在每個 cell 元件(HeaderCell / BodyCell / FooterCell)各有一份,長這樣(PrimeVue 4.0.5):

js
updateStickyPosition() {
  if (this.columnProp('frozen')) {
    let align = this.columnProp('alignFrozen');
    if (align === 'right') {
      let pos = 0;
      let next = getNextElementSibling(this.$el, '[data-p-frozen-column="true"]');
      if (next) {
        pos = getOuterWidth(next) + parseFloat(next.style.right || 0);
      }
      this.styleObject.right = pos + 'px';
    } else {
      let pos = 0;
      let prev = getPreviousElementSibling(this.$el, '[data-p-frozen-column="true"]');
      if (prev) {
        // left = 前一個 frozen sibling 的「當下 DOM 寬度」+ 它的 inline left
        pos = getOuterWidth(prev) + parseFloat(prev.style.left || 0);
      }
      this.styleObject.left = pos + 'px';
    }
  }
}

關鍵問題:這個方法只在 mountedupdated 兩個生命週期被呼叫。整份 DataTable 原始碼裡沒有任何 ResizeObserver 或 resize listener 會重算 sticky position。

所以當 scrollbar 出現讓容器少了約 15px、欄寬全部重新分配時——這是「純 layout 變化」,不會觸發任何 Vue 元件更新——所有 cell 的 inline left 都停留在舊寬度算出來的值。

這在上游是長期未修的已知問題2,而且最新版 master 的實作只是把 left/right 換成 inset-inline-start/inset-inline-end(RTL 支援),呼叫時機完全相同——升級 PrimeVue 救不了

用 Playwright 把「壞掉的瞬間」量出來

推理到這裡還有個疑點:資料變化「會」觸發 re-render,updated 有 fire 的話應該會修好才對,為什麼實務上還是壞?與其繼續紙上談兵,AI 直接把 repro 架起來(Vite + PrimeVue 4.0.5 + Playwright headless Chrome),對每個 frozen cell 量測兩個數字:

  • inline left:PrimeVue 實際寫入的值
  • expected:前面所有 frozen 欄「當下實際寬度」的累計(正確答案)

再加一個視覺指標 tiling seams:橫向捲動後,相鄰凍結欄應該無縫貼合,量測縫隙/重疊的 px 數。

結果抓出三類失效,也意外挖出第二層 root cause:

失效一:純 layout 變化,完全不重算

把容器寬度從 auto 改成 900px(不觸發任何 Vue 更新):五個凍結欄全部過期,inline left 最多錯 284px,橫向捲動時欄間出現 17~166px 的縫。這對應第一層 root cause,無懸念。

失效二(第二層 root cause):重算鏈會 deadlock

這是整次調查最有趣的發現。切換分頁大小讓欄寬改變(這觸發 re-render),量測卻顯示:前兩個凍結欄正確更新了,第三欄之後全部停在舊值,錯位 71px——而且永遠不會恢復。

機制是這樣的:

  1. cell 的 updated hook 是 post-flush callback。cell N 重算時讀 prev.style.left,但 cell N-1 在同一輪 flush 才剛把新值寫進 reactive 的 styleObject還沒 patch 到 DOM——cell N 讀到的是上一輪的舊值。
  2. 正常情況下 cell N 算出一個「錯但跟自己舊值不同」的數字 → reactive 變化 → 觸發自己再 render 一輪 → 下一輪讀到 N-1 的新值 → 逐輪收斂,自我修復。
  3. 死角在這裡:如果第一輪算出的錯誤值恰好等於自己的舊值(典型情境:前一欄的「寬度」沒變、只有「位置」變了,寬度 + 舊left 算出來跟原值一樣),reactive 值沒有變化,更新鏈就此中斷——這個 cell 和它後面所有凍結欄,永久停在錯的位置。

這解釋了為什麼 bug 看起來「隨機」:會不會踩中 deadlock,取決於這次變化改到的是哪一欄的寬度。它其實是完全確定性的。

失效三:新舊 row 不一致

模擬真實 app 的 append 情境(舊資料保持物件 reference、只往後加新資料,讓 scrollbar 出現):新 append 的 row 是全新 mount,offset 正確;舊 row 和 header 因為更新鏈 deadlock 停在舊值。同一張表裡新舊 row 的凍結欄位置差了 4px——視覺上就是錯落的破版。

如果欄寬可以固定,事情很簡單

在動手寫解法之前值得先說:left 偏移量只取決於「它前面的 frozen 欄的寬度」。所以如果業務允許,把凍結欄設成固定寬度,這個 bug 根本碰不到:

html
<DataTable scrollable table-style="min-width: 60rem">
  <Column frozen field="name" style="width: 10rem; min-width: 10rem" />
  <Column frozen field="id" style="width: 7rem; min-width: 7rem" />
  <Column field="other" /> <!-- 非凍結欄可以流動 -->
</DataTable>

table-style="min-width: …" 是 PrimeVue 官方範例的標準配置——表格窄的時候走橫向捲動而不是擠壓欄寬。再加一行 scrollbar-gutter: stable3 在滾動容器上,把 scrollbar 佔寬這個觸發源也消掉,就很穩了。只凍結一欄在最左邊(left: 0)的話更是零風險。

但我們的需求是多個凍結欄 + 欄寬必須流動,上面這條路走不通,只能接管定位。

接管定位:集中計算 + CSS 覆蓋

核心設計反轉了 PrimeVue 的做法:

PrimeVue 原生我們的做法
每個 cell 各自計算單一 ResizeObserver4,一次 pass 算完全部
依賴前一個 sibling 的舊 inline style每次都從頭累計「新鮮的」量測值,沒有依賴鏈
只在 Vue 生命週期觸發監聽「欄寬變化」這個結果,不管觸發源是什麼
寫 per-cell inline style!important CSS 規則覆蓋

沒有依賴鏈 → 沒有 deadlock;監聽的是結果 → 純 layout 變化也逃不掉;規則統一套用 → 不會有新舊 row 不一致。三類失效一次治好。

實作上迭代了三版,每一版都是被真實需求打出來的。

迭代一:table-level CSS variables + nth-child

第一版把 offset 寫成 table 元素上的 CSS variables,配 SCSS 迴圈生成規則:

scss
@for $i from 1 through 10 {
  .p-datatable-frozen-column:nth-child(#{$i}) {
    left: var(--frozen-left-#{$i}, 0px) !important;
  }
}

Playwright 重跑全部情境:接縫全部歸零。但 code review 時被一句話打中要害:「through 10 這寫法有點微妙,超過 10 呢?」——這是藏在 SCSS 裡的 magic number,超過上限的欄位會 silently 退回 PrimeVue 的 buggy 行為,是最糟的失效方式。

迭代二:讓 CSS variable 騎在每個 Column 的 :style

改成透過 Column 的 style prop 把 var 直接綁在每個凍結欄上(PrimeVue 的 Column style 會同時套用到 header 與 body cell),CSS 只剩一條規則、沒有上限:

vue
<Column
  v-for="(col, i) in columns"
  :frozen="col.frozen"
  :style="col.frozen ? { '--frozen-left': `${offsets[i] ?? 0}px` } : undefined"
/>
css
.p-datatable-frozen-column {
  left: var(--frozen-left, auto) !important;
}

這版有個機械風險:offset 更新要靠 Vue re-render 傳遞,而失效三證明過「有些 row 會 skip 更新」。實測結果沒事——父層 re-render 時 Column vnode 會重建,所有 row 都拿得到新值。

迭代三:零 markup 侵入——runtime 生成規則,包成 directive

迭代二要求每個凍結欄綁 :style,對「手寫 <Column> 的既有表格」是侵入性的。最後一版把遞送方式再換一次:offset 直接生成為 scoped CSS 規則,nth-child 的 index 和數量都是 runtime 從 DOM 算出來的(上限問題天然不存在),整個功能收進一個 directive,既有表格加一個 attribute 就生效。

兩個實作細節值得一提:

  1. 方向嗅探:PrimeVue 在凍結 cell 上永遠只寫「一邊」的 inline style(靠左寫 left、靠右寫 right)。我們用 !important 蓋過它但不移除它,那個 inline 值就繼續當「這欄靠哪邊」的 marker 用——不需要額外的 config。
  2. Scope 選擇器全部用直接子層> .p-datatable-thead > tr > th),避免誤傷 row expansion 裡巢狀的 DataTable。

最終實作:v-frozen-sync directive

完整程式碼(Nuxt 3 plugin 形式;純 Vue 專案改用 app.directive 註冊即可):

ts
/**
 * v-frozen-sync — 修正 PrimeVue DataTable frozen column 的定位失效問題。
 *
 * PrimeVue 對 frozen column 的 sticky offset 是在 cell mounted/updated 時各自量測、
 * 寫成 inline style 的固定 px 值:
 * 1. 純 layout 變化(scrollbar 出現/消失、容器寬度改變)不會觸發重算,offset 過期。
 * 2. 即使重算,讀的是前一個 sibling 尚未 patch 的舊 inline style,重算鏈可能中斷,
 *    造成部分 cell 永久停在錯誤位置(上游未修,見 primefaces/primevue#5331、#1473)。
 *
 * 此 directive 改由單一 ResizeObserver 集中計算所有 frozen column 的 offset,
 * 以 runtime 生成的 CSS 規則(!important)覆蓋 PrimeVue 的 inline style。
 *
 * 用法:<PrimeDataTable v-frozen-sync scrollable ...>
 *
 * 限制:
 * - 僅支援單層 header(column group / colspan 會使 nth-child 對應失準)。
 * - 方向判斷依賴 PrimeVue 在 frozen cell 上寫入單側 inline style 的行為
 *   (4.0.5 寫 left/right,新版改寫 inset-inline-*,兩者皆已相容);升級 PrimeVue 後需重新驗證。
 * - 極端時序下(ResizeObserver callback 早於 PrimeVue 寫入 inline style)該輪會跳過寫入,
 *   待下次尺寸變化自動修正。
 */

interface FrozenSyncController {
  refresh: () => void
  destroy: () => void
}

const controllers = new WeakMap<HTMLElement, FrozenSyncController>()
let scopeSeq = 0

function frozenSide(headerCell: HTMLElement): 'left' | 'right' | null {
  const { left, right, insetInlineStart, insetInlineEnd } = headerCell.style
  if (right !== '' || insetInlineEnd !== '') {
    return 'right'
  }
  if (left !== '' || insetInlineStart !== '') {
    return 'left'
  }
  // PrimeVue 尚未寫入 inline style(cell 剛 mount、patch 未落地),方向未知
  return null
}

function buildRule(scopeId: string, columnIndex: number, side: 'left' | 'right', offset: number) {
  // 只用直接子層選擇器,避免影響巢狀(row expansion 內)的 DataTable
  const cellSelectors = [
    `> .p-datatable-thead > tr > th.p-datatable-frozen-column:nth-child(${columnIndex + 1})`,
    `> .p-datatable-tbody > tr > td.p-datatable-frozen-column:nth-child(${columnIndex + 1})`,
    `> .p-datatable-tfoot > tr > td.p-datatable-frozen-column:nth-child(${columnIndex + 1})`,
  ]
    .map(selector => `[data-frozen-sync="${scopeId}"] ${selector}`)
    .join(',')
  const oppositeSide = side === 'left' ? 'right' : 'left'
  return `${cellSelectors}{${side}:${offset}px !important;${oppositeSide}:auto !important}`
}

function attachFrozenSync(rootEl: HTMLElement): FrozenSyncController | undefined {
  const table = rootEl.querySelector<HTMLElement>('.p-datatable-table')
  if (!table) {
    return undefined
  }

  const scopeId = `fs-${++scopeSeq}`
  table.setAttribute('data-frozen-sync', scopeId)
  const styleEl = document.createElement('style')
  document.head.appendChild(styleEl)

  const headerCells = () => [
    ...table.querySelectorAll<HTMLElement>('.p-datatable-thead > tr:first-child > th'),
  ]
  const isFrozen = (cell: HTMLElement) => cell.dataset.pFrozenColumn === 'true'

  const update = () => {
    // 隱藏容器(display: none)下寬度全為 0,寫入只會產生 left:0 的垃圾規則;
    // 容器顯示時 ResizeObserver 會再次觸發,屆時再算
    if (table.getBoundingClientRect().width === 0) {
      return
    }

    const cells = headerCells()
    const sides = cells.map(cell => (isFrozen(cell) ? frozenSide(cell) : undefined))
    // 任一 frozen cell 方向未知(PrimeVue inline style 尚未寫入)就整批跳過,
    // 保留既有規則,等下一次 callback 再算
    if (sides.includes(null)) {
      return
    }

    const rules: string[] = []

    let offset = 0
    cells.forEach((cell, index) => {
      if (sides[index] === 'left') {
        rules.push(buildRule(scopeId, index, 'left', offset))
        offset += cell.getBoundingClientRect().width
      }
    })

    offset = 0
    for (let index = cells.length - 1; index >= 0; index--) {
      if (sides[index] === 'right') {
        rules.push(buildRule(scopeId, index, 'right', offset))
        offset += cells[index].getBoundingClientRect().width
      }
    }

    const css = rules.join('\n')
    if (styleEl.textContent !== css) {
      styleEl.textContent = css
    }
  }

  // directive lifecycle 沒有 active component instance,@vueuse 的 useResizeObserver
  // 依賴 getCurrentInstance 掛載清理,這裡必須直接使用原生 ResizeObserver
  const observer = new ResizeObserver(update)
  let observed: HTMLElement[] = []

  // observe() 會觸發 ResizeObserver 的 initial callback,等同重算一次
  const observe = () => {
    observer.disconnect()
    observed = [table, ...headerCells().filter(isFrozen)]
    observed.forEach(target => observer.observe(target))
  }
  observe()

  // 觀察目標沒變(一般 re-render)只重算 CSS;
  // 有變(欄位增減、th 重建)才重建觀察,避免每次 re-render 都 disconnect/re-observe
  const refresh = () => {
    const targets = [table, ...headerCells().filter(isFrozen)]
    const changed
      = targets.length !== observed.length || targets.some((target, index) => target !== observed[index])
    if (changed) {
      observer.disconnect()
      observed = targets
      targets.forEach(target => observer.observe(target))
      return
    }
    update()
  }

  return {
    refresh,
    destroy: () => {
      observer.disconnect()
      styleEl.remove()
      table.removeAttribute('data-frozen-sync')
    },
  }
}

export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.vueApp.directive<HTMLElement>('frozen-sync', {
    mounted(el) {
      const controller = attachFrozenSync(el)
      if (controller) {
        controllers.set(el, controller)
      }
    },
    updated(el) {
      const controller = controllers.get(el)
      // 首次 mounted 時 table 可能尚未渲染(如延遲載入),在 updated 補掛
      if (!controller) {
        const attached = attachFrozenSync(el)
        if (attached) {
          controllers.set(el, attached)
        }
        return
      }
      controller.refresh()
    },
    unmounted(el) {
      controllers.get(el)?.destroy()
      controllers.delete(el)
    },
  })
})

使用方式:

html
<DataTable v-frozen-sync :value="rows" scrollable scroll-height="flex">
  <Column field="name" header="Name" frozen />
  <Column field="balance" header="Balance" frozen align-frozen="right" />
  <Column field="company" header="Company" />
</DataTable>

最終驗證:左 5 欄 + 右 2 欄凍結、六種失效情境(初始非同步載入、換頁 deadlock 情境、scrollbar 消失/出現、layout-only 縮容器、append 保留舊 row reference)全數通過,header / 新舊 body row 的接縫全部為 0,右側凍結組永遠貼齊可視區右緣。

Trade-off 與適用場景

迭代二(:style 綁定)vs 迭代三(directive)怎麼選:

:style 綁定directive
markup 侵入每個凍結欄要綁 :style零,表上加一個 attribute
更新路徑走 Vue re-render純 CSSOM,零 re-render
方向來源column config(顯式)嗅探 PrimeVue inline style(內部耦合)
column group / colspan不受 nth-child 錯位影響不支援
適合場景config 驅動的 table wrapper大量手寫 <DataTable> 的專案

已知限制:

  • 兩版都只處理單層 header。column group + frozen 的組合連 PrimeVue 原生都有 bug(rowspan 定位錯誤),要支援得自己實作 header grid resolution(展開 colspan/rowspan 建欄位對應矩陣)——工程上可行,但等於整個接手 PrimeVue 沒做好的部分,建議等真的有需求再做。
  • directive 的方向嗅探是對 PrimeVue 內部行為的耦合,升級大版本時要重新驗證(保留 Playwright repro 就是為了這一天)。
  • 這本質上是在專案裡養一小塊「補上游缺陷」的程式碼。在檔頭註明存在原因與上游 issue 連結,哪天上游修好了,這層可以整塊拆掉。

與 AI 協作的側記

這次調查我(人類)的實際輸入大概只有這些:

  1. 開頭的 prompt:「frozen column 在 scrollbar 出現時不會 re-calc position,幫我 survey root cause 與解法」
  2. 「我現在就是有多 column 的需求,寬度沒辦法固定」
  3. @for 1 through 10 這寫法有點微妙,超過 10 呢?」
  4. 「alignFrozen left 跟 right 會一起用」
  5. 「手寫 <Column> 不行處理?」

剩下的——翻 node_modules 原始碼、比對 master 分支、架 Vite + Playwright repro、設計量測指標、發現 deadlock 機制、每一版提案改完立刻重跑六種情境驗證——都是 AI(Claude Fable 5)自己完成的。

回頭看有兩個心得。第一,AI 最有價值的行為不是「給答案」,而是每個結論都附帶可重跑的實驗:deadlock 機制不是推理出來就算了,是量出「第三欄之後停在 71px 錯位」這種具體數據;每一版解法也都是先過了全部情境的量測才端出來。第二,人的介入點不在技術細節,而在需求約束和 code smell 的直覺——上面那五句話裡,每一句都直接造成了一次設計迭代。我其實不確定這些介入有沒有幫 AI 少走彎路,也可能它自己遲早會想到;但至少這個分工是舒服的:它負責把每條路走到底並拿數據回來,我負責決定哪條路值得走。

環境資訊

  • Nuxt: 3.x(SPA mode)
  • Vue: 3.x
  • PrimeVue: 4.0.5(Aura theme)
  • 驗證工具: Playwright + headless Chrome

本文撰寫時間:2026 年 7 月,技術版本可能隨時間更新,請以官方文件為準。

延伸閱讀

Reference

  1. MDN — position: sticky
  2. primefaces/primevue#5331 — DataTable: Frozen Column Rendering Issue When Columns Automatically Resized
  3. MDN — scrollbar-gutter
  4. MDN — ResizeObserver