HTML-in-Canvas API 可讓您直接在 2D、WebGL 或 WebGPU 畫布內容中,算繪互動式、無障礙的 DOM 子樹。
本指南概述所有 Canvas 情境通用的 HTML-in-Canvas 技術概念:
準備畫布來算繪 HTML
如要在 <canvas> 中算繪 HTML 元素,請設定下列兩個屬性:
- 在
<canvas>上設定content="drawable":這會讓畫布元素選擇版面配置其子系 DOM 節點,而不是僅將其視為隱藏的內容。 - 在目標元素上設定
drawable:這會將元素標示為獨立繪製根,可進行畫布算繪。
<canvas id="mainCanvas" width="600" height="400" content="drawable">
<div id="parentElement" class="card" drawable>
<h2>Container Title</h2>
<p>Descriptive text inside the parent element.</p>
<!-- Nested drawable: excluded from parentElement and drawn independently -->
<button id="childElement" class="btn" drawable>Action Button</button>
</div>
</canvas>
瞭解可繪製子樹
準備要轉譯為畫布的 HTML 時,您需要決定哪些元素應獨立繪製。每個 drawable 元素都代表獨立的繪製根層級及其可繪製子樹狀結構。
可繪製子樹狀結構包含具有 drawable 屬性的元素及其所有子系,但「不包括」任何同樣帶有 drawable 的巢狀子系,例如:
<canvas content="drawable">
<!-- Subtree A (Renders parent container and text) -->
<div id="parentElement" drawable>
<h2>Parent Card Heading</h2>
<p>Card body text content.</p>
<!-- Subtree B (Excluded from Subtree A and drawn separately) -->
<button id="nestedChildElement" drawable>
Clickable Button
</button>
</div>
</canvas>
| 元素 | 繪製界線 | 行為 |
|---|---|---|
#parentElement (子樹 A) |
背景面板、標題和段落 | 呼叫 drawElementImage(parentElement, ...) 時算繪 |
#nestedChildElement (子樹 B) |
按鈕表面和文字 | 已從 #parentElement 子樹狀結構中排除;在獨立的 drawElementImage(nestedChildElement, ...) 呼叫中算繪 |
drawable 按鈕會以巢狀結構嵌入 drawable 資訊卡中,且每個按鈕都會由自己的 drawElementImage() 呼叫繪製。巢狀可繪元素可讓您維持語意式 HTML 關係,同時自由地在畫布場景中獨立分層、定位或為子元素建立動畫。
瞭解 paint 事件
HTML-in-Canvas API 導入了 paint 事件。每當 的任何 drawable 子系元素發生版面配置、文字或 CSS 樣式變更時,瀏覽器就會自動在 <canvas> 上調度 paint 事件。
canvas.addEventListener("paint", (event) => {
// Clear the viewport
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Redraw elements individually
ctx.drawElementImage(parentElement, 20, 20);
ctx.drawElementImage(childElement, 40, 90);
});
paint 事件會調度 CanvasPaintEvent 的例項,該例項會擴充標準 DOM Event,並具有 changedElements 屬性,其中僅包含自上次繪製以來視覺上發生變化的 drawable 子系 (例如樣式變更、版面配置偏移、文字編輯或焦點狀態變更)。
canvas.addEventListener("paint", (event) => {
// Array of changed elements
for (const el of event.changedElements) {
console.log(`Element #${el.id} visually changed and needs repainting`);
}
});
影格生命週期
在 paint 事件中執行的畫布繪圖指令會顯示在目前的轉譯更新中,但在 paint 事件中進行的 DOM 修改則要到後續影格才會顯示:
canvas.addEventListener("paint", () => {
// Renders in the CURRENT rendering update:
ctx.drawElementImage(parentElement, 20, 20);
// Modifying DOM styles inside paint schedules a new layout cycle;
// changes will appear in the NEXT frame:
parentElement.style.backgroundColor = "#e0f2fe";
});
巢狀 Canvas
如果網頁包含巢狀 <canvas content="drawable"> 元素,瀏覽器會以 DOM 樹狀結構的反向順序 (子系在祖系之前觸發) 傳送 paint 事件。這樣一來,子項畫布元素就會先算繪,再由父項畫布元素合成。
在以下範例中,假設內部的 nestedElement 元素必須在視覺上有所變化 (例如,當使用者將指標懸停在該元素上時,其 CSS 背景顏色會改變)。首先,paint 事件會在 nestedCanvas 畫布上觸發。其事件監聽器會清除子項緩衝區,並呼叫 ctx.drawElementImage(nestedElement, ...) 來算繪更新後的狀態。接著,paint 事件會在 parentCanvas 觸發。其事件監聽器會將 wrapperElement (包含已更新的 nestedCanvas 畫布) 繪製到場景中。
<!-- Outer parent canvas -->
<canvas id="parentCanvas" width="800" height="600" content="drawable">
<!-- Wrapper container for nested content -->
<div id="wrapperElement" drawable>
<!-- Nested child canvas -->
<canvas id="nestedCanvas" width="300" height="200" content="drawable">
<div id="nestedElement" class="badge" drawable>
Inner Child Content
</div>
</canvas>
</div>
</canvas>
<script>
const parentCanvas = document.getElementById("parentCanvas");
const parentCtx = parentCanvas.getContext("2d");
const nestedCanvas = document.getElementById("nestedCanvas");
const nestedCtx = nestedCanvas.getContext("2d");
const nestedElement = document.getElementById("nestedElement");
const wrapperElement = document.getElementById("wrapperElement");
// ============================================================================
// 1. Child Canvas Listener (Fires FIRST)
// ============================================================================
nestedCanvas.addEventListener("paint", (event) => {
// Clear and redraw the child canvas with the updated inner element
nestedCtx.clearRect(0, 0, nestedCanvas.width, nestedCanvas.height);
nestedCtx.drawElementImage(nestedElement, 10, 10);
console.log("1. Child nested canvas rendered fresh pixels.");
});
// ============================================================================
// 2. Parent Canvas Listener (Fires SECOND)
// ============================================================================
parentCanvas.addEventListener("paint", (event) => {
// Clear and redraw the parent canvas scene
parentCtx.clearRect(0, 0, parentCanvas.width, parentCanvas.height);
// Draws the wrapper container (including the freshly painted nestedCanvas)
parentCtx.drawElementImage(wrapperElement, 50, 50);
console.log("2. Parent canvas composited the complete scene.");
});
</script>
<canvas content="drawable"> 元素會以反向樹狀結構順序觸發繪製事件:巢狀畫布會先繪製,讓父項畫布繪製最新的像素。觸發 paint 事件
呼叫 requestPaint(),在下一個影格上排定單一 paint 事件,即使 DOM 文字或樣式未變更也一樣。這項功能可用於算繪含有移動元素的互動式場景,例如:
let animatedX = 0;
canvas.addEventListener("paint", () => {
animatedX += 1.5; // Move the element horizontally
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawElementImage(staticElement, 20, 20);
ctx.drawElementImage(movingElement, animatedX, 200);
// Request canvas paint on each rendering update for smooth animation
canvas.requestPaint();
});
canvas.requestPaint();
更新元素的幾何圖形
將 HTML 元素繪製到畫布時,繪圖程式碼會決定元素顯示的位置和大小。與一般 HTML 內容不同,元素的螢幕位置和大小 (即幾何圖形) 無法單靠計算樣式決定。瀏覽器需要元素的幾何圖形,才能執行下列操作:
- 將點擊、輕觸和指標移動等輸入事件傳送至正確的元素。這稱為點擊測試。
- 從版面配置查詢 (例如
getBoundingClientRect()) 傳回準確結果。 - 向輔助技術 (例如放大鏡) 回報元素邊界。
在 2D 模式下,drawElementImage() 預設會自動更新幾何圖形。您傳遞給它的引數,加上元素的計算樣式和內容的目前轉換,足以決定元素顯示的位置。
在 WebGL 和 WebGPU 情境中,瀏覽器無法判斷幾何圖形。HTML 元素會複製到紋理中,著色器程式則能以任何方式將該紋理對應至場景。如要告知瀏覽器元素顯示的位置,請將轉換傳遞至 updateElementGeometry()。
updateElementGeometry() 後,瀏覽器就無法再得知 3D 卡片上 HTML 元素的位置。卡片會持續翻轉,但點擊仍會導向原本的位置。如要更新幾何圖形,請將空間轉換傳遞至方法。瞭解如何針對不同情境建構轉換:
管理點擊測試堆疊順序
根據預設,每當元素的幾何圖形更新時,瀏覽器就會將該元素移至互動堆疊頂端 (最後繪製 = 最頂層的點擊目標)。您可以使用 preserveHitTestOrder 控制這項行為:
// Updates the backdrop without stealing clicks from foreground elements
canvas.updateElementGeometry(backgroundElement, {
canvasTransform: customTransform,
preserveHitTestOrder: true
});
| 選項 | 行為 | 用途 |
|---|---|---|
preserveHitTestOrder: false (預設) |
將元素移至點擊測試堆疊的頂端。 | 應優先接收點擊的前景元素。 |
preserveHitTestOrder: true |
將元素保留在目前的堆疊優先順序。 | 需要在前景元素之後更新的背景元素。 |
重設幾何圖形
如要取消繪製元素,但不從 DOM 中移除,請呼叫 canvas.clearElementGeometry()。這會重設元素的畫布轉換,並在無障礙樹狀結構中將其標示為螢幕外:
// Remove from canvas hit-testing and mark as offscreen
canvas.clearElementGeometry(childElement);
大小和單位
將 HTML 繪製到畫布時,瀏覽器會同時在三個座標系統中運作:CSS 像素、裝置像素和畫布格線像素。
CSS 像素用於在版面配置期間測量及定位內容。接著,瀏覽器會將這些像素縮放 devicePixelRatio 倍,將頁面光柵化為實體裝置像素。由於 CSS 像素是邏輯單位,位置和大小通常會是裝置像素的分數 (例如 10.5 CSS px × DPR 1.5 = 15.75 裝置 px)。螢幕只能為整個像素上色,因此瀏覽器必須解析像素之間的任何內容。具體做法有兩種:
- 貼齊:方塊邊界、邊框和圖層偏移會四捨五入至最接近的整數裝置像素,以保持清晰。
- 反鋸齒:針對文字字符、曲線和轉換邊緣,部分覆蓋範圍會融入相鄰像素,因此看起來仍平滑且位置正確。
<canvas> 有自己的像素格線,由 width 和 height 屬性設定,與 CSS 像素和裝置像素分開。為了顯示,瀏覽器會將畫布點陣圖重新取樣到實體像素格線,混合相鄰的畫布像素,填滿每個裝置像素。如要瞭解瀏覽器如何在 CSS、畫布和裝置像素之間對應,請參閱「使用 devicePixelContentBox 進行像素完美算繪」。
任何 drawable HTML 元素都會以 CSS 像素排列。由於繪製到畫布的內容可能會經過兩次轉換 (從 CSS 像素到畫布格線,再從畫布格線到實體像素),因此每個未完全對齊的步驟都會增加一輪混合,結果可能會看起來模糊。為避免這種情況,請讓畫布格線與實體像素格線相符。這有兩個目的:
- 一致的算繪:如果畫布與實體像素一對一對應,將 CSS 像素轉換為畫布像素,產生的結果會與瀏覽器將 CSS 像素轉換為一般 HTML 的實體像素相同。
- 不需額外混合:如果畫布與實體像素一對一對應,轉換為裝置像素時就不會增加模糊。
下圖顯示像素轉換導致內容模糊。這三個格線並未對齊:畫布元素為 360 × 180 CSS 像素,畫布大小為 300 × 150 畫布格線像素。如果裝置像素比例為 1.5,畫布會涵蓋 540 × 270 個裝置像素。一個 CSS 像素跨越 1.5 個裝置像素,一個畫布像素跨越 1.2 個 CSS 像素,或 1.8 個裝置像素 (面板 1)。繪製圓形時,其位置和半徑是以畫布像素 (而非 CSS 像素) 為單位,因此瀏覽器會將其繪製到 300 × 150 畫布點陣圖中,並對邊緣進行反鋸齒處理 (面板 2)。為了顯示畫布,瀏覽器會將這個點陣圖延展至 540 × 270 裝置像素,因此瀏覽器必須進行內插:每個裝置像素都會採用最接近畫布像素的加權混合,其中已包含反鋸齒邊緣。邊緣會再次混合,並分散在多個裝置像素上,因此圓形看起來會模糊不清 (面板 3)。如果畫布格線與裝置像素為 1:1,就能避免這種情況。接著,瀏覽器會直接在裝置格線上對邊緣進行一次消除鋸齒處理,並顯示點陣圖,而不重新取樣,因此邊緣會保持清晰 (面板 4):
如要清晰顯示,畫布格線必須與畫布涵蓋的裝置像素數量相符:以上例來說,就是 540 × 270。
使用 ResizeObserver 調整畫布格線大小
合適的格線大小取決於裝置,且可能會隨著版面配置變更、使用者縮放頁面,或使用者將視窗移至其他螢幕而改變。因此,請讓瀏覽器告訴您格線大小,而不是硬式編碼。
如要讓畫布格線與畫面上的畫布保持同步,請一律使用 ResizeObserver 設定大小,並將畫布格線調整為 devicePixelContentBoxSize:
const observer = new ResizeObserver(([entry]) => {
const dpc = entry.devicePixelContentBoxSize;
canvas.width = dpc ? dpc[0].inlineSize : Math.round(entry.contentRect.width * window.devicePixelRatio);
canvas.height = dpc ? dpc[0].blockSize : Math.round(entry.contentRect.height * window.devicePixelRatio);
});
const supportsDevicePixelContentBox =
typeof ResizeObserverEntry !== 'undefined' &&
'devicePixelContentBoxSize' in ResizeObserverEntry.prototype;
const options = supportsDevicePixelContentBox ? { box: 'device-pixel-content-box' } : {};
observer.observe(canvas, options);
將 CSS 像素轉換為畫布格線座標
paint 處理常式通常會結合 DOM 測量和繪製呼叫,例如根據元素大小定位元素。兩者使用的單位不同:
- 畫布像素用於描述畫布格線,其大小由
canvas.width和canvas.height決定。例如drawElementImage()繪圖方法的dx、dy、dwidth和dheight參數,或其他畫布 API 參數,例如fillRect()和translate()。 - CSS 像素用於描述網頁。DOM 測量值 (例如
offsetWidth和clientWidth) 和指標事件座標 (例如offsetX) 都是以 CSS 像素為單位。
以下概略說明各種 API 參數使用的單位:
| 值 | 單位 |
|---|---|
canvas.width和canvas.height |
畫布格線像素 |
2D 繪圖參數,包括 drawElementImage() 的 dx、dy、dwidth 和 dheight |
畫布格狀像素 |
canvas.getElementImageDefaultSize() 傳回的 width 和 height |
畫布格線像素 |
來源矩形:drawElementImage() 和 texElementSubImage2D() 的 sx、sy、swidth 和 sheight;drawElementImageToTexture() 的 sourceX、sourceY、sourceWidth 和 sourceHeight |
CSS 像素 |
紋理大小:texElementSubImage2D() 的 width 和 height 選項;drawElementImageToTexture() 的 size |
紋理像素。根據預設,元素大小會以畫布格線像素為單位,並四捨五入 |
元素幾何圖形:getElementTransform(),以及 updateElementGeometry() 的 canvasTransform 和 canvasClip 選項 |
CSS 像素 |
DOM 測量結果,例如 offsetWidth、clientWidth、getBoundingClientRect()、指標事件座標,以及調整大小觀察器項目的 contentBoxSize |
CSS 像素 |
調整大小觀察器項目的 devicePixelContentBoxSize |
裝置像素 |
如果使用的單位有誤,繪製到畫布的 HTML 內容大小可能不符預期。將 HTML 繪製到畫布時,必須將 CSS 像素值轉換為畫布格線像素。方法是計算畫布格線大小與畫布 CSS 大小的比率:水平方向為 canvas.width / cssContentWidth,垂直方向為 canvas.height / cssContentHeight。調整大小觀察器是計算比例的便利位置,如下所示:
// The number of canvas grid pixels per CSS pixel
let scaleX = 1;
let scaleY = 1;
const observer = new ResizeObserver(([entry]) => {
const dpc = entry.devicePixelContentBoxSize;
const cssContentWidth = entry.contentRect.width;
const cssContentHeight = entry.contentRect.height;
// Best practice: make the canvas grid match the physical pixel grid
if (dpc) {
canvas.width = dpc[0].inlineSize;
canvas.height = dpc[0].blockSize;
} else {
canvas.width = Math.round(cssContentWidth * window.devicePixelRatio);
canvas.height = Math.round(cssContentHeight * window.devicePixelRatio);
}
scaleX = canvas.width / cssContentWidth;
scaleY = canvas.height / cssContentHeight;
});
const supportsDevicePixelContentBox =
typeof ResizeObserverEntry !== 'undefined' &&
'devicePixelContentBoxSize' in ResizeObserverEntry.prototype;
const options = supportsDevicePixelContentBox ? { box: 'device-pixel-content-box' } : {};
observer.observe(canvas, options);
接著,將 CSS 像素值乘以 scaleX 和 scaleY,再傳遞至繪圖方法。舉例來說,如要將可繪製元素置於畫布中央,請按照下列步驟操作:
const ctx = canvas.getContext('2d');
const card = document.getElementById('card');
canvas.onpaint = () => {
ctx.reset();
// offsetWidth and offsetHeight are in CSS pixels.
// Convert them to canvas grid coordinates.
const width = card.offsetWidth * scaleX;
const height = card.offsetHeight * scaleY;
const x = (canvas.width - width) / 2;
const y = (canvas.height - height) / 2;
// drawElementImage() takes canvas grid coordinates.
ctx.drawElementImage(card, x, y);
};
最後,由於 drawElementImage() 會繪製元素的邊框方塊,因此請使用 box-sizing: border-box 在資訊卡的 width 和 height 中加入邊框間距和邊框,這樣資訊卡就會以您設定的大小繪製:
#card {
/* Make width and height include padding and border */
box-sizing: border-box;
width: 200px;
padding: 16px;
}
瞭解 Canvas 內 HTML 的無障礙功能
一般來說,<canvas content="drawable"> 的 HTML 內容會以與一般 DOM 相同的方式公開給無障礙功能,也就是說,除非使用 aria-hidden="true" 或 inert 等屬性明確隱藏,否則會顯示在無障礙樹狀結構中。不過,有一個重要例外狀況:drawable 元素的算繪幾何圖形尚未建立或更新。
如果符合下列條件,<canvas content="drawable"> 內的 drawable 元素就不會顯示幾何圖形:
- 尚未透過
drawElementImage()算繪,預設會自動更新幾何圖形。 - 其幾何圖形並未明確以
updateElementGeometry()更新。
如果 drawable 元素缺少算繪幾何圖形,系統會將這個 drawable 元素及其 drawable 子樹狀結構回報給無障礙樹狀結構,但不含幾何圖形資訊。舉例來說,這項功能可讓螢幕閱讀器存取語意內容,同時指示視覺輔助工具 (例如放大鏡) 篩除未繪製的元素。
以下範例在分別繪製圖片和圖表時,保留圖表、圖片和說明文字的語意:
<canvas id="canvas" width="400" height="300" content="drawable">
<figure id="figure">
<img drawable id="image" src="..." alt="image alt text" />
<figcaption drawable id="caption">A caption</figcaption>
</figure>
</canvas>
<script>
const ctx = canvas.getContext('2d');
canvas.onpaint = () => {
ctx.reset();
// Note: Because `preserveElementGeometry` defaults to false, the
// `drawElementImage` call automatically updates geometry, ensuring
// the image and caption have geometry information for accessibility.
ctx.drawElementImage(image, 0, 0);
ctx.drawElementImage(caption, 0, 200);
};
</script>
備用策略
HTML-in-Canvas API 隨 Chrome 157 發布,可能不支援所有瀏覽器。為確保解決方案能順利運作,請檢查瀏覽器是否支援 API,並在必要時改用替代解決方案:
export function supportsHtmlInCanvas() {
if (typeof document === 'undefined') return false;
const canvas = document.createElement('canvas');
canvas.setAttribute('content', 'drawable');
return canvas.content === 'drawable';
}
如果系統不支援該 API,您可以根據導入目標,選擇下列其中一種替代方案:
- 替代 UI
- 浮動 HTML 疊加層
- three-html-render 程式庫
- html2canvas
- dom-to-image
後續步驟
- 瞭解如何在 2D 情境中算繪 HTML。
- 瞭解如何在 WebGL 畫布中算繪 HTML。
- 瞭解如何在 WebGPU 畫布中算繪 HTML。
- 瞭解備用策略。
- 歡迎在 GitHub 回報問題,分享您的意見。
- 請參閱社群提供的 HTML-in-Canvas 示範集合。