绘制 DOM 到 Canvas

11,234 阅读2分钟

前言

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

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-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 的工具函数:

// 非递归、深度优先遍历
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> 中书写的,经过浏览器渲染引擎计算得到的样式结果。

// 凡是要复制的样式,都写在这
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 中是无效的资源地址。

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 结构。

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

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

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 模糊的问题也就迎刃而解。

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 还原。