HTML-in-Canvas:技術概念

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):

四格圖表,比較曲線邊緣的像素格線:1. 未對齊的 CSS (360 x 180)、畫布 (300 x 150) 和裝置 (540 x 270) 格線。2. 曲線會反鋸齒化,並套用至粗糙的 300x150 畫布格線。3. 該點陣圖會內插到 540 x 270 的裝置格線,第二次消除鋸齒並模糊邊緣。4. 540 x 270 的畫布,與裝置格線 1 對 1 匹配,經過一次消除鋸齒處理,邊緣清晰。
未對齊的 CSS、畫布和裝置像素格線如何導致雙重反鋸齒 (面板 1 至 3),以及 1:1 畫布與裝置格線 (面板 4) 的比較。

如要清晰顯示,畫布格線必須與畫布涵蓋的裝置像素數量相符:以上例來說,就是 540 × 270。

並排比較 HTML 資訊卡,左側顯示「Hello from the DOM! 這張資訊卡是繪製在畫布上的 HTML。左圖:在預設的 300x150 畫布格線上繪製,看起來模糊不清。右圖:使用 ResizeObserver 調整大小,在 800 x 400 的畫布格線上繪製,看起來很清晰。
在 DPR 為 1.5 的螢幕上,相同的畫布為 400 x 200 CSS 像素。左:400 x 200 畫布格線。右圖:根據裝置像素比調整為 600 x 300 網格。

使用 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,您可以根據導入目標,選擇下列其中一種替代方案:

後續步驟