史上最详细浏览器端网页截图方案解析

3,612 阅读23分钟

简介

剖析流行的截图插件 html2canvas 的实现方案,探索其功能上的一些不足之处及不能正确截取的一些场景,比如不支持 CSS 的 box-shadow 截取情况等。探索一种新的实现方式,能够避免多数目前 html2canvas 不支持的情况,解密其原理,深究 Canvas 绘图的机制。

本篇文章你可以学到:

  1. 纯前端网页截图的基本原理

  2. html2canvas 的核心原理

  3. SVG 内嵌 HTML 的方式

  4. Canvas 渲染 SVG 的方式及各种问题的解决方案

适合人群:前端开发

开篇

平时很多时候,需要把当前页面或者页面某一部分内容保存为图片分享出去,也或者有其他的业务用途,这种在很多的营销场景和裂变的过程都会使用到,那我们要把一个页面的内容转化为图片的这个过程,就是比较需要探讨的了。

首先这种情况,想到的实现方案就是使用 Canvas 来实现,我们探索一下基本实现步骤:

  1. 把需要分享或者记录的内容绘制到 Canvas 上

  2. 把绘制之后的 Canvas 转换为图片

这里需要明确的一点就是,只要把数据绘制到 Canvas 上,这就在 Canvas 画布上形成了被保存在内存中的像素点信息,所以可以直接调用 Canvas 的 API 方法 toDataURL、toBlob,把已经形成的像素信息转化为可以被访问的资源 URI,同时保存在服务器当中。这就很轻松的解决了第二步(把 Canvas 转为图片链接),下面是代码的实现:

图片

在实现了第二步的情况之下,需要关注的就是第一步的内容,怎么把内容绘制到 Canvas 上,我们知道 Canvas 的绘图环境有一个方法是 ctx.drawImage,可以绘制部分元素到 Canvas 上,包含图片元素 Image、svg 元素、视频元素 Video、Canvas 元素、ImageBitmap 数据等,但是对于一般的其他 div 或者列表 li 元素它是不可以被绘制的。

所以,这不是直接调用绘图的 API 就可以办到的,我们就需要思考其他的方法。在一般的实现上,比较常见的就是使用 html2canvas,那么我们先来聊聊 html2canvas 的使用和实现。

html2canvas 的使用及实现

使用

首先看一下 html2cavas 的使用方法:

图片

调用 html2canvas 方法传入想要截取的 Dom,执行之后,返回一个 Promise,接收到的 Canvas 上,就绘制了我们想要截取的 Dom 元素。到这一步之后,我们再调取 Canvas 转图片的方法,就可以对其做其他的处理。

这里它的 html2canvas 方法还支持第二个选项传入一些用户的配置参数,比如是否启用缓存、整个绘图 Canvas 的宽高值等。

在这个转换的过程,在 html2canvas 的内部,是怎么把 Dom 元素绘制到 Canvas 上的,这是咱们需要思考的问题!

实现

首先咱们先献上一个内部的大致流程图:

图片

image

对比着内部的流程图,就可以理一下整体的思路,整体的思路就是遍历目标节点和子节点,收集样式,计算节点本身的层级关系和根据不同的优先级绘制到画布中,下面基于这个思路,咱们深入一下整个过程。

1. 调用 html2canvs 函数,直接返回一个执行函数,这一步没有什么。

2. 在执行函数的内部第一步是构建配置项 defaultOptions,在合并默认配置的过程中,有一个缓存的配置,它会生成处理缓存的方法。

  • 处理缓存类,对于一个页面中的多个不同的地方渲染调用多次的情况做优化,避免同一个资源被多次加载;

  • 缓存类里面控制了所有图片的加载和处理,包括使用 proxy 代理和使用 cors 跨域资源共享这两种情况资源的处理,同时也对 base64 和 blob 这两种形式资源的处理。比如如果渲染 Dom 里面包含一个图片的链接类型是 blob,使用的方式就是如下处理,然后添加到缓存类中,下次使用就不需要再重新请求。

image.gif

image

3. 在上一步生成了默认配置的情况之下,传入需要绘制的目标节点 element 和配置到 DocumentCloner 里面,这个过程会克隆目标节点所在的文档节点 document,同时把目标节点也克隆出来。这个过程中,只是克隆了开发者定义的对应节点样式,并不是结合浏览器渲染生成特定视图最后的样式。

图片

image

如上这个 .box 的元素节点,定义的样式只有高度,但是在浏览器渲染之下,会对它设置默认的文字样式等等。

4. 基于上一步的情况,就需要把克隆出来的目标节点所在的文档节点 document 进行一次浏览器的渲染,然后在收集最终目标节点的样式。于此,把克隆出来的目标节点的 document 装载到一个 iframe 里面,进行一次渲染,然后就可以获取到经过浏览器视图真实呈现的节点样式。

图片

image

在这个过程中,就可以通过 window.getComputedStyle 这个 API 拿到要克隆的目标节点上所有的样式了(包含自定义和浏览器默认的结合最终的样式)。

5. 目标节点的样式和内容都获取到了之后,就需要把它所承载的数据信息转化为 Canvas 可以使用的数据类型,比如某一个子节点的宽度设置为 50%或者 2rem,在这个过程中,就需要根据父级的宽度把它计算成为像素级别的单位。同时对于每一个节点而言需要绘制的包括了边框、背景、阴影、内容,而对于内容就包含图片、文字、视频等。这个过程就需要对目标节点的所有属性进行解析构造,分析成为可以理解的数据形式。

图片

图片

如上图片这种数据结构和我注释一样,在它内部把每一个节点处理成为了一个 container,它的上面有一个 styles 字段,这个字段是所有节点上的样式经过转换计算之后的数据,还有一个 textNodes 属性,它表示当前节点下的文本节点,如上,每一个文本的点的内容使用 text 来表示,位置和大小信息放置在 textBounds 中。对于 elements 字段存放的就是当前节点下除了文本节点外,其他节点转换成为的 container,最后一个就是 bounds 字段,存放的是当前节点的位置和大小信息。可以看一下 container 这个类的代码:

图片

基于这种情况,每一个 container 数据结构的 elements 属性都是子节点,整个节点就够构造成一个 container tree。

6. 在通过解析器把目标节点处理成特定的数据结构 container 之后,就需要结合 Canvas 调用渲染方法了,我们在浏览器里面创建多个元素的时候,不同的元素设置不同的样式,最后展示的结果就可能不一样,比如下面代码:

图片

这个代码的展示结果如下:

图片

此时,如果修改了代码中 .sta1 元素节点的 opacity 属性为 0.999,此时整个布局的层级就会发生大变化,结果如下:

图片

这个是什么原因?因为 Canvas 绘图需要根据样式计算哪些元素应该绘制在上层,哪些在下层。元素在浏览器中渲染时,根据 W3C 的标准,所有的节点层级布局,需要遵循层叠上下文和层叠顺序的标准。当某一些属性发生变化,层叠上下文的顺序就可能发生变化,比如上列中透明度默认为 1 和不为 1 的情况(对于如何形成一个层叠上下文此处不做深入讲解,可以自行研究)。

更加直白的理解就是,一部分属性会使一些元素形成一个单独的层级,不同属性的层级有一定的排列顺序。如下就是我们对应的顺序:

  • 形成层叠上下文环境的元素的背景与边框(相当于整个文档的背景和边框)

  • 拥有负 z-index 的子层叠上下文元素 (负的越高越层叠上下文层级越低)

  • 正常流式布局,非 inline-block,无 position 定位(static 除外)的子元素

  • 无 position 定位(static 除外)的 float 浮动元素

  • 正常流式布局, inline-block 元素,无 position 定位(static 除外)的子元素(包括 display:table 和 display:inline

  • 拥有 z-index:0 或者 auto 的子堆叠上下文元素

  • 拥有正 z-index: 的子堆叠上下文元素(正的越低层叠上下文层级越低)

  • 在正常的元素情况下,没有形成层叠上下文的时候,显示顺序准守以上规则,在设置了一些属性,形成了层叠上下文之后,准守谁大谁上(z-index 比较)、后来居上(后写的元素后渲染在上面)

图片

此处,在清楚了元素的渲染需要遵循这个标准的情况之下,Canvas 绘制节点的时候,就需要先计算出整个目标节点里子节点渲染时所展现的不同层级。先给出来内部模拟层叠上下文的数据结构 StackingContext:

图片

以上就是某一个节点对应的层叠上下文在内部所表现出来的数据结构。很多属性都会形成层叠上下文,不同的属性形成的上下文,有不同的顺序,所以需要对目标节点的子节点解析,根据不同的样式属性分配到不同的数组中归类,比如遍历子节点的 container 上的 styles,发现 opacity 为 0.5,此时会形成层叠上下文,然后就把它构造成为上下文的数据结构 StackContext。添加到 zeroOrAutoZIndexOrTransformedOrOpacity 这个数组中,这样一个递归查看子节点的过程,最后会形成一个层叠上下文的树。

7. 基于上面构造出的数据结构,就开始调用内部的绘图方法了,以下代码是渲染某一个层叠上下文的代码:

image.gif

如上绘图函数中,如果子元素形成了层叠上下文,就调用 renderStack,这个方法内部继续调用了 renderStackContent,这就形成了对于层叠上下文整个树的递归。

图片

如果子元素没有形成层叠上下文,而是正常元素,就直接调用 renderNode 或者 renderNodeContent。这两个的区别是 renderNodeContent 只负责渲染内容,不会渲染节点的边框和背景色。

图片

对于 renderNodeContent 这个方法就是渲染一个元素节点里面的内容,可能是正常元素、图片、文字、SVG、Canvas、视频、input、iframe。对于图片、SVG、视频、Canvas 这几种元素,直接通过调用前文提到的 API,对于 input 需要根据样式计算出绘图数据来模拟完成,文字就直接根据提供的样式来绘制。重点需要提一下的是 iframe,如果需要绘制的元素中包含了 iframe,就相当于我们需要重新绘制一个新的文档 document,处理方法是在内部调用 html2canvas 的 API,绘制整个文档。

以下为多个不同类型的元素的绘制方式。

对于文字的绘制方式:

图片

对于图片、SVG、Canvas 元素的绘制:

图片

对于代码中调用 renderReplacedElement 方法内部的处理逻辑,就是调用 Canvas 的 drawImage 方法绘制以上三种数据形式。

对于需要绘制的元素是 iframe 的时候,做的处理逻辑就如同重新调用整个绘制方法,重新渲染页面的过程:

图片

对于单选或者多选框的处理情况,就是根据是否选中,来绘制对应状态的样式:

图片

对于 input 输入框的情况,首先需要绘制边框,然后把内部的文字绘制到输入框中,超出部分需要剪切掉,所以需要使用到 Canvas 的 clip 绘图 API:

图片

对于最后一种需要考虑的就是列表,对于 li、ol 这两种列表,都可以设置不同类型的 list-style,所以需要区分绘制。

图片

以上整个过程,就是 html2canvas 的整体内部流程,最后的操作都是不同的线条、图片、文字等等的绘制,概括起来就是遍历目标节点,收集样式信息,转化为绘制数据,并且根据一定的优先级策略递归绘制节点到 Canvas 画布上。

html2canvas 实现上的缺点

在捋顺了整个大流程的情况之下,咱们来看看 html2canvas 的一些缺点。

不支持的一些场景

  1. box-shadow 属性,支持的不好,因为对于 Canvas 的阴影 API 没有扩散半径。所以对于样式的阴影支持不是特别好;

  2. 边框虚线的情况也不支持,这一点源码里面没有使用 setLineDash,是因为大多数浏览器原本不支持这个属性,chrome 也是 64 版本之后才支持这个属性;

  3. css 中元素的 zoom 属性支持也不是也特别好,因为换算会出现问题;

  4. 计算问题是最大的问题!!!因为每一次计算都会有精确度的省略问题,比如父元素的宽度是 100 像素,子元素是父元素的 30%,这个时候转化为 Canvas 绘图单位像素的时候,就会有省略的过程,在有多次省略的情况之下,精确度就会变得不精确。并且还涉及到一些圆弧的情况,这种弧度的计算,最后模仿出来,都会有失去精确度的问题。对于正常的浏览器渲染节点,渲染的内部逻辑,直接是由浏览器处理,但是对于 html2canvas 的方案,需要先计算为像素单位,然后绘制到 Canvas 上,最后 Canvas 元素还要经过浏览器的一次处理,才能够渲染出来。这个过程不止是换算单位失去精度,渲染也会失去精度。

换一种思路实现截图

基于我们对于上面 html2canvas 整个流程的实现,会发现中间换算会出现很多不精准的问题,那么怎么做一个可以精准的绘制呢?能不能把所有内部绘制的换算过程全部交给浏览器?

基本思路

上文提到 Canvas 还可以绘制 image、SVG 等等,此处就可以把 HTML 处理成 SVG 的结果,然后再绘制到 Canvas 上。

对于 SVG 是一种可扩展标记语言,在转化的过程中,就需要使用到 这个 SVG 元素。 允许包含不同的 XML 命名空间,在浏览器的上下文中,很可能是 XHTML\HTML,如下是使用方式:

图片

这样只需要指定对应的命名空间,就可以把它嵌套到 foreignObject 中,然后结合 SVG,直接渲染。

什么是命名空间,相当于是元素名和属性名的一种集合,元素和属性可以有多种不同的集合,为了解决冲突,就需要有命名空间的指派,对于带有属性 xmlns="" 就是一个命名空间的表现形式。以下是多种命名空间:

对于不同的命名空间,浏览器解析的方式也不一样,所以在 SVG 中嵌套 HTML,解析 SVG 的时候遇到 www.w3.org/2000/svg 转化 SVG 的解析方式,当遇到了 www.w3.org/1999/xhtml 就使用 HTML 的解析方式。

这是为什么 SVG 中可以嵌套 HTML,并且浏览器能够正常渲染。

实现

但是这个过程中,会存在一些问题:

  1. SVG 是不允许连接到外部的资源,比如 HTML 中图片链接、CSS link 方式的资源链接等,在 SVG 中都会有限制;

  2. HTML 中会有脚本执行的情况,比如 Vue 的 SPA 单页项目,需要先执行 JS 的逻辑才能够渲染出 Dom 节点。但是 SVG 中,是不支持 JS 执行的情况。

  3. SVG 的位置大小和 foreignObject 标签的位置大小不能够确定,需要计算。

基于以上的情况,需要做一些其他的处理,以下为这个方案渲染的整个流程,看看如何解决存在的问题:

图片

对于这种方案需要处理以上几个流程:

  1. 初始化不同类型的截图需要,比如 DrawHTML(截取部分文档片段)、DrawDocument(截取完整 document 节点)、DrawURL(截取一个 HTML 资源链接)这几种形式,最后都会处理成截取整个 document 文档节点,以下是流程第一步的处理。
  • DrawHTML 转换部分文档片段为一个完整的 document 文档节点,然后使用 DrawDocument 的方式处理。

image.gif

DrawURL 转换一个 HTML 资源链接为截取一个完整的 document 文档节点,再使用 DrawDocument 的方式处理。

image.gif

可以看到最后的方式都是处理成一个 document 文档,实现到 drawDocument 这个方法里面,使用绘制 document 的形式来渲染。

基于上面的思路,把 document 文档转为 SVG,但是 document 文档里面包含了外部链接的图片资源、外部样式资源和脚本资源。这种情况在 SVG 是不支持的,所以这一步的处理方式是把所有的外部资源,处理为内联形式的,改造为新的 document,比如:

图片

以上这种文档结构中,所有的资源都是属于外部资源,如果要转变为 SVG,就需要处理成内联的形式,构造新的 document 文档,如下:

图片

所以上一步把所有截图形式都处理成为了渲染一个 document 文档之后,就需要对文档进行重构转换,处理文档内部所有外部资源,不同的资源对应不同的处理方式,这里需要处理的资源情况分为以下几点:

在 HTML 文档中存在 img 图片标签的链接为外部资源,需要处理为 base64 资源,通过 loadAndInlineIages 函数进行处理,以下是 loadAndInlineIages 函数。

image.gif

loadAndInlineImages 函数的处理流程是获取到所有和图片有关的标签,在通过 Ajajx 请求下来,然后处理成 Base64 的资源类型,对原有的图片标签进行替换,这样就把所有的标签图片,处理成为了内联资源类型。以下是 encodeImageAsDataURI 方法内部请求图片资源且转义 Base64 的逻辑:

图片

通过了以上步骤之后,此时的 document 文档里面的图片标签元素的资源已经全部为内联形式了

在 HTML 中同时也存在着脚本为外部资源的情况,对于脚本的处理逻辑,整体就比较简单了,获取到脚本的链接,请求脚本内容,之后用请求的内容替换原有的外部链接的

图片

以上处理脚本资源的方法整体比较简单。

  • 在处理完成了脚本和图片的情况之后,目前剩余需要处理成为内联资源的情况还剩下外部样式表。但是此处还需要注意一点,对于本来存在的内联样式也需要处理,因为可能会出现使用外链背景图的情况、通过@import 导入样式表的情况。

所以对于外部样式表请求下来的内容会存在同样的问题,所以对于外部样式表而言,整体的流程就是通过 Ajax 请求外部样式内容,然后对内容存在背景图片和 @import 的情况做处理。先供上对于 CSS 处理不同情况的流程处理:

图片

通过上面的架构流程图,可以看出来远端请求的样式表需要和内联样式做同样的处理,把内部的远端图片资源和字体资源处理为内联形式。

对外部样式表的请求逻辑,大致逻辑如下:

图片

通过以上代码,可以看见请求和处理逻辑全部在 requestStylesheetAndInlineResources 方法中,以下为代码方法:

图片

从以上的代码逻辑中,可以清楚,有几个 promise 的处理流程,每一个流程处理的内容主要做了以下几件事情:

  1. 请求远端样式资源表,通过封装的 Ajax 方法;

  2. 处理请求下来的样式表中可能使用到的远端图片或者字体资源链接,使用 inlineCss.adjustPathsOfCssResources 方法,把使用到资源的相对地址,处理成为绝对地址;

  3. 通过 inlineCss.loadCSSImportsForRule 方法处理@import 资源引入的情况;

  4. 请求样式表中使用到的图片和文字资源,并且处理成内联,这一步的逻辑在 inlineCss.loadAndInlineCSSResourcesForRules 这个方法中;

  5. 基于原有样式表构造新的样式表。

现在我们来看一下,对应每一种处理情况具体所做的事情:

  • Ajax 请求资源,这一步不做深入,简单的 Ajax 封装;

  • 对于 adjustPathsOfCssResources 方法处理链接相对路劲变为绝对路劲,整体的实现思路是遍历查找所有的 CSSRule,查找到 background、font-face、@import 等对应的 Rule,解析属性设置的值,判断引用的地址是否是外部 URL,处理路劲变换为绝对路劲。构建新的 CSSRule。

图片

通过上面的逻辑处理之后,此时所有的 CSS 中包含的外部资源的链接已经处理为绝对路劲,对于整个资源 CSS 中的资源内联处理,第一步就已经完成了。

对于处理完成路劲之后,对于上面整个资源处理的大流程 loadCSSImportsForRule 方法就是把 import 的外部 CSS 请求回来,然后重新构建新的 CSS。大体的思路为搜集当前 CSS 中所有的 import 资源地址,下载下来之后,构建为新的 CSS,在分析新的 CSS 是否包含 import,递归写入到最后的 CSSRule 中。

图片

对于以上代码处理 @import 的函数中,loadAndInlineCSSImport 方法就是核心的逻辑了,结合上面讲的整体处理流程,看看以下代码:

图片

这样就把所有的 CSS 中的 @import 的资源,也处理进来了。

对于 CSS 资源,处理到这一步之后,结合我们上面的流程图,就只剩下把所有的资源诸如背景图、font-face 等引用的外部链接变为内联资源。这一步的实现和上面 CSS 中转换资源相对路劲到绝对路劲,整个思路是一致的。区别在于对于最后一步替换相对路劲为绝对路劲的 URL 不一致,这里需要替换的是资源请求下来之后处理成为 base64 的 data 数据之后的链接。

  1. 首先遍历所有 CSSRule,找出需要替换的所有 Rule;

  2. 获取对应 Rule 中包含的外部链接;

  3. 请求资源回来之后,处理为 Base64 类型的 data 链接;

  4. 替换原有 Rule 中资源的地址,改为内联类型,构造成为新的 CSSRule。

这样整个流程中的资源就已经处理完成,目前构造出来的文档,全是内联文档,符合构造 SVG 的要求。

在处理完成内容之后,就需要计算整个文档需要展示的大小,这是在 SVG 构建的时候需要使用到的;因为在用户截图的时候回传入对应想要的大小,这个时候怎么去控制。大致的思路如下:

  • 根据用户传入宽高大小创建 iframe,把上面处理过的内联文档装载到 iframe 中执行;

  • 获取到执行之后文档的 clientWidth 和 clientHeight,同时根据 zoom 计算缩放的大小来作为最后 SVG 需要渲染的结果;

  • 获取装载之后 iframe 中的文档的 font-size 来设置 SVG 的内容字体大小。

经过上面这些步骤,我们计算出来了大小,剩下最后一步,序列化处理之后的文档节点构建 SVG:

1. 序列化文档节点的过程,就是把文档节点处理成为整个字符串的过程,在大多数浏览器中都是有序列化 API 的支持,不过有少数兼容问题,所以最优方法为自己实现序列化的过程,整个过程逻辑主要为递归遍历文档节点,处理节点名称大小写、文本内容中包含<、>、&这几个符号的转义处理及对整个文档添加指定的命名空间。

2. 在序列化文档文档之后,就需要使用序列化之后的内容和计算出来的展示文档大小值来构建 SVG,整个构建的过程代码大致流程:

图片

至此,SVG 构建已经构建完成,剩下最后一步就是把 SVG 处理成图片可以显示的资源。

处理图片显示的资源这个过程,其实有两种实现:

  • 第一种是通过 createObjectURL 把图片资源处理为 blob 数据,img 使用时直接使用 blob 数据;

  • 第二种是直接 Encode 对应的 SVG 资源,构建 data 资源链接。

图片

这两种生成的连接都可以对应添加到图片的 src 中;当然,此时也可以拿到对应的 SVG 调用 Canvas 绘图的 API 来绘制 SVG,做二次加工。

至此,这个思路的实现全部完成。

这个思路的缺点

基于以上两个思路的对比,明显会发现,使用 HTML 通过 foreignObject 构建 SVG 的方法要简单清晰,但是对于一些浏览器也会有一些小问题,不过已经有一个比较不错的库通过 hack 的方式,处理了这些问题。rasterizeHTML.js 是一个比较不错的截图库,实现的逻辑就是基于上面的思路。

不过这两种方式都会涉及到一个问题,就是图片资源跨域问题,如果图片为跨域图片,就需要通过 CORS 来处理。由于在 Canvas 位图中的像素可能来自多种来源,包括从其他主机检索的图像或视频,因此不可避免的会出现安全问题,所以对于除 CORS 以外的跨域图片,Canvas 都会被处理成污染的情况,此时 getImageData、toBlob、toDataURL 都会被禁止调用,这种机制也可以避免未经许可拉取远程网站信息而导致的用户隐私泄露,这对于 webgl 的贴图也是同样的处理,不能使用除 CORS 以外的跨域图片。

总结

以上总结了 html2canvas 的整体思路及优缺点,目前 html2canvas 源码里面也已经开始融合第二种思路,这说明了第二种截图思路的优点。但是第二种思路的过程中自己手动处理的序列化性能相比浏览器处理而言略微慢一点,等到浏览器序列化都支持的特别好的时候,就可以替代这一部分。当然,咱们也可以打开思路,结合 WebAssembly 来重写序列化的部分,打开整个 BS 架构大门。