简介
剖析流行的截图插件 html2canvas 的实现方案,探索其功能上的一些不足之处及不能正确截取的一些场景,比如不支持 CSS 的 box-shadow 截取情况等。探索一种新的实现方式,能够避免多数目前 html2canvas 不支持的情况,解密其原理,深究 Canvas 绘图的机制。
本篇文章你可以学到:
- 纯前端网页截图的基本原理
- html2canvas 的核心原理
- SVG 内嵌 HTML 的方式
- Canvas 渲染 SVG 的方式及各种问题的解决方案
适合人群:前端开发
开篇
平时很多时候,需要把当前页面或者页面某一部分内容保存为图片分享出去,也或者有其他的业务用途,这种在很多的营销场景和裂变的过程都会使用到,那我们要把一个页面的内容转化为图片的这个过程,就是比较需要探讨的了。
首先这种情况,想到的实现方案就是使用 Canvas 来实现,我们探索一下基本实现步骤:
- 把需要分享或者记录的内容绘制到 Canvas 上
- 把绘制之后的 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 上的,这是咱们需要思考的问题!
实现
首先咱们先献上一个内部的大致流程图:
对比着内部的流程图,就可以理一下整体的思路,整体的思路就是遍历目标节点和子节点,收集样式,计算节点本身的层级关系和根据不同的优先级绘制到画布中,下面基于这个思路,咱们深入一下整个过程。
1. 调用 html2canvs 函数,直接返回一个执行函数,这一步没有什么。
2. 在执行函数的内部第一步是构建配置项 defaultOptions,在合并默认配置的过程中,有一个缓存的配置,它会生成处理缓存的方法。
- 处理缓存类,对于一个页面中的多个不同的地方渲染调用多次的情况做优化,避免同一个资源被多次加载;
- 缓存类里面控制了所有图片的加载和处理,包括使用 proxy 代理和使用 cors 跨域资源共享这两种情况资源的处理,同时也对 base64 和 blob 这两种形式资源的处理。比如如果渲染 Dom 里面包含一个图片的链接类型是 blob,使用的方式就是如下处理,然后添加到缓存类中,下次使用就不需要再重新请求。
3. 在上一步生成了默认配置的情况之下,传入需要绘制的目标节点 element 和配置到 DocumentCloner 里面,这个过程会克隆目标节点所在的文档节点 document,同时把目标节点也克隆出来。这个过程中,只是克隆了开发者定义的对应节点样式,并不是结合浏览器渲染生成特定视图最后的样式。
如上这个 .box 的元素节点,定义的样式只有高度,但是在浏览器渲染之下,会对它设置默认的文字样式等等。
4. 基于上一步的情况,就需要把克隆出来的目标节点所在的文档节点 document 进行一次浏览器的渲染,然后在收集最终目标节点的样式。于此,把克隆出来的目标节点的 document 装载到一个 iframe 里面,进行一次渲染,然后就可以获取到经过浏览器视图真实呈现的节点样式。
在这个过程中,就可以通过 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. 基于上面构造出的数据结构,就开始调用内部的绘图方法了,以下代码是渲染某一个层叠上下文的代码:
如上绘图函数中,如果子元素形成了层叠上下文,就调用 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 的一些缺点。
不支持的一些场景
- box-shadow 属性,支持的不好,因为对于 Canvas 的阴影 API 没有扩散半径。所以对于样式的阴影支持不是特别好;
- 边框虚线的情况也不支持,这一点源码里面没有使用 setLineDash,是因为大多数浏览器原本不支持这个属性,chrome 也是 64 版本之后才支持这个属性;
- css 中元素的 zoom 属性支持也不是也特别好,因为换算会出现问题;
- 计算问题是最大的问题!!!因为每一次计算都会有精确度的省略问题,比如父元素的宽度是 100 像素,子元素是父元素的 30%,这个时候转化为 Canvas 绘图单位像素的时候,就会有省略的过程,在有多次省略的情况之下,精确度就会变得不精确。并且还涉及到一些圆弧的情况,这种弧度的计算,最后模仿出来,都会有失去精确度的问题。对于正常的浏览器渲染节点,渲染的内部逻辑,直接是由浏览器处理,但是对于 html2canvas 的方案,需要先计算为像素单位,然后绘制到 Canvas 上,最后 Canvas 元素还要经过浏览器的一次处理,才能够渲染出来。这个过程不止是换算单位失去精度,渲染也会失去精度。
换一种思路实现截图
基于我们对于上面 html2canvas 整个流程的实现,会发现中间换算会出现很多不精准的问题,那么怎么做一个可以精准的绘制呢?能不能把所有内部绘制的换算过程全部交给浏览器?
基本思路
上文提到 Canvas 还可以绘制 image、SVG 等等,此处就可以把 HTML 处理成 SVG 的结果,然后再绘制到 Canvas 上。
对于 SVG 是一种可扩展标记语言,在转化的过程中,就需要使用到
这样只需要指定对应的命名空间,就可以把它嵌套到 foreignObject 中,然后结合 SVG,直接渲染。
什么是命名空间,相当于是元素名和属性名的一种集合,元素和属性可以有多种不同的集合,为了解决冲突,就需要有命名空间的指派,对于带有属性 xmlns=""
就是一个命名空间的表现形式。以下是多种命名空间:
- HTML:http://www.w3.org/1999/xhtml
- SVG:http://www.w3.org/2000/svg
- MathML:http://www.w3.org/1998/math/MathML
对于不同的命名空间,浏览器解析的方式也不一样,所以在 SVG 中嵌套 HTML,解析 SVG 的时候遇到 http://www.w3.org/2000/svg 转化 SVG 的解析方式,当遇到了 http://www.w3.org/1999/xhtml 就使用 HTML 的解析方式。
这是为什么 SVG 中可以嵌套 HTML,并且浏览器能够正常渲染。
实现
但是这个过程中,会存在一些问题:
- SVG 是不允许连接到外部的资源,比如 HTML 中图片链接、CSS link 方式的资源链接等,在 SVG 中都会有限制;
- HTML 中会有脚本执行的情况,比如 Vue 的 SPA 单页项目,需要先执行 JS 的逻辑才能够渲染出 Dom 节点。但是 SVG 中,是不支持 JS 执行的情况。
- SVG 的位置大小和 foreignObject 标签的位置大小不能够确定,需要计算。
基于以上的情况,需要做一些其他的处理,以下为这个方案渲染的整个流程,看看如何解决存在的问题:
对于这种方案需要处理以上几个流程:
- 初始化不同类型的截图需要,比如 DrawHTML(截取部分文档片段)、DrawDocument(截取完整 document 节点)、DrawURL(截取一个 HTML 资源链接)这几种形式,最后都会处理成截取整个 document 文档节点,以下是流程第一步的处理。
- DrawHTML 转换部分文档片段为一个完整的 document 文档节点,然后使用 DrawDocument 的方式处理。
DrawURL 转换一个 HTML 资源链接为截取一个完整的 document 文档节点,再使用 DrawDocument 的方式处理。
可以看到最后的方式都是处理成一个 document 文档,实现到 drawDocument 这个方法里面,使用绘制 document 的形式来渲染。
基于上面的思路,把 document 文档转为 SVG,但是 document 文档里面包含了外部链接的图片资源、外部样式资源和脚本资源。这种情况在 SVG 是不支持的,所以这一步的处理方式是把所有的外部资源,处理为内联形式的,改造为新的 document,比如:
以上这种文档结构中,所有的资源都是属于外部资源,如果要转变为 SVG,就需要处理成内联的形式,构造新的 document 文档,如下:
所以上一步把所有截图形式都处理成为了渲染一个 document 文档之后,就需要对文档进行重构转换,处理文档内部所有外部资源,不同的资源对应不同的处理方式,这里需要处理的资源情况分为以下几点:
在 HTML 文档中存在 img 图片标签的链接为外部资源,需要处理为 base64 资源,通过 loadAndInlineIages 函数进行处理,以下是 loadAndInlineIages 函数。
loadAndInlineImages 函数的处理流程是获取到所有和图片有关的标签,在通过 Ajajx 请求下来,然后处理成 Base64 的资源类型,对原有的图片标签进行替换,这样就把所有的标签图片,处理成为了内联资源类型。以下是 encodeImageAsDataURI 方法内部请求图片资源且转义 Base64 的逻辑:
通过了以上步骤之后,此时的 document 文档里面的图片标签元素的资源已经全部为内联形式了
在 HTML 中同时也存在着脚本为外部资源的情况,对于脚本的处理逻辑,整体就比较简单了,获取到脚本的链接,请求脚本内容,之后用请求的内容替换原有的外部链接的