網頁應用程式經常需要擷取並匯出豐富的 HTML 內容 (例如自訂資訊主頁、樣式文件或互動式圖表),並儲存為靜態圖片或錄製影片。過去,要達到這個目的,需要使用龐大的第三方程式庫,手動剖析 DOM 節點和 CSS 屬性,在畫布上重建視覺傳真。這種做法需要耗費大量運算資源、容易出錯,且通常不支援新式 CSS 版面配置功能。開發人員可以使用 HTML-in-Canvas API,直接在畫布內容中算繪實際的 DOM 元素。由於瀏覽器的原生算繪引擎會以像素級精確度繪製 HTML 子樹,因此使用內建的畫布方法 (例如 toDataURL()、toBlob() 或 captureStream()),就能以極高效率擷取精確的視覺輸出內容,做為圖片或影片串流。
導入方式
檢查瀏覽器是否支援 HTML-in-Canvas:
if ('content' in HTMLCanvasElement.prototype) { // Use HTML in Canvas API } else { // Use fallback strategy }在
<canvas>HTML 元素中加入content="drawable"屬性,初始化畫布,以便支援子系 HTML 元素的算繪。將 HTML 內容放入<canvas>元素中,並在繪製的每個元素中加入drawable屬性。具有drawable的元素會擷取其子樹狀結構,但同樣是drawable的巢狀子系除外:<canvas id="canvas" content="drawable"> <div id="html-content" drawable></div> </canvas>配合裝置縮放比例係數調整畫布格線,避免模糊:
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);在
canvas.onpaint事件處理常式中,將 HTML 內容算繪至畫布。在 2D 情境中,請使用
drawElementImage方法。它會繪製drawable元素的最後一個快照,以及其可繪製的子樹狀結構 (不包括其他可繪製的子項),從元素的邊框方塊開始,在 CSS 轉換之前:
canvas.onpaint = () => {
ctx.reset();
// Draw the form element at x:0, y:0
ctx.drawElementImage(form_element, 0, 0);
};
- 在 WebGL 和 WebGPU 情境中,您會將元素複製到紋理中。從
canvas.captureElementImage(element)調整紋理大小。其width和height位於畫布格線 (備份儲存區) 像素中,因此請使用Math.ceil()將其四捨五入。如果元素尚未有繪製記錄,captureElementImage()會擲回:呼叫requestPaint(),並在下一個paint事件重試。
// Returns the element's size in canvas grid pixels, or null if the element
// has no paint record yet.
function getElementImageSize(canvas, element) {
let elementImage;
try {
elementImage = canvas.captureElementImage(element);
} catch (err) {
canvas.requestPaint(); // No paint record yet: retry on the next paint event.
return null;
}
const width = Math.ceil(elementImage.width);
const height = Math.ceil(elementImage.height);
elementImage.close();
return width > 0 && height > 0 ? { width, height } : null;
}
- 在 WebGL 環境中,請先使用
texImage2D方法預先分配紋理背景。本指南中的程式碼片段使用 WebGL 2 情境 (canvas.getContext("webgl2"))。請使用null(而非像素資料) 呼叫texImage2D()來分配紋理儲存空間,這樣就能保留指定大小和格式的 GPU 記憶體,而不必上傳任何像素。請遵循下列規則:- 按照上文從
captureElementImage()調整紋理大小。 - 請只在首次上傳或大小變更時分配層級 0,因為重新分配會清除紋理。使用可變動儲存空間:元素大小變更時,不可變動的
texStorage2D()儲存空間無法調整大小。
- 按照上文從
// Binds the texture, and allocates its storage on the first upload, and when
// the element size changes. `state` stores the allocated size.
function allocateElementTexture(gl, texture, state, width, height) {
gl.bindTexture(gl.TEXTURE_2D, texture);
if (state.width === width && state.height === height) return;
gl.texImage2D(
gl.TEXTURE_2D,
0, // Mipmap level
gl.RGBA8, // Internal format: 8 bits per RGBA channel
width, // Size in canvas grid pixels
height,
0, // Border: must be 0
gl.RGBA, // Format and type that match RGBA8
gl.UNSIGNED_BYTE,
null // null reserves GPU memory without uploading pixels
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
state.width = width;
state.height = height;
}
const texture = gl.createTexture();
const textureState = { width: 0, height: 0 };
- 接著,在 WebGL 環境中,使用
texElementSubImage2D方法將 HTML 內容算繪至紋理。請遵循下列規則:- 在設定中傳遞
{ width, height }。無論畫布如何縮放,副本都會填滿分配的紋理。 - 如果大小有所變更,請重新分配紋理,如上所示。
- 在設定中傳遞
// Uploads the element into the texture. Returns false if the element has no
// paint record yet. In that case, it's uploaded on the next paint event.
function uploadElementWebGL(gl, canvas, texture, element, state) {
const size = getElementImageSize(canvas, element);
if (!size) return false;
const { width, height } = size;
// Bind the texture, and reallocate it if the size changed (see above).
allocateElementTexture(gl, texture, state, width, height);
gl.texElementSubImage2D(gl.TEXTURE_2D, 0, 0, 0, element, { width, height });
return true;
}
canvas.onpaint = () => {
try {
uploadElementWebGL(gl, canvas, texture, uiElement, textureState);
} catch (err) {
console.error('texElementSubImage2D copy failed:', err);
}
};
- 在 WebGPU 情境中,請使用
drawElementImageToTexture方法。請遵循下列規則:- 使用
COPY_DST和RENDER_ATTACHMENT用途建立紋理,並新增TEXTURE_BINDING,以便在著色器中取樣。如果使用方式錯誤,系統會回報 WebGPU 驗證錯誤,而非例外狀況。 - 將紋理大小傳遞為
size。如果沒有size,較大的元素會裁剪至紋理。如果使用size,元素會縮放至填滿。 - 元素大小變更時,重新建立紋理,並重建參照該紋理的繫結群組。
- 使用
// Create the texture once, and recreate it
// when the element size changes.
function ensureElementTexture(device, state, width, height) {
if (state.texture?.width === width && state.texture?.height === height) {
return state.texture;
}
state.texture?.destroy();
state.texture = device.createTexture({
size: { width, height },
format: "rgba8unorm",
usage:
GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.COPY_DST |
GPUTextureUsage.RENDER_ATTACHMENT,
});
return state.texture; // Recreate the bind groups that used the previous texture.
}
const textureState = { texture: null };
canvas.onpaint = () => {
const size = getElementImageSize(canvas, uiElement);
if (!size) return;
const previousTexture = textureState.texture;
const texture = ensureElementTexture(device, textureState, size.width, size.height);
if (texture !== previousTexture) {
// New texture: create, or rebuild, the bind groups that reference it.
}
try {
device.queue.drawElementImageToTexture({ source: uiElement }, { texture, size });
} catch (err) {
console.error('drawElementImageToTexture copy failed:', err);
}
};
使用 requestAnimationFrame 迴圈轉現場景時,請在迴圈中呼叫 canvas.requestPaint(),確保 HTML 內容轉譯至畫布。event.changedElements 會列出轉譯方式變更的元素。請務必只在子系 HTML 元素更新時,才重新繪製元素或重新上傳其紋理:
function render() {
// Request to update the canvas
canvas.requestPaint();
requestAnimationFrame(render);
}
requestAnimationFrame(render);
canvas.onpaint = (event) => {
if (event.changedElements && event.changedElements.length > 0) {
// Redraw the element, or update its texture, as shown above
}
// In WebGL and WebGPU, render the scene, and sync the element geometry as shown in step 5
};
- 同步處理元素幾何圖形。
瀏覽器需要知道 HTML 元素的繪製位置,才能讓 DOM 位置與繪製的像素相符,例如讓使用者點選並輸入內容。畫布只會對使用 2D drawElementImage() 方法繪製或使用 updateElementGeometry() 註冊的元素進行點擊測試。
如果是 2D 情境,瀏覽器會自動同步處理元素。
drawElementImage()會將 DOM 點擊測試邊界和螢幕閱讀器焦點環與繪製的座標對齊,因此您不需要設定style.transform。如要手動管理幾何圖形 (例如多次繪製相同元素),請將{ preserveElementGeometry: true }傳遞至drawElementImage(),並使用canvas.updateElementGeometry()套用轉換。如果是使用 WebGL 或 WebGPU 的 3D 情況,畫布無法判斷 HTML 元素的繪製位置,因此您必須告知。計算
DOMMatrix,將元素的邊框方塊 (以 CSS 像素為單位) 對應至畫布 (以 CSS 像素為單位)。瀏覽器需要使用可視區域轉換,從 3D 座標空間對應至 CSS 座標空間。如要簡化這項作業,請按照下列步驟操作:- 正規化 HTML 元素。HTML 元素的大小以像素為單位 (例如 200 像素寬)。不過,WebGL 通常會將物件視為「單位正方形」,例如範圍從 -0.5 到 0.5。如果不進行正規化,200 像素的按鈕看起來會大 200 倍。這個步驟也會翻轉 Y 軸,因為在 CSS 中,向下為正,但在 WebGL 中,向上為正。
- 將 WebGL MVP 矩陣轉換為 DOM 矩陣。
- 對應至畫布可視區域。這個步驟是「重新縮放」階段:將單位空間數學運算結果重新拉伸,以符合螢幕上
<canvas>元素的 CSS 像素尺寸,並將 Y 軸翻轉回來。 - 計算最終轉換。依序將矩陣相乘:可視區域 * MVP * 正規化。將這些轉換合併為最終轉換,就會產生「對應」,確切指出 HTML 元素應位於何處,才能與 3D 繪圖對齊。瀏覽器會執行透視除法。
// Maps the element's border box (CSS pixels) to the canvas (CSS pixels), for a // quad that spans -0.5 to 0.5 in model space and is drawn with the `mvp` matrix. function computeCanvasTransform(canvas, element, mvp) { const width = element.offsetWidth; const height = element.offsetHeight; // 1. Normalize the HTML element (CSS pixels -> WebGL Model Space) const toGLModel = new DOMMatrix() // Scale pixels to 1 unit, flip Y (as in CSS it points down, and in WebGL it points up) .scale(1 / width, -1 / height, 1 / height) // Center the origin: (0,0) becomes (-width/2, -height/2) before scaling .translate(-width / 2, -height / 2); // 2. Convert WebGL MVP Matrix to DOM Matrix const mvpDOM = new DOMMatrix(Array.from(mvp)); // 3. Map to the canvas viewport, in CSS pixels const clipToCanvasViewport = new DOMMatrix() // Move center (0,0) to center of canvas .translate(canvas.clientWidth / 2, canvas.clientHeight / 2) // Scale normalized clip (-1..1) to viewport size, and flip Y back .scale(canvas.clientWidth / 2, -canvas.clientHeight / 2, canvas.clientHeight / 2); // 4. Multiply: (Clip -> Pixels) * (MVP) * (pixels -> unit square) return clipToCanvasViewport.multiply(mvpDOM).multiply(toGLModel); }
然後將轉換傳遞至 canvas.updateElementGeometry() 做為 canvasTransform。這會將 HTML 元素移至其算繪像素的正上方。這可確保使用者點選按鈕或選取文字時,實際點擊的是真正的 HTML 元素。瀏覽器也會使用轉換功能,設定元素的無障礙功能邊界,例如螢幕閱讀器焦點環:
canvas.updateElementGeometry(targetHTMLElement, {
canvasTransform: computeCanvasTransform(canvas, targetHTMLElement, htmlElementMVP),
});
使用下列選項控制點擊測試:
- 每次呼叫
updateElementGeometry(),都會將元素移至點擊測試順序的頂端。傳遞{ preserveHitTestOrder: true }以保留其位置。 - 呼叫
canvas.clearElementGeometry(element),從點擊測試中移除元素,例如隱藏元素時。
- 使用一般的畫布匯出方法,例如
toDataURL()、toBlob()或captureStream()。匯出的資料會包含轉譯的 HTML 內容。
範例程式碼
<body>
<canvas id="canvas" style="width: 400px; height: 200px;" content="drawable">
<input id="element" drawable>
</canvas>
<button id="download">Download Image</button>
<script>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const element = document.getElementById('element');
const download = document.getElementById('download');
canvas.onpaint = (event) => {
ctx.reset();
// Draw the element into the canvas. The browser synchronizes its
// DOM position for hit testing (typing).
ctx.drawElementImage(element, 10, 10);
};
download.onclick = () => {
// Export the canvas content as an image
const dataURL = canvas.toDataURL('image/png');
const link = document.createElement('a');
link.download = 'exported-canvas.png';
link.href = dataURL;
link.click();
};
// Re-initialize canvas size on screen resize
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);
canvas.requestPaint();
});
const supportsDevicePixelContentBox =
typeof ResizeObserverEntry !== 'undefined' &&
'devicePixelContentBoxSize' in ResizeObserverEntry.prototype;
const options = supportsDevicePixelContentBox ? { box: 'device-pixel-content-box' } : {};
observer.observe(canvas, options);
</script>
</body>
最佳做法
- 必要:使用 HTML-in-Canvas API 前,請先確認瀏覽器是否支援。
- 必要:請務必在
<canvas>元素中加入content="drawable"屬性。 - 必要:請為繪製的每個元素 (包括畫布的直接子項) 新增
drawable屬性。 - 必要:使用
onpaint事件處理常式,將 HTML 內容算繪至畫布。 - 必要:在 2D 中使用
drawElementImage方法,或在 WebGPU 中使用drawElementImageToTexture,將 HTML 內容算繪至畫布。 - 必要:在 WebGL 中,使用
texImage2D()預先分配紋理緩衝區一次 (在初始化或元素大小變更時)。只有在大小變更時才重新分配紋理,並將TEXTURE_MIN_FILTER設為LINEAR。 - 必要:在 WebGL 中,使用
texElementSubImage2D方法將 HTML 內容算繪至您使用texImage2D()預先分配的紋理,大小為captureElementImage()。 - 必要:在 WebGPU 中,請根據
captureElementImage()設定紋理大小,並只在大小變更時重新建立紋理。使用COPY_DST和RENDER_ATTACHMENT用途建立紋理,並將紋理大小傳遞為size。 - 必要:HTML 元素的幾何圖形必須與顯示的內容同步。在 2D 模式下,
drawElementImage會自動執行這項操作。在 WebGL 和 WebGPU 中,呼叫canvas.updateElementGeometry(element, { canvasTransform })。 - 必要:使用
ResizeObserver觀察螢幕大小,並更新畫布大小,以符合裝置像素。 - 請勿在畫布中嵌入跨來源內容,因為系統不支援這類內容。
- 請勿在
onpaint事件處理常式中初始化ResizeObserver,否則可能導致記憶體流失。
備用策略
HTML-in-Canvas API 目前並非所有新式瀏覽器都支援,因此通常需要備用策略。不過,如果瀏覽器支援 HTML-in-Canvas,建議使用這項 API,因為能提升效能。
如果需要從畫布匯出 HTML 內容,請使用 html2canvas、dom-to-image 或 snapdom 等程式庫。
如要逐一擷取 HTML 互動影格 (例如串流),請使用 rrweb 等程式庫擷取 DOM 變異。
或者,您也可以實作警告,說明瀏覽器不支援 HTML 媒體匯出,因為不支援 HTML-in-Canvas。