绘制 DOM 到 Canvas

January 01, 2021

前言

Canvas API 提供了一个通过 JavaScript 和 HTML 的 <canvas> 元素来绘制图形的方式。它可以用于动画、游戏画面、数据可视化、图片编辑以及实时视频处理等方面。

除了以上的内容,还可以直接绘制 DOM。

原理

  1. 借助于 SVG 中 <foreignObject> 元素的能力,允许将 XHTML 片段嵌入其中,从而成为 SVG 矢量图的一部分
  2. 组装 Data URL,其格式类似于 src = data:image/svg+xml,[svg]
  3. 调用 CanvasRenderingContext2D.drawImage(src) 绘制到 canvas 上

知道了原理,下面就来逐步实现这个功能。

获取 DOM

html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Document</title>
</head>
<style>
h1 {
color: #ff4040;
border: 2px solid #ccc;
width: 200px;
}
img {
margin-right: 10px;
}
</style>
<body>
<div id="container">
<header>
<h1>hello, world</h1>
<p>你好,世界</p>
</header>
<main>
<img src="https://dummyimage.com/200" />
<img src="https://dummyimage.com/100" />
</main>
<footer></footer>
</div>
<button onclick="dom2base64(document.getElementById('container'))">
DOM -> Canvas
</button>
</body>
</html>
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Document</title>
</head>
<style>
h1 {
color: #ff4040;
border: 2px solid #ccc;
width: 200px;
}
img {
margin-right: 10px;
}
</style>
<body>
<div id="container">
<header>
<h1>hello, world</h1>
<p>你好,世界</p>
</header>
<main>
<img src="https://dummyimage.com/200" />
<img src="https://dummyimage.com/100" />
</main>
<footer></footer>
</div>
<button onclick="dom2base64(document.getElementById('container'))">
DOM -> Canvas
</button>
</body>
</html>

这里的获取 DOM 不仅仅指获取 id 为 container 的父容器元素,更多的指获取 #container 的所有子元素(children),所以需要实现一个遍历 DOM Tree 的工具函数:

js
// 非递归、深度优先遍历
const DFSDomTraversal = root => {
if (!root) return;
const arr = [],
queue = [root];
let node = queue.shift();
while (node) {
arr.push(node);
if (node.children.length) {
for (let i = node.children.length - 1; i >= 0; i--) {
queue.unshift(node.children[i]);
}
}
node = queue.shift();
}
return arr;
};
js
// 非递归、深度优先遍历
const DFSDomTraversal = root => {
if (!root) return;
const arr = [],
queue = [root];
let node = queue.shift();
while (node) {
arr.push(node);
if (node.children.length) {
for (let i = node.children.length - 1; i >= 0; i--) {
queue.unshift(node.children[i]);
}
}
node = queue.shift();
}
return arr;
};

至于为什么要获取所有子元素,这是因为绘制 DOM 到 canvas 上,必然意味着把所有元素的样式也一并绘制上。

复制样式

当 SVG 被赋值给 dataURL 时,<style> 标签中写入的样式已隔离无效,需要一种方法将 计算样式(Computed Style) 复制到 行内样式(Inline Style),这里的计算样式是在 <style> 中书写的,经过浏览器渲染引擎计算得到的样式结果。

js
// 凡是要复制的样式,都写在这
const CSSRules = ["color", "border", "width", "margin-right"];
const copyStyle = element => {
const styles = getComputedStyle(element);
CSSRules.forEach(rule => {
element.style.setProperty(rule, styles.getPropertyValue(rule));
});
};
js
// 凡是要复制的样式,都写在这
const CSSRules = ["color", "border", "width", "margin-right"];
const copyStyle = element => {
const styles = getComputedStyle(element);
CSSRules.forEach(rule => {
element.style.setProperty(rule, styles.getPropertyValue(rule));
});
};

处理图像资源

除了处理样式问题,子元素中的 <img> 元素的 src 资源也需被替换成 base64,否则在 dataURL 中是无效的资源地址。

js
const img2base64 = element => {
return new Promise((resolve, reject) => {
const img = new Image();
// 处理 canvas 受污染的情况
img.crossOrigin = "anonymous";
img.onerror = reject;
img.onload = function() {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = this.naturalWidth;
canvas.height = this.naturalHeight;
ctx.drawImage(this, 0, 0);
resolve(canvas.toDataURL());
};
img.src = element.src;
});
};
js
const img2base64 = element => {
return new Promise((resolve, reject) => {
const img = new Image();
// 处理 canvas 受污染的情况
img.crossOrigin = "anonymous";
img.onerror = reject;
img.onload = function() {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = this.naturalWidth;
canvas.height = this.naturalHeight;
ctx.drawImage(this, 0, 0);
resolve(canvas.toDataURL());
};
img.src = element.src;
});
};

序列化 DOM

XMLSerializer 接口提供 serializeToString() 方法来构建一个代表 DOM 树的 XML 字符串,即 XHTML,它是更严谨更纯净的 HTML 版本。

SVG 中的 <foreignObject> 元素允许包含来自不同的 XML 命名空间的元素,在浏览器的上下文中,可以为 XHTML.

如此一来,生成的 SVG 元素就包含了要绘制的 DOM 结构。

js
let XHTML = new XMLSerializer().serializeToString(root);
const SVGDomElement = `<svg xmlns="http://www.w3.org/2000/svg" height="${height}" width="${width}">
<foreignObject height="100%" width="100%">${XHTML}</foreignObject>
</svg>`;
js
let XHTML = new XMLSerializer().serializeToString(root);
const SVGDomElement = `<svg xmlns="http://www.w3.org/2000/svg" height="${height}" width="${width}">
<foreignObject height="100%" width="100%">${XHTML}</foreignObject>
</svg>`;

dom2base64

最终将上述步骤串联起来:

js
const dom2base64 = async root => {
DFSDomTraversal(root).forEach(copyStyle);
const imgElements = [...root.querySelectorAll("img")];
const base64Result = await Promise.all(imgElements.map(img2base64));
const width = root.offsetWidth;
const height = root.offsetHeight;
let XHTML = new XMLSerializer().serializeToString(root);
imgElements.forEach((element, index) => {
XHTML = XHTML.replace(element.src, base64Result[index]);
});
const SVGDomElement = `<svg xmlns="http://www.w3.org/2000/svg" height="${height}" width="${width}">
<foreignObject height="100%" width="100%">${XHTML}</foreignObject>
</svg>`;
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = width;
canvas.height = height;
const img = new Image();
img.onload = function() {
ctx.drawImage(this, 0, 0);
document.body.appendChild(canvas);
};
img.src = `data:image/svg+xml,${SVGDomElement}`;
};
js
const dom2base64 = async root => {
DFSDomTraversal(root).forEach(copyStyle);
const imgElements = [...root.querySelectorAll("img")];
const base64Result = await Promise.all(imgElements.map(img2base64));
const width = root.offsetWidth;
const height = root.offsetHeight;
let XHTML = new XMLSerializer().serializeToString(root);
imgElements.forEach((element, index) => {
XHTML = XHTML.replace(element.src, base64Result[index]);
});
const SVGDomElement = `<svg xmlns="http://www.w3.org/2000/svg" height="${height}" width="${width}">
<foreignObject height="100%" width="100%">${XHTML}</foreignObject>
</svg>`;
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = width;
canvas.height = height;
const img = new Image();
img.onload = function() {
ctx.drawImage(this, 0, 0);
document.body.appendChild(canvas);
};
img.src = `data:image/svg+xml,${SVGDomElement}`;
};

点击 DOM to Canvas 按钮,可以看到:

但会发现生成的 canvas 画质下降、文字模糊(建议点击大图,仔细对比),这是因为我使用的是 retina 屏幕(DPR = 2), <h1> 的 CSS content-width 为 200px,但实际上生成的像素为 400 px.

content-width 不是 box-width,默认情况下 box-width = content-width + padding-width + border-width

可以通过浏览器 DevTools 获取 DOM 节点快照,看到该 DOM 节点的实际宽度为 400px,这就是高倍屏非常清晰的原理,400px 的原始图被缩放到 200px,2px 当 1px 用,自然会变清晰,仿佛一张固定大小的图片,你看大图就非常模糊,小图就很清楚。

绘制完成的 canvas 本身可以视作一张图片,假设其图片原始宽度为 200px,CSS 宽度也是 200px,由于 retina 屏幕的特性,用了 400px 去绘制,原先的 1px 当 2px 用,自然会出现像素的稀释。

DPR 优化

这里有个前置知识点:<canvas> 元素有两个宽度,一个是画布的宽度:canvas.width,另一个是实际在网页上展示的 CSS 宽度:canvas.style.width,高度同理。

只要将画布宽高调整为原来的 2 倍,画布内的内容宽高相应扩大 2 倍,即图片原始宽高由 200px 变成 400px,而 CSS 宽高依旧保持不变(还是 200px),canvas 模糊的问题也就迎刃而解。

js
const dom2base64 = async (root, dpr = window.devicePixelRatio) => {
// ……
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
const img = new Image();
img.onload = function() {
ctx.scale(dpr, dpr);
ctx.drawImage(this, 0, 0);
document.body.appendChild(canvas);
};
img.src = `data:image/svg+xml,${SVGDomElement}`;
};
js
const dom2base64 = async (root, dpr = window.devicePixelRatio) => {
// ……
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
const img = new Image();
img.onload = function() {
ctx.scale(dpr, dpr);
ctx.drawImage(this, 0, 0);
document.body.appendChild(canvas);
};
img.src = `data:image/svg+xml,${SVGDomElement}`;
};

优化后的 canvas 如下所示,做到了 1:1 还原。


B2D1 (包邦东)

Written by B2D1 (包邦东)