html2canvas绘制原理(源码)解析

453 阅读13分钟

做了很多运营活动h5页面,经常需要实现一些根据信息生成图文卡片下载和分享到端外的逻辑,有时候会使用手写的方式,有时候为了效率,也会使用html2canva来生成,为了更好的排查html2canvas生成图片与原html绘制存在的差异问题,这次就来详细探究下它的绘制原理。

html2canvas是一个网页截图脚本,允许我们直接在浏览器上对整个或者部分网页进行“截图”。它的“截图”不是我们熟知的把屏幕给截图下来,而是根据页面上的dom信息在canvas上重新进行一次模拟浏览器页面的绘制,因此它“截图”下来的页面并不是100%准确的。

克隆document

首先会克隆目标节点所在的文档节点 documentElement。

node.cloneNode(deep); ---- 如果单纯这么做,不能应付很多的特殊情况,所以需要手动进行特殊情况的处理

需要结合node.nodeTypenode.tagName判断元素类型,对特殊节点进行额外处理

递归遍历documentElement,拿到一个node时,首先克隆node自身,根据node的元素类型进行不同的克隆处理

  1. 文本元素

    document.createTextNode(node.data)

  2. canvas元素

    使用node.cloneNode克隆一个canvas元素,并把原canvas的图像绘制到克隆canvas

    clonedCtx.putImageData(ctx.getImageData(0, 0, canvas.width, canvas.height), 0, 0);

  3. video元素

    将video绘制到一个宽高相同的canvas中

    ctx.drawImage(video, 0, 0, canvas.width, canvas.height);

  4. style元素(<style>标签)

    使用node.cloneNode克隆一个style元素,并手动将node.sheet.cssRules列表中取到的css文本rule.cssText填入到新的<style>标签中

    style.textContent = [].slice.call(sheet.cssRules, 0).reduce((css, rule) => {
     return css + rule.cssText;
    }, '');
    
  5. image元素 将loading属性设置了延迟加载的图片改为尽早加载

    if (clone.loading === 'lazy') {
      clone.loading = 'eager';
    }
    
  6. custom元素(自定义元素)

    创建一个自定义标签替换custom标签

  7. 其它

    node.cloneNode(deep)

然后克隆node的before和after伪元素

为了方便绘制,node的beforeafter伪元素在克隆时会使用真元素来替代,并将克隆好的元素使用insertBeforeinsertAfter插入node的开头或结尾

  1. content是url

    创建img标签加载该url

  2. content是attr(x)函数

    从node中拿到x属性的值,作为克隆元素的内容

  3. content是open-quoteclose-quote关键字

    插入quotes属性中设置的引号字符

  4. 其它

    直接插入内容文本

随后创建iframe插入body,并将克隆好的documentElement插入iframe中,然后才可以获取到经过浏览器绘制真实呈现的节点及样式。

    const documentClone = iframe.contentWindow.document;
    documentClone.open();
    documentClone.write(`${serializeDoctype(document.doctype)}<html></html>`);
    const cloneDocumentElement = documentClone.adoptNode(this.documentElement);
    documentClone.replaceChild(cloneDocumentElement, documentClone.documentElement);
    documentClone.close();

Document.adoptNode()

从其他的document文档中获取一个节点。 该节点以及它的子树上的所有节点都会从原文档删除 (如果有这个节点的话), 并且它的ownerDocument 属性会变成当前的document文档。 之后你可以把这个节点插入到当前文档中。

等待iframe加载完成(触发iframe.onload)

  1. 将所有需要滚动的元素滚动到相应位置

  2. 等待字体加载完成

    await documentClone.fonts.ready

  3. 等待图片加载完成

    通过document.images拿到所有图片,image.completeimage.onload判断图片加载完成

  4. 如果传入了onclone参数,则将iframe的文档对象documentClone和截图目标根元素的克隆clonedReferenceElement传给onclone函数,此时可以修改需要截图的元素信息,onclone函数需要返回一个promise

    return Promise.resolve()
       .then(() => onclone(documentClone, clonedReferenceElement))
       .then(() => iframe);
    

绘制截图

从克隆iframe中获取需要截图的克隆根元素clonedElement

使用svg绘制

如果需要使用svg绘制而非canvas绘制,在克隆元素的阶段,还需要将nodebeforeafter伪元素通过window.getComputedStyle获得的CSSStyleDeclaration对象中的所有css属性直接设置到新的克隆节点的内联样式里,转成svg时只能读取到元素的内联样式。

  1. 初始化绘制选项(定好宽高、偏移、缩放等)
  2. 根据绘制选项创建画布
  3. 根据绘制选项创建包含<foreignObject>标签的<svg>标签并把clonedElement放入<foreignObject>标签中
  4. 将svg转换成图片
    const img = new Image();
    img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(new XMLSerializer().serializeToString(svg))}`;

new XMLSerializer().serializeToString(node)

将一个节点对象序列化为xml

  1. 将图片绘制到画布中

使用canvas绘制

浏览器渲染页面主要分为下面几步:

  1. 解析HTML生成DOM树。

  2. 解析CSS生成CSSOM规则树。

  3. 将DOM树与CSSOM规则树合并生成渲染树。从DOM树的根节点开始遍历每个可见节点。对每个可见节点,找到其适配的CSS样式规则并应用。

  4. 遍历渲染树开始布局,确定每个节点对象在页面上的确切大小与位置。

  5. 将渲染树每个节点绘制到屏幕。由于CSS有z-index、transform,float等属性会改变文档流或者造成屏幕在垂直方向上的元素重叠,就需要先确定元素在垂直方向的顺序,同一层的元素按顺序绘制,依次绘制每一层的元素。

此处的层指的是元素在垂直方向上的显示顺序(层叠上下文中的层级),而并非指浏览器绘制页面时将带了trnaform/opacity/willchange等样式的元素分层并将每一层单独绘制(我们常说的启用GPU加速绘制)

在使用canvas绘制时,会模拟渲染页面的后三步,合成,布局和绘制。

解析节点信息,构建元素容器树

递归遍历clonedElement及其所有子元素,为每个可见(判断display、opacity、visibility)元素构建自定义的元素容器对象elementContainer(类似虚拟dom,即使用js对象来描述一个dom节点)。

对每个可见节点,找到其适配的CSS样式规则并应用。确定每个节点对象在页面上的确切大小与位置。

dom容器节点的基本结构

    class ElementContainer {
      Styles: {
        margin;
        padding;
        fontSize;
        ...
      };
      TextNodes;
      Elements;
      Bounds: {
        left;
        top;
        width;
        height;
      };
      Flags;
    }

每个元素容器对象有四个关键属性:

styles : Object: 通过window.getComputedStyle(element)获取元素的样式,并对元素的每个支持绘制的样式格式化并存储在styles对象中(设置初始值,剔除不支持的取值,将多个取值合并为数组,将代表某个数值的关键字替换为该数值,合并意义相同的取值等)

textNodes : Array: dom容器递归遍历得到的每个文本子容器挂载到该属性中

elements : Array: dom容器递归遍历得到的每个非文本子容器挂载到该属性中

Bounds : Object: 通过element.getBoundingClientRect()获取并存储元素节点的宽高和定位

flags : Number : 决定如何渲染的标志,区分普通上下文元素,层叠上下文元素,列表父元素以及供开发调试的debug元素

对于一些特殊的节点,元素的容器对象在构建时还需要存储更多的额外信息

  1. Text -- 构建Text容器 ,通过css-line-break库将文本的每一行都给切分出来,将每行文本和位置大小等信息存储在textBounds数组中,容器额外存入texttextBounds

css-line-break是一个实现了Unicode换行算法,计算文本合适换行位置的开源库

  1. Image -- 构建Image容器,存入src,width,height

Image的src存到cache中,首次存入cache的src将请求图片加载,重复的图片无需再次加载

  1. Canvas -- 构建Canvas容器,存入canvas节点,width,height

  2. SVG -- 将svg转成base64图片链接,存入svg,width,height,svg的base64链接存到cache中

  3. LI -- 构建li容器,存入value(列表项在列表中的序号)

  4. OL -- 构建ol容器,存入start(列表起始序号)和reversed(列表序号是否反转)

  5. input -- 根据type重制样式,存入type,checked和value

  6. select -- 获取选中项的显示文本存入value

const value = element.options[element.selectedIndex].text

  1. TextArea -- 构建textarea容器,存入value

  2. Iframe -- 获取iframe内部dom树,构建一个容器树,存入tree

......

最终构建得到一个容器树。

构建绘制层级树

递归遍历元素容器树,构建绘制层级树

在遍历父元素的子元素列表时,会进行下面的步骤:

1. 为子元素容器创建绘制对象ElementPaint

    class ElementPaint {
      container: ElementContainer;
      parent: ElementPaint;
        effects;
        curves: BoundCurves;  // src/render/bound-curves.ts
        listValue;
    }

container: 存储元素的容器对象elementContainer

parent: 父元素的ElementPaint对象,对于根元素是null。

effects: 存储对元素本身及其所有后代元素均有影响的属性(opacity、transform、overflow)

curves:构建自定义元素盒子并存储它在画布上需要绘制的盒子中每一层的位置。

我们知道的盒模型一般可以分为四层,从内到外分别为content、padding、border、margin。

1.png

但在html2canvas中,将一个元素盒子完整地绘制到canvas,会将盒模型分为六层,分别为content、padding、borderDoubleInner(距离padding 1/3 个 border长度处)、borderStorke(距离padding 1/2 个 border长度处),borderDoubleOuter(距离padding 2/3 个 border长度处)、border。并省略margin层。

2.png

对于每一层,需要根据元素容器属性boundsstyles对象内的相应属性值,计算并存储四个顶点在canvas中的位置(x, y),如果该元素盒子设置了border-radius,则需要改为计算并存储四个顶点方向的三次贝塞尔曲线参数。

注意

在获取元素容器内包含单位(px、rem等)的样式值时,会先调用getAbsoluteValue方法获取样式的数值number和单位unit,并将不同单位的数值转换为px单位的数值,但在该方法内实现转换rem和em单位为px时,将转换公式写死为16 * number,并在注释中附上了TODO字样......

// src/css/types/length-percentage.ts
return 16 * token.number; // TODO use correct font-size
2. 为元素容器创建层叠对象(层叠上下文)

先来复习一下有关层叠上下文的知识,层叠上下文(Stacking Context)是 CSS 中控制元素层叠顺序的核心机制。当元素发生重叠时,浏览器会通过层叠上下文的规则决定它们的显示优先级。

普通层叠上下文

普通层叠上下文分为七层,如果一个元素创建了一个普通层叠上下文,该元素和它的所有后代都会被分到这个层叠上下文中,限制在元素所在层叠上下文中进行绘制,它会按照下图所示的绘制层级顺序进行元素的绘制。

3.png

‼️‼️‼️对层叠上下文熟悉的同学已经发现上图有一处错误,上图中指示的层叠上下文会把文本内容放在一个“content”层绘制,导致图中出现了八层,而真正的层叠上下文其实并没有“content”层,只有其他七层,文本内容应该放在 "inline/inline-block盒子"这一层与其他内联元素一起按顺序绘制。

上图展示的是html2canvas源码中对于层叠上下文的错误概念实现,html2canvas在绘制时,就是将文本内容放在一个单独的"content"层进行绘制,因此它在特定情况下会导致一个与真实页面渲染不一致的bug。查看示例(“normal text”实际绘制与截图的区别)

会在下列情况下,为元素创建一个普通层叠上下文。

  • opacity < 1
  • transform !== none
  • position !== static && zIndex !== auto
  • ...

html2canvas中只实现了上面列出的前三条,意味着如果页面使用了其他会导致创建层叠上下文的样式,在canvas2html中可能无法正常工作

文档根元素视为一个普通层叠上下文。

低级层叠上下文

可以理解为除了普通层叠上下文以外,还有一个低级层叠上下文,这个上下文比普通层叠上下文少了一些层级,如果一个元素创建了一个低级层叠上下文,那么只有它的所有浮动元素后代、普通块后代、普通内联后代才会被分到这个低级层叠上下文中绘制,其他后代则需要继续向上寻找到最近的一个能容纳它们的普通层叠上下文。它里面会按照下图所示的绘制层级顺序进行遍历和绘制。

4.png

在下列情况下,元素会创建一个低级层叠上下文。

  • position !== static && zIndex == auto

  • float !== none

普通元素

当元素是普通的块元素或普通内联元素,则该元素不创建任何层叠对象,它的所有后代元素的绘制层级顺序都不受该元素影响,绘制时只管绘制自己的背景边框和内容即可。

5.png

设置了不同属性的元素会被分到距离元素最近的层叠上下文的不同层级里面,在一个层级中的元素需要遵循的两条规则来确定绘制顺序:

  1. 谁大谁上: 当具有明确的层叠水平值的时候,如z-indx值,在同一个层级里面,层叠水平值大的那一个覆盖在小的那一个上方。

  2. 后来居上: 当元素的所处的层级相同、层叠水平值相同的时候,处于后面的元素会覆盖在前面元素的上方。

html2canvas会为每个符合条件的元素创建层叠对象来实现层叠上下文的概念。 (在源码中,只有一种StackingContext对象,为了方便讲解,将其分为两种)

    class StackingContext { // 低级层叠对象 - 低级层叠上下文
        element: ElementPaint;
        nonPositionedFloats: StackingContext;
        inlineLevel: ElementPaint;
        nonInlineLevel: ElementPaint;
    }

    class RealStackingContext { // 高级层叠对象 - 普通层叠上下文呢
        element: ElementPaint;
        negativeZIndex: RealStackingContext;
        positiveZIndex: RealStackingContext;
        nonPositionedFloats: StackingContext;
        inlineLevel: ElementPaint;
        nonInlineLevel: ElementPaint;
        zeroOrAutoZIndexOrTransformedOrOpacity: StackingContext | RealStackingContext;
    }

element: 存储元素的绘制对象elementPaint

negativeZIndex: 存储zIndex小于0的后代层叠对象。

positiveZIndex: 存储z-index大于0的后代层叠对象。

zeroOrAutoZIndexOrTransformedOrOpacity: 存储z-index为auto或0、opacity小于1或transform不为none的后代层叠对象。

nonPositionedFloats: 存储没有定位的浮动后代层叠对象。

inlineLevel: 存储内联后代绘制对象。

nonInlineLevel: 存储非内联后代绘制对象。

3. 确定元素所在的绘制层级

根据元素满足的条件,找到离元素最近的含有该条件对应层级的祖先层叠对象,如果元素是普通块或普通内联元素,将元素的绘制对象push到对应层级的数组中,否则将元素的层叠对象push到对应层级的数组中。如果元素是正zIndex盒子或负zIndex盒子,还需要根据zIndex找到它在数组中的对应位置(谁大谁上)。

比如该子元素为正Index盒子,则需要找到最近的含有positiveZIndex属性的祖先高级层叠对象,并根据zIndex的值插入positiveZIndex数组中相应的位置。

4. 深度递归遍历该元素的后代元素,构建绘制层级树

对该子元素的后代元素进行深度递归遍历,并按上述规则将所有子孙元素存储到相应的层叠对象属性中。

将元素绘制到canvas

最后一步就是递归遍历得到的绘制层级树,并按照上一步所示的层叠绘制规则,按顺序将所有元素绘制到画布中的置顶位置。

使用renderStackContent方法解析并绘制一个层叠顺序对象:

        async renderStackContent(stack: StackingContext): Promise<void> {
        // 1. 绘制元素背景和边框
            await this.renderNodeBackgroundAndBorders(stack.element);
        // 2. 递归解析并绘制zIndex为负值的子孙元素列表
            for (const child of stack.negativeZIndex) {
                await this.renderStackContent(child);
           }
        // 3. 绘制元素的内容
            await this.renderNodeContent(stack.element);
        // 4. 绘制元素的块级子孙元素
            for (const child of stack.nonInlineLevel) {
                await this.renderNode(child);
           }
        // 5. 递归解析并绘制元素的浮动子孙元素列表
            for (const child of stack.nonPositionedFloats) {
                await this.renderStackContent(child);
           }
        // 6. 绘制元素的内联子孙元素
            for (const child of stack.inlineLevel) {
                await this.renderNode(child);
           }
        // 7. 递归解析并绘制zIndex为0或auto、opacity < 1、transformed不等于none的子孙元素列表
            for (const child of stack.zeroOrAutoZIndexOrTransformedOrOpacity) {
                await this.renderStackContent(child);
           }
        // 8. 递归解析并绘制zIndex为正值的子孙元素列表
            for (const child of stack.positiveZIndex) {
                await this.renderStackContent(child);
           }
       }
  1. 绘制元素之前,需要根据元素是否拥有会对元素本身及其所有后代元素均有影响的属性(opacity、transform、overflow),对画布进行处理

opacity: 通过ctx.globalAlpha属性设置透明度

transform: 通过ctx.transform方法设置3D变换

overflow:通过ctx.clip方法设置画布的可绘制区域(防止溢出)

  1. 绘制背景和边框(background/border层)
  • 根据backgroundClip确定并绘制背景

  • 根据borderStyle使用盒模型不同层的数据进行边框绘制

    dotted: 以borderStorke为基准绘制

    double: 以borderDoubleInnerborderDoubleInner为基准绘制

borderStyle仅支持dotted、double、dashed、solid取值

  1. 绘制元素的内容(content层)
  • 逐行绘制每行的文本内容
  • 如果是img、canvas、svg元素,绘制对应图像
  • 如果是input元素,绘制标准的input元素图像

......