HTMLElement,SVGAElement 如何转换成图片下载?(html2canvas 的基本实现原理)

要生成图片,必然需要了解一下浏览器普遍支持的图像文件类型

我们最常用的有:JPEG、PNG、GIF、SVG ......

前置知识

MIME 类型

MIME 类型是一种标准,用来表示文档、文件或一组数据的性质和格式。

主要由两个部分:类型 (type)和子类型 (subtype),还有一个可选参数(用来指定数据中的字符所使用的字符集)组成。

bash 复制代码
type/subtype;parameter=value

如果感觉陌生,那打开控制台,随便点击一个文件,看看 Response HeadersContent-Type 吧。

对,其实我们早就很熟悉,text/htmltext/csstext/javascriptimage/pngimage/svg+xml (重点需要提前知道 image 图片类型 所支持的图像文件格式 中有 svg+xml)......

Data URL

Data URL 是前缀为 data: 协议的 URL,允许向文档中嵌入小文件。(与 URL 不同,被现代浏览器视作唯一的不透明来源,不可用于导航)

四个部分组成 前缀(data:)、MIME 类型、可选的 base64 标记、数据本身。

javascript 复制代码
`data:${mediatype}${;base64},${data}`

本文下面的 imgsrc 属性都是使用 Data URL

接下来进入正题。

Canvas 生成图片下载

我们先从最简单的 Canvas 生成图片并下载的一段代码开始:

ini 复制代码
 const link = document.createElement('a');
 link.download = '图片.png';
 link.href = canvas.toDataURL();
 document.body.appendChild(link);
 link.click();
 document.body.removeChild(link);

通过调用 canvas 的 toDataURL 方法,返回一个包含 canvas 数据,默认图片格式为 image/pngData URL

然后通过 a 锚元素href 属性指定资源。download 属性表明链接视为下载资源,同时表明下载文件的名称。

SVGElement 生成图片

SVG 基于 XML 标记语言,用于描述二维的矢量图形。

作为一个基于文本的开放网络标准,SVG 能够优雅而简洁地渲染不同大小的图形,并和 CSS、DOM、JavaScript 等其他网络标准无缝衔接。

本质上,SVG 相对于图像,就好比 HTML 相对于文本。

在最开始的时候,我们已经了解到 image 所支持的图像文件格式 中有 svg+xml (也就是 XML 字符串形式的 SVG)。

  1. SVG 转 XML 字符串
ini 复制代码
 const svgString = new XMLSerializer().serializeToString(svgElement);

新建一个 XMLSerializer 对象实例,然后通过 serializeToString 方法来将 DOM 树序列化为一个的 XML 字符串。

  1. 生成图片
ini 复制代码
 const img = new Image();
 img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgString)}`;

序列化为 XML 字符串的 SVG 通过 encodeURIComponent 方法把特定的一些字符(如空格、斜杠等)进行转义为对应的 UTF-8

Data URL 形式 作为 img.src 的参数。

  1. 下载图片

此时如果想要下载图片,需要先通过 canvas 的 drawImage 方法将 img 元素绘制到 canvas 上,再重复上面 canvas 下载图片的代码。

下面给出完整代码:

ini 复制代码
function svgToImg(svgElement: SVGElement) {
  const svgString = new XMLSerializer().serializeToString(svgElement);
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgString)}`;
  img.onload = () => {
    const canvas = document.createElement('canvas');
    const context = canvas.getContext('2d');
    canvas.width = svgElement.clientWidth;
    canvas.height = svgElement.clientHeight;
    context?.drawImage(img, 0, 0);

    const link = document.createElement('a');
    link.download = '图片.png';
    link.href = canvas.toDataURL('image/png');
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
  };
}

为什么需要通过 canvas 多做这么一步呢?

别忘记了,我们此时的图片格式是 svg+xml。而我们一般用的图片格式都是 .jpg.png

所以不通过 canvas 的话,此时我们下载的话,只是一个 SVG 。(如何下载页面上的 SVG 呢?如下代码)

ini 复制代码
  const link = document.createElement('a');
  link.download = 'SVG.svg';
  link.href = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgString)}`;
  document.body.appendChild(link);
  link.click();
  document.body.removeChild(link);

HTMLElement 生成图片

接下来到了最关键的 DOM 如何转成图片了。

但图像文件格式似乎没有 image/dom 这种格式,那我们要怎么把 DOM 转换成图片呢?

幸好 SVG 中的 foreignObject 元素允许包含来自不同的 XML 命名空间的元素。(也就是说我们可以使用 HTML)

所以我们可以把 DOM 放到 SVG 内的 foreignObject 里 (foreignObject 一定要设置对应的宽高) ,通过 SVG 为媒介,然后重复上面的步骤,此时我们就可以写出下面这个方法了。

ini 复制代码
function htmlToImg(htmlElement: HTMLElement) {
  const { clientWidth, clientHeight } = htmlElement;

  const svgString = `
  <svg xmlns="http://www.w3.org/2000/svg" width="${clientWidth}px" height="${clientHeight}px">
    <foreignObject style="width:${clientWidth}px; height:${clientHeight}px;">
      ${new XMLSerializer().serializeToString(htmlElement)}
    </foreignObject>
  </svg>`;

  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgString)}`;

  img.onload = () => {
    const canvas = document.createElement('canvas');
    const context = canvas.getContext('2d');
    canvas.width = clientWidth;
    canvas.height = clientHeight;
    context?.drawImage(img, 0, 0);

    const link = document.createElement('a');
    link.download = '图片.png';
    link.href = canvas.toDataURL('image/png');
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
  };
}

有时候生成的图片,可能不是想象的样子,可以通过 DOMParser.parseFromString 方法,解析为一个 DOM Document,先验证下生成的 SVG 的样子。

ini 复制代码
  const svgDoc = new DOMParser().parseFromString(svgString, 'image/svg+xml');
  document.body.appendChild(svgDoc.firstElementChild);

样式丢失问题

  • DOM 放到 SVG 内时部分样式丢失:

这个经常会是因为 css 用了后代选择器的写法,当我们把部分 DOM 放到 SVG 内后,一些选择器的失效,丢失了样式。

  • SVG 绘制到 canvas 上时部分样式丢失:

因为此时元素的样式是通过 class 属性是指向样式表的,所以当被序列化为 xml 字符串时,这个连接就被中断了。只有直接写在 style 属性上,以内联样式设置元素属性的样式还能生效。

要怎么解决呢?

我参考了 html2canvas 是如何解决的,关键点是用 getComputedStyle 方法来获取元素在浏览器中最终渲染效果的最终样式,然后赋值到 style 上。

ini 复制代码
//深拷贝样式
const copyCSSStyles = <T extends HTMLElement | SVGElement>(
  style: CSSStyleDeclaration,
  target: T,
): T => {
  //忽略样式的属性
  const ignoredStyleProperties = [
    'all', // #2476
    'd', // #2483
    'content', // 如果设置了内容,Safari 会显示伪元素
  ];

  // Edge 不提供 cssText 属性的值
  for (let i = style.length - 1; i >= 0; i--) {
    const property = style.item(i);
    if (ignoredStyleProperties.indexOf(property) === -1) {
      target.style.setProperty(property, style.getPropertyValue(property));
    }
  }
  return target;
};

接下来我们自己写个深拷贝 dom 的代码:

php 复制代码
//深拷贝 dom
function deepClone(node: HTMLElement | SVGElement) {
  const clone = node.cloneNode(false) as HTMLElement | SVGElement;
  
  copyCSSStyles(window.getComputedStyle(node), clone);

  for (const child of node.childNodes) {
    if (child instanceof HTMLElement || child instanceof SVGElement) {
      const clonedChild = deepClone(child);
      clone.appendChild(clonedChild);
    } else if (child instanceof Text) {
      clone.appendChild(child.cloneNode());
    }
  }
  return clone;
}

这样子,就解决了以上样式丢失的问题。★,° :.☆( ̄▽ ̄)/$:.°★

最后

  • 特殊需求,特殊处理

html2canvas 的定位是截图功能,所以当有一些特殊的需求,比如不要包含某个样式,或者想修改宽度以显示 div 内被 overflow 的所有元素,此时就无法很好的满足。需要知道实现原理,对特殊需求进行特殊处理。

  • 快速定位,自己实现

有时 html2canvas 生成的结果并不是 100% 准确,因为是基于读取 DOM 和样式,并没有制作实际的屏幕截图,而是根据页面上可用的信息构建屏幕截图。此时需要知道实现原理,快速定位问题,甚至自己简单实现。

当然,html2canvas 还做了浏览器之间的兼容,这里只是基本的实现原理而已。

相关推荐
魏大帅。3 分钟前
Axios 的 responseType 属性详解及 Blob 与 ArrayBuffer 解析
前端·javascript·ajax
花花鱼9 分钟前
vue3 基于element-plus进行的一个可拖动改变导航与内容区域大小的简单方法
前端·javascript·elementui
k093313 分钟前
sourceTree回滚版本到某次提交
开发语言·前端·javascript
EricWang135834 分钟前
[OS] 项目三-2-proc.c: exit(int status)
服务器·c语言·前端
September_ning34 分钟前
React.lazy() 懒加载
前端·react.js·前端框架
web行路人44 分钟前
React中类组件和函数组件的理解和区别
前端·javascript·react.js·前端框架
超雄代码狂1 小时前
ajax关于axios库的运用小案例
前端·javascript·ajax
长弓三石1 小时前
鸿蒙网络编程系列44-仓颉版HttpRequest上传文件示例
前端·网络·华为·harmonyos·鸿蒙
小马哥编程1 小时前
【前端基础】CSS基础
前端·css
嚣张农民2 小时前
推荐3个实用的760°全景框架
前端·vue.js·程序员