PrimeVue DataTable Frozen Column 錯位之謎:兩層 Root Cause,與一個 ResizeObserver Directive 解法
專案裡的 PrimeVue DataTable 用了 frozen column(凍結欄),QA 回報一個詭異的現象:表格內容變多、垂直 scrollbar 出現的那一刻,凍結欄的位置就錯了——欄與欄之間出現縫隙或互相重疊,橫向捲動時整個版面破掉。更麻煩的是它「時好時壞」,看起來像隨機發生。
這篇文章記錄了從 PrimeVue 原始碼追出兩層 root cause、用 Playwright 把「壞掉的瞬間」量化成數據、再迭代出最終解法的完整過程。
先說明這篇的特殊之處:整個調查是我與 AI(Claude Fable 5)協作完成的。實際上幾乎所有 research——讀 PrimeVue 原始碼、架 repro、寫 Playwright 量測腳本、驗證每一版提案——都是 AI 自己跑完的;我只給了開頭的 prompt 和過程中的幾個需求約束與質疑。文末有一段協作側記。
問題現象
觸發條件組合起來是這樣的:
- DataTable 使用
scrollable+ 多個frozencolumn - 欄寬是流動的(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):
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';
}
}
}
關鍵問題:這個方法只在 mounted 和 updated 兩個生命週期被呼叫。整份 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——而且永遠不會恢復。
機制是這樣的:
- cell 的
updatedhook 是 post-flush callback。cell N 重算時讀prev.style.left,但 cell N-1 在同一輪 flush 才剛把新值寫進 reactive 的styleObject,還沒 patch 到 DOM——cell N 讀到的是上一輪的舊值。 - 正常情況下 cell N 算出一個「錯但跟自己舊值不同」的數字 → reactive 變化 → 觸發自己再 render 一輪 → 下一輪讀到 N-1 的新值 → 逐輪收斂,自我修復。
- 死角在這裡:如果第一輪算出的錯誤值恰好等於自己的舊值(典型情境:前一欄的「寬度」沒變、只有「位置」變了,
寬度 + 舊left算出來跟原值一樣),reactive 值沒有變化,更新鏈就此中斷——這個 cell 和它後面所有凍結欄,永久停在錯的位置。
這解釋了為什麼 bug 看起來「隨機」:會不會踩中 deadlock,取決於這次變化改到的是哪一欄的寬度。它其實是完全確定性的。
失效三:新舊 row 不一致
模擬真實 app 的 append 情境(舊資料保持物件 reference、只往後加新資料,讓 scrollbar 出現):新 append 的 row 是全新 mount,offset 正確;舊 row 和 header 因為更新鏈 deadlock 停在舊值。同一張表裡新舊 row 的凍結欄位置差了 4px——視覺上就是錯落的破版。
如果欄寬可以固定,事情很簡單
在動手寫解法之前值得先說:left 偏移量只取決於「它前面的 frozen 欄的寬度」。所以如果業務允許,把凍結欄設成固定寬度,這個 bug 根本碰不到:
<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 迴圈生成規則:
@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 只剩一條規則、沒有上限:
<Column
v-for="(col, i) in columns"
:frozen="col.frozen"
:style="col.frozen ? { '--frozen-left': `${offsets[i] ?? 0}px` } : undefined"
/>
.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 就生效。
兩個實作細節值得一提:
- 方向嗅探:PrimeVue 在凍結 cell 上永遠只寫「一邊」的 inline style(靠左寫
left、靠右寫right)。我們用!important蓋過它但不移除它,那個 inline 值就繼續當「這欄靠哪邊」的 marker 用——不需要額外的 config。 - Scope 選擇器全部用直接子層(
> .p-datatable-thead > tr > th),避免誤傷 row expansion 裡巢狀的 DataTable。
最終實作:v-frozen-sync directive
完整程式碼(Nuxt 3 plugin 形式;純 Vue 專案改用 app.directive 註冊即可):
/**
* 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)
},
})
})
使用方式:
<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 協作的側記
這次調查我(人類)的實際輸入大概只有這些:
- 開頭的 prompt:「frozen column 在 scrollbar 出現時不會 re-calc position,幫我 survey root cause 與解法」
- 「我現在就是有多 column 的需求,寬度沒辦法固定」
- 「
@for 1 through 10這寫法有點微妙,超過 10 呢?」 - 「alignFrozen left 跟 right 會一起用」
- 「手寫
<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 月,技術版本可能隨時間更新,請以官方文件為準。
延伸閱讀
- primefaces/primevue#1473 — Alignment issue with multiple frozen columns on DataTable - 多凍結欄錯位的早期回報
- primefaces/primevue#7386 — frozen column doesn't work correctly with Column Group - column group + frozen 的上游 bug
- PrimeVue DataTable 官方文件 - frozen columns 官方範例(注意範例都搭配固定欄寬 +
tableStyle="min-width: …")