绘制 DOM 到 Canvas
January 01, 2021
前言
Canvas API 提供了一个通过 JavaScript 和 HTML 的 <canvas>
元素来绘制图形的方式。它可以用于动画、游戏画面、数据可视化、图片编辑以及实时视频处理等方面。
除了以上的内容,还可以直接绘制 DOM。
原理
- 借助于 SVG 中
<foreignObject>
元素的能力,允许将 XHTML 片段嵌入其中,从而成为 SVG 矢量图的一部分 - 组装 Data URL,其格式类似于
src = data:image/svg+xml,[svg]
- 调用
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 还原。