用 invisible absolute 解決 Chart.js 在隱藏 Tab 中匯出空白圖片的問題
最近在做一個多 Tab 頁面的「下載圖表」功能時,踩到一個 Chart.js 的經典坑:當圖表所在的 Tab 被隱藏時,匯出的圖片是空白的。
這篇文章記錄問題的成因、CSS 隱藏方式的差異,以及最終用 invisible absolute 搭配 offscreen 定位解決的過程。
問題背景
頁面結構是一個多 Tab 的分析報告詳情頁,兩個 Tab 分別是「受眾包分析」(包含 Chart.js 圖表)和「內容分析」(純數據表格)。使用者可以透過一個 Modal 選擇下載內容,觸發匯出。
<!-- 原本用 v-show 切換 Tab -->
<ChartPanel v-show="activeTab === 'package_analysis'" :data="data" />
<DataPanel v-show="activeTab === 'content_analysis'" :data="data" />
匯出圖表的核心是 Chart.js 的 toBase64Image() 方法,它會把 Canvas 當前的渲染結果轉成 Base64 PNG。
問題出在:當使用者停留在「內容分析」Tab 時觸發下載,「受眾包分析」Tab 被 v-show 隱藏,匯出的圖片全部是空白的。
為什麼 v-show 會導致空白圖片?
v-show 的底層實作是 display: none1。當一個元素被設為 display: none 時,瀏覽器會將它完全從 Layout 流程中移除——不計算尺寸、不分配空間、不進行繪製。
對 Canvas 來說,這意味著它的寬高會變成 0。Chart.js 的 toBase64Image() 本質上是呼叫 Canvas 的 toDataURL(),而一個 0×0 的 Canvas 自然只能產出空白圖片。
這是 Chart.js 的已知行為,在官方 GitHub Issue #2933 中有明確說明2。
CSS 隱藏元素的三種方式比較
要理解解法,先釐清三種常見的 CSS 隱藏方式在瀏覽器渲染管線中的差異:
| 方式 | DOM 存在 | Layout 計算 | Paint 繪製 | 佔據空間 |
|---|---|---|---|---|
display: none | ✅ | ❌ | ❌ | ❌ |
visibility: hidden | ✅ | ✅ | ❌ | ✅ |
opacity: 0 | ✅ | ✅ | ✅ | ✅ |
關鍵差異在於 Layout 計算3:
display: none:元素完全從 Layout 中移除,瀏覽器不會計算它的尺寸和位置visibility: hidden:元素仍然參與 Layout,瀏覽器會正常計算尺寸,只是最後不繪製到畫面上opacity: 0:元素完全參與 Layout 和 Paint,只是透明度為 0
對 Canvas 和 Chart.js 來說,我們需要的是「元素不可見,但尺寸正常」——這正好是 visibility: hidden 的行為。
第一版解法:invisible absolute
單純用 visibility: hidden 有一個問題:元素雖然不可見,但仍然佔據空間,會把頁面撐開。
解法是搭配 position: absolute,讓元素脫離 Normal Flow:
<ChartPanel
ref="chartPanelRef"
:data="data"
class="p-12"
:class="activeTab !== 'package_analysis' ? 'invisible absolute' : ''"
/>
<DataPanel
ref="dataPanelRef"
:data="data"
class="p-12"
:class="activeTab !== 'content_analysis' ? 'invisible absolute' : ''"
/>
這裡的 invisible 和 absolute 是 UnoCSS(或 Tailwind CSS)的 utility class:
invisible→visibility: hidden(保留 Layout,不繪製)absolute→position: absolute(脫離 Normal Flow,不佔空間)
兩者組合的效果:
- ✅ DOM 存在,Canvas 有正確尺寸
- ✅ Chart.js
toBase64Image()能正常匯出 - ✅ 使用者看不到隱藏的 Tab 內容
- ✅ 不影響頁面排版(不會撐開空間)
第一版的問題:多餘的 Scroll 和空白
上線後發現 invisible absolute 雖然解決了圖表匯出的問題,但引入了新的 Layout 副作用:頁面出現了不應該存在的 y-scroll,底部也多出大片空白區域。
原因在於 position: absolute 的定位行為。當隱藏的 Tab 元素被設為 absolute 時,它會相對於最近的 positioned ancestor 定位。如果沒有明確指定 top/left 等 offset 屬性,元素會停留在它原本在 Normal Flow 中的位置(即 static position),但脫離了文件流。
問題是:這個元素雖然脫離了 Normal Flow,它的 border box 仍然會被計入外層 scroll container 的 scrollable overflow area。根據 CSS Overflow Level 3 規範,scroll container 的 scrollable overflow 包含「所有它作為 containing block 的 box 的 border box」4。也就是說,即使元素是 absolute 定位,只要它的 border box 落在 scroll container 的可滾動區域內(正方向),就會撐大 scrollable area,產生多餘的 scrollbar。
改進版:Offscreen 定位
解法是把隱藏的元素移到畫面左側的「不可達區域」(unreachable scrollable overflow region):
<ChartPanel
ref="chartPanelRef"
:data="data"
class="p-12"
:class="activeTab !== 'package_analysis'
? 'absolute invisible -z-1 pointer-events-none left-[-9999px]'
: ''"
/>
<DataPanel
ref="dataPanelRef"
:data="data"
class="p-12"
:class="activeTab !== 'content_analysis'
? 'absolute invisible -z-1 pointer-events-none left-[-9999px]'
: ''"
/>
新增的 class 各自的作用:
left-[-9999px]→left: -9999px:把元素移到畫面左側極遠處-z-1→z-index: -1:確保即使意外可見也不會遮擋內容pointer-events-none→pointer-events: none:防止隱藏元素攔截滑鼠事件
為什麼負方向不會產生 Scroll?
這是 CSS 規範中一個重要但容易被忽略的設計。CSS Overflow Level 3 定義了「unreachable scrollable overflow region」的概念4:在預設的 LTR(left-to-right)書寫模式下,scroll container 的起始邊(左邊和上邊)方向的 overflow 是「不可達的」——使用者無法透過滾動到達那個區域。
瀏覽器在計算 scrollable overflow area 時,會排除落在 unreachable region 的內容。因此,left: -9999px 把元素移到左側負方向,不會撐大 scrollable area,自然也不會產生多餘的 scrollbar。
相反地,如果用 left: 9999px(正方向),就會產生水平 scrollbar,因為正方向是「可達的」scrollable overflow。
效果驗證
改進後的組合:
- ✅ Canvas 保持正確尺寸(
visibility: hidden保留 Layout) - ✅ Chart.js 匯出正常
- ✅ 不產生多餘的 scroll 或空白
- ✅ 不攔截使用者互動
- ✅ 不影響 z-index 堆疊
為什麼不用 sr-only?
可能會想到 Tailwind/UnoCSS 的 sr-only(screen reader only)class,它也是用來「視覺隱藏但保留在 DOM 中」。但 sr-only 的實作會把元素壓成 width: 1px; height: 1px; overflow: hidden,Canvas 尺寸會變成 1×1,匯出結果一樣是壞的。
sr-only 的設計目的是讓元素「存在但面積趨近於零」,給 Screen Reader 讀取用。而我們的需求是「存在且保持原始尺寸」,剛好相反。
Trade-off:資源消耗
invisible absolute 不是沒有代價。在 Vue 中三種隱藏方式的資源消耗排序:
| 方式 | Vue 指令 | DOM | Layout 成本 | 記憶體佔用 |
|---|---|---|---|---|
| 條件渲染 | v-if | ❌ 不存在 | 無 | 最低 |
| 顯示切換 | v-show | ✅ 存在 | 無(display: none 跳過) | 中 |
| 可見性隱藏 | invisible absolute | ✅ 存在 | ✅ 正常計算 | 最高 |
invisible absolute 多了 Layout 計算的成本,因為瀏覽器需要算出每個元素的尺寸和位置。
但實際影響取決於場景。以這個 case 來說,隱藏的是兩個 Tab 的內容,Chart.js 的 Canvas 數量是固定的十幾個,Layout 成本很低。真正吃資源的是 Chart.js Instance 本身(JS Heap),而這個不管用哪種隱藏方式,只要 Instance 活著就會佔用。
適用場景
這個 pattern 適合以下情境:
- Tab 切換中需要保持 Canvas/Chart 的渲染狀態
- 需要在元素不可見時匯出 Canvas 內容
- 需要保持元素的尺寸資訊(例如動態計算 Layout)
- 元素數量有限,不會造成顯著的 Layout 效能問題
不適合的情境:
- 大量列表項目的顯示/隱藏(Layout 成本會累積)
- 不需要保持元素尺寸的純顯示切換(用
v-show即可) - 元素可以完全銷毀重建的場景(用
v-if更省資源)
環境資訊
- Chart.js: 4.4.7
- Vue: 3.x
- Nuxt: 3.x
- UnoCSS(
invisible/absolute/left-[-9999px]utility classes)
本文撰寫時間:2026 年 3 月,技術版本可能隨時間更新,請以官方文件為準。